diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 517fc57824..edba4db47c 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -2167,6 +2167,12 @@ network binding. ### API +Mutation requests to `POST /v1/bulk/findings` and `POST /v1/dedupe-groups` require +`Content-Type: application/json`; charset parameters are accepted. Other media +types, including a missing content type, return HTTP 400 `invalid_request` +before embedding or storage. The API remains unauthenticated and requires an +authenticated TLS proxy before sharing access. + `POST /v1/bulk/findings` accepts `{"findings": [...]}`, using the existing SDK `Finding` model, including `findingId`, `occurrenceId`, and `fingerprints`. A complete exported `findings.json` document is also accepted; only its diff --git a/sdk/typescript/src/server/routes.ts b/sdk/typescript/src/server/routes.ts index 3dfa3d072b..040daf6734 100644 --- a/sdk/typescript/src/server/routes.ts +++ b/sdk/typescript/src/server/routes.ts @@ -118,6 +118,17 @@ export async function handleFindingsRequest( } async function readJson(request: IncomingMessage): Promise { + const mediaType = request.headers["content-type"] + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + if (mediaType !== "application/json") { + request.resume(); + throw new FindingsError( + "invalid_request", + "Request body must use application/json.", + ); + } const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); try { diff --git a/sdk/typescript/tests-ts/findings-server.test.ts b/sdk/typescript/tests-ts/findings-server.test.ts index bad754cd7b..be0731349d 100644 --- a/sdk/typescript/tests-ts/findings-server.test.ts +++ b/sdk/typescript/tests-ts/findings-server.test.ts @@ -906,6 +906,7 @@ test("imports persist repository associations and keep untagged findings in expl ( await fetch(`${base}/v1/bulk/findings`, { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ findings: [findings[2]] }), }) ).status, @@ -941,6 +942,74 @@ test("imports persist repository associations and keep untagged findings in expl ]); }); +test.each([ + undefined, + "text/plain;charset=UTF-8", + "application/x-www-form-urlencoded", + "multipart/form-data; boundary=synthetic-qa", +])( + "rejects non-JSON mutation bodies before side effects: %s", + async (mediaType) => { + const { store } = await fixture(); + const embed = mock(embedder.embed); + const base = await start(store, { embed }); + const existing = [finding(1), finding(2)]; + expect((await insert(base, existing)).status).toBe(201); + embed.mockClear(); + const writeGroups = spyOn(store, "storeDedupeGroups"); + try { + for (const [path, body] of [ + ["/v1/bulk/findings", { findings: [finding(3)] }], + ["/v1/dedupe-groups", { groups: [existing.map((f) => f.findingId)] }], + ] as const) { + const response = await fetch(base + path, { + method: "POST", + headers: { + Origin: "null", + "Sec-Fetch-Site": "cross-site", + ...(mediaType === undefined ? {} : { "Content-Type": mediaType }), + }, + body: Buffer.from(JSON.stringify(body)), + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "invalid_request", + message: "Request body must use application/json.", + }); + } + expect(embed).not.toHaveBeenCalled(); + expect(writeGroups).not.toHaveBeenCalled(); + expect( + (await (await fetch(base + "/v1/findings")).json()).findings, + ).toEqual(existing); + expect(await getGroups(base, existing[0]!.findingId)).toEqual([]); + } finally { + writeGroups.mockRestore(); + } + }, +); + +test.each(["application/json", "Application/JSON; charset=UTF-8"])( + "accepts JSON mutation bodies with MIME type %s", + async (mediaType) => { + const { store } = await fixture(); + const base = await start(store); + const findings = [finding(1), finding(2)]; + for (const [path, body] of [ + ["/v1/bulk/findings", { findings }], + ["/v1/dedupe-groups", { groups: [findings.map((f) => f.findingId)] }], + ] as const) { + const response = await fetch(base + path, { + method: "POST", + headers: { "Content-Type": mediaType }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(201); + } + expect(await getGroups(base, findings[0]!.findingId)).toHaveLength(1); + }, +); + test("rejects invalid requests before embedding and preserves unknown-route behavior", async () => { const { store } = await fixture(); const embed = mock<() => Promise>().mockResolvedValue([]); @@ -959,6 +1028,7 @@ test("rejects invalid requests before embedding and preserves unknown-route beha ]) { const response = await fetch(`${base}/v1/bulk/findings`, { method: "POST", + headers: { "Content-Type": "application/json" }, body, }); expect(response.status).toBe(400);