From 1c59ed688c1201d33dffaa58f401e8d6ebe5d505 Mon Sep 17 00:00:00 2001 From: JannikStreek Date: Sat, 30 May 2026 14:30:58 +0200 Subject: [PATCH] harden backend against malicious input --- backend/src/httpRouter.test.ts | 18 ++++++++++++++++++ backend/src/httpRouter.ts | 12 +++++++++++- backend/src/model/document.test.ts | 17 +++++++++++++++++ backend/src/model/document.ts | 6 +++++- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/backend/src/httpRouter.test.ts b/backend/src/httpRouter.test.ts index 65a9b43..87e890a 100644 --- a/backend/src/httpRouter.test.ts +++ b/backend/src/httpRouter.test.ts @@ -78,6 +78,24 @@ describe("httpRouter", () => { vi.unstubAllEnvs(); }); + it("rejects a person id with a non-string pid claim", async () => { + vi.stubEnv("JWT_SECRET", "test"); + const payload = mock(); + // A validly signed token whose pid is an object, not a string. + const secret = jwt.sign({ pid: {} }, "test", { + algorithm: "HS256", + }); + payload.request.method = "GET"; + payload.request.url = "/documents"; + payload.request.headers = { cookie: `person_id=${secret}` }; + + await expect(httpRouter(payload, null)).rejects.toThrow(); + // The object pid must be rejected, so the extracted person id is null. + const personId = vi.mocked(handleGetOwnDocumentsRequest).mock.calls[0][2]; + expect(personId).toBeNull(); + vi.unstubAllEnvs(); + }); + it("creates images", async () => { const payload = mock(); payload.request.method = "POST"; diff --git a/backend/src/httpRouter.ts b/backend/src/httpRouter.ts index a9b376d..8a8410b 100644 --- a/backend/src/httpRouter.ts +++ b/backend/src/httpRouter.ts @@ -115,7 +115,17 @@ function extractPersonIdFromCookies(cookies?: string): string | null { try { const decoded = jwt.verify(personId, secret, { algorithms: ["HS256"], - }) as { pid: string }; + }); + + if ( + typeof decoded !== "object" || + decoded === null || + typeof decoded.pid !== "string" || + decoded.pid.length === 0 + ) { + return null; + } + return decoded.pid; } catch { console.error("JWT verification failed for person_id cookie"); diff --git a/backend/src/model/document.test.ts b/backend/src/model/document.test.ts index 50eecc1..16f4ec1 100644 --- a/backend/src/model/document.test.ts +++ b/backend/src/model/document.test.ts @@ -130,6 +130,13 @@ describe("deleteDocument", () => { expect(result).toBeFalsy(); expect(prismaMock.document.delete).not.toHaveBeenCalled(); }); + + it("should not query Prisma when the document id is not a valid UUID", async () => { + const result = await deleteDocument(prismaMock, "not-a-uuid", "secret"); + + expect(result).toBeFalsy(); + expect(prismaMock.document.findFirst).not.toHaveBeenCalled(); + }); }); describe("updateLastAccessedAt", () => { @@ -328,5 +335,15 @@ describe("document ownership", () => { expect(docs).toEqual([]); expect(prismaMock.document.findMany).not.toHaveBeenCalled(); }); + + it("returns an empty list if ownerExternalId is not a string", async () => { + // Guards against a Prisma filter object reaching the where clause. + const docs = await getDocumentsByOwner(prismaMock, { + not: null, + } as unknown as string); + + expect(docs).toEqual([]); + expect(prismaMock.document.findMany).not.toHaveBeenCalled(); + }); }); }); diff --git a/backend/src/model/document.ts b/backend/src/model/document.ts index bff0ecb..c94cf3b 100644 --- a/backend/src/model/document.ts +++ b/backend/src/model/document.ts @@ -33,7 +33,9 @@ export const getDocumentsByOwner = async ( prisma: PrismaClient, ownerExternalId: string, ) => { - if (!ownerExternalId) return []; + if (typeof ownerExternalId !== "string" || ownerExternalId.length === 0) { + return []; + } return prisma.document.findMany({ where: { ownerExternalId: ownerExternalId, @@ -107,6 +109,8 @@ export const deleteDocument = async ( documentName: string, modificationSecret: string, ): Promise => { + if (!isValidUUID(documentName)) return false; + try { console.info(`Deleting document ${documentName}`);