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
+74 -14

No files matched your search

@@ -18,7 +18,9 @@ export function fetchProductionBatches(
} }
export function fetchProductionBatch(id: string): Promise<ProductionBatch> { export function fetchProductionBatch(id: string): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(`/production-batches/${id}`); return apiFetch<ProductionBatch>(
`/production-batches/${encodeURIComponent(id)}`,
);
} }
export function createProductionBatch( export function createProductionBatch(
@@ -34,14 +36,20 @@ export function updateProductionBatch(
id: string, id: string,
input: UpdateProductionBatchInput, input: UpdateProductionBatchInput,
): Promise<ProductionBatch> { ): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(`/production-batches/${id}`, { return apiFetch<ProductionBatch>(
`/production-batches/${encodeURIComponent(id)}`,
{
method: "PATCH", method: "PATCH",
body: JSON.stringify(input), body: JSON.stringify(input),
}); },
);
} }
export function deleteProductionBatch(id: string): Promise<{ id: string }> { export function deleteProductionBatch(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/production-batches/${id}`, { return apiFetch<{ id: string }>(
`/production-batches/${encodeURIComponent(id)}`,
{
method: "DELETE", method: "DELETE",
}); },
);
} }
@@ -13,7 +13,11 @@ export interface ProductionBatch {
inputs?: { inputs?: {
id: string; id: string;
quantity: number; 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> { 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( export function createRawMaterialLot(
@@ -27,7 +29,10 @@ export function createRawMaterialLot(
} }
export function deleteRawMaterialLot(id: string): Promise<{ id: string }> { export function deleteRawMaterialLot(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/raw-material-lots/${id}`, { return apiFetch<{ id: string }>(
`/raw-material-lots/${encodeURIComponent(id)}`,
{
method: "DELETE", method: "DELETE",
}); },
);
} }
+11 -5
View File
@@ -10,7 +10,7 @@ export function fetchStock(
path: StockBatchPath, path: StockBatchPath,
id: string, id: string,
): Promise<BatchStock> { ): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/stock`); return apiFetch<BatchStock>(`/${path}/${encodeURIComponent(id)}/stock`);
} }
export function withdrawStock( export function withdrawStock(
@@ -18,10 +18,13 @@ export function withdrawStock(
id: string, id: string,
input: WithdrawInput, input: WithdrawInput,
): Promise<BatchStock> { ): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/withdrawals`, { return apiFetch<BatchStock>(
`/${path}/${encodeURIComponent(id)}/withdrawals`,
{
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); },
);
} }
export function correctStock( export function correctStock(
@@ -29,8 +32,11 @@ export function correctStock(
id: string, id: string,
input: CorrectInput, input: CorrectInput,
): Promise<BatchStock> { ): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/corrections`, { return apiFetch<BatchStock>(
`/${path}/${encodeURIComponent(id)}/corrections`,
{
method: "POST", method: "POST",
body: JSON.stringify(input), body: JSON.stringify(input),
}); },
);
} }