From a7609ff3f89101c310ff4682d82bfcf19b825b1e Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 6 Oct 2026 06:34:07 +0200 Subject: [PATCH] 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) --- .../src/lib/production-batches/api.ts | 24 ++++++++---- .../src/lib/production-batches/types.ts | 6 ++- .../src/lib/raw-materials/api.spec.ts | 37 +++++++++++++++++++ ApiCore-Frontend/src/lib/raw-materials/api.ts | 13 +++++-- ApiCore-Frontend/src/lib/stock/api.ts | 24 +++++++----- 5 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 ApiCore-Frontend/src/lib/raw-materials/api.spec.ts diff --git a/ApiCore-Frontend/src/lib/production-batches/api.ts b/ApiCore-Frontend/src/lib/production-batches/api.ts index d7aea9f..f3c1071 100644 --- a/ApiCore-Frontend/src/lib/production-batches/api.ts +++ b/ApiCore-Frontend/src/lib/production-batches/api.ts @@ -18,7 +18,9 @@ export function fetchProductionBatches( } export function fetchProductionBatch(id: string): Promise { - return apiFetch(`/production-batches/${id}`); + return apiFetch( + `/production-batches/${encodeURIComponent(id)}`, + ); } export function createProductionBatch( @@ -34,14 +36,20 @@ export function updateProductionBatch( id: string, input: UpdateProductionBatchInput, ): Promise { - return apiFetch(`/production-batches/${id}`, { - method: "PATCH", - body: JSON.stringify(input), - }); + return apiFetch( + `/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", + }, + ); } diff --git a/ApiCore-Frontend/src/lib/production-batches/types.ts b/ApiCore-Frontend/src/lib/production-batches/types.ts index a875ae0..4d71749 100644 --- a/ApiCore-Frontend/src/lib/production-batches/types.ts +++ b/ApiCore-Frontend/src/lib/production-batches/types.ts @@ -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"; + }; }[]; } diff --git a/ApiCore-Frontend/src/lib/raw-materials/api.spec.ts b/ApiCore-Frontend/src/lib/raw-materials/api.spec.ts new file mode 100644 index 0000000..9ce6b2c --- /dev/null +++ b/ApiCore-Frontend/src/lib/raw-materials/api.spec.ts @@ -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", + ]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/raw-materials/api.ts b/ApiCore-Frontend/src/lib/raw-materials/api.ts index e5a92cd..988d9f8 100644 --- a/ApiCore-Frontend/src/lib/raw-materials/api.ts +++ b/ApiCore-Frontend/src/lib/raw-materials/api.ts @@ -14,7 +14,9 @@ export function fetchRawMaterialLots( } export function fetchRawMaterialLot(id: string): Promise { - return apiFetch(`/raw-material-lots/${id}`); + return apiFetch( + `/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", + }, + ); } diff --git a/ApiCore-Frontend/src/lib/stock/api.ts b/ApiCore-Frontend/src/lib/stock/api.ts index 5db76ce..be1ed4f 100644 --- a/ApiCore-Frontend/src/lib/stock/api.ts +++ b/ApiCore-Frontend/src/lib/stock/api.ts @@ -10,7 +10,7 @@ export function fetchStock( path: StockBatchPath, id: string, ): Promise { - return apiFetch(`/${path}/${id}/stock`); + return apiFetch(`/${path}/${encodeURIComponent(id)}/stock`); } export function withdrawStock( @@ -18,10 +18,13 @@ export function withdrawStock( id: string, input: WithdrawInput, ): Promise { - return apiFetch(`/${path}/${id}/withdrawals`, { - method: "POST", - body: JSON.stringify(input), - }); + return apiFetch( + `/${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 { - return apiFetch(`/${path}/${id}/corrections`, { - method: "POST", - body: JSON.stringify(input), - }); + return apiFetch( + `/${path}/${encodeURIComponent(id)}/corrections`, + { + method: "POST", + body: JSON.stringify(input), + }, + ); }