fix(ui): encode ids in raw material, stock and production batch API paths

Security review finding (client-side path traversal): ids taken from the
URL were interpolated into API paths unencoded, so a crafted link like
/raw-materials/..%2Fusers made the page call another endpoint with the
viewer's token. Ids are now encodeURIComponent-ed; a regression test pins
"../users" to its own path segment.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 06:34:07 +02:00
1 parent 44971c23e6
commit a7609ff3f8
5 files changed
+82 -22

No files matched your search

@@ -18,7 +18,9 @@ export function fetchProductionBatches(
}
export function fetchProductionBatch(id: string): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(`/production-batches/${id}`);
return apiFetch<ProductionBatch>(
`/production-batches/${encodeURIComponent(id)}`,
);
}
export function createProductionBatch(
@@ -34,14 +36,20 @@ export function updateProductionBatch(
id: string,
input: UpdateProductionBatchInput,
): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(`/production-batches/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
return apiFetch<ProductionBatch>(
`/production-batches/${encodeURIComponent(id)}`,
{
method: "PATCH",
body: JSON.stringify(input),
},
);
}
export function deleteProductionBatch(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/production-batches/${id}`, {
method: "DELETE",
});
return apiFetch<{ id: string }>(
`/production-batches/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
);
}
@@ -13,7 +13,11 @@ export interface ProductionBatch {
inputs?: {
id: string;
quantity: number;
rawMaterialLot: { id: string; lotNumber: string; kind: "BEESWAX" | "PROPOLIS" };
rawMaterialLot: {
id: string;
lotNumber: string;
kind: "BEESWAX" | "PROPOLIS";
};
}[];
}
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { deleteRawMaterialLot, fetchRawMaterialLot } from "./api";
import { fetchStock } from "@/lib/stock/api";
import { fetchProductionBatch } from "@/lib/production-batches/api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(async () => ({})) }));
/**
* Ids come from the URL (useParams). Unencoded, a crafted link such as
* /raw-materials/..%2Fusers would make the page call another endpoint with
* the viewer's token (client-side path traversal). Every helper must keep
* the id inside its own path segment.
*/
describe("id encoding in API paths", () => {
beforeEach(() => vi.mocked(apiFetch).mockClear());
const hostile = "../users";
it("keeps a raw material id inside its path segment", async () => {
await fetchRawMaterialLot(hostile);
await deleteRawMaterialLot(hostile);
expect(vi.mocked(apiFetch).mock.calls.map(([path]) => path)).toEqual([
"/raw-material-lots/..%2Fusers",
"/raw-material-lots/..%2Fusers",
]);
});
it("keeps stock and production batch ids inside their path segment", async () => {
await fetchStock("production-batches", hostile);
await fetchProductionBatch(hostile);
expect(vi.mocked(apiFetch).mock.calls.map(([path]) => path)).toEqual([
"/production-batches/..%2Fusers/stock",
"/production-batches/..%2Fusers",
]);
});
});
@@ -14,7 +14,9 @@ export function fetchRawMaterialLots(
}
export function fetchRawMaterialLot(id: string): Promise<RawMaterialLot> {
return apiFetch<RawMaterialLot>(`/raw-material-lots/${id}`);
return apiFetch<RawMaterialLot>(
`/raw-material-lots/${encodeURIComponent(id)}`,
);
}
export function createRawMaterialLot(
@@ -27,7 +29,10 @@ export function createRawMaterialLot(
}
export function deleteRawMaterialLot(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/raw-material-lots/${id}`, {
method: "DELETE",
});
return apiFetch<{ id: string }>(
`/raw-material-lots/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
);
}
+15 -9
View File
@@ -10,7 +10,7 @@ export function fetchStock(
path: StockBatchPath,
id: string,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/stock`);
return apiFetch<BatchStock>(`/${path}/${encodeURIComponent(id)}/stock`);
}
export function withdrawStock(
@@ -18,10 +18,13 @@ export function withdrawStock(
id: string,
input: WithdrawInput,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/withdrawals`, {
method: "POST",
body: JSON.stringify(input),
});
return apiFetch<BatchStock>(
`/${path}/${encodeURIComponent(id)}/withdrawals`,
{
method: "POST",
body: JSON.stringify(input),
},
);
}
export function correctStock(
@@ -29,8 +32,11 @@ export function correctStock(
id: string,
input: CorrectInput,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/corrections`, {
method: "POST",
body: JSON.stringify(input),
});
return apiFetch<BatchStock>(
`/${path}/${encodeURIComponent(id)}/corrections`,
{
method: "POST",
body: JSON.stringify(input),
},
);
}