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:
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> {
|
||||
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}`, {
|
||||
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}`, {
|
||||
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}`, {
|
||||
return apiFetch<{ id: string }>(
|
||||
`/raw-material-lots/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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`, {
|
||||
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`, {
|
||||
return apiFetch<BatchStock>(
|
||||
`/${path}/${encodeURIComponent(id)}/corrections`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user