Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions sdk/typescript/src/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ export async function handleFindingsRequest(
}

async function readJson(request: IncomingMessage): Promise<unknown> {
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 {
Expand Down
70 changes: 70 additions & 0 deletions sdk/typescript/tests-ts/findings-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<never[]>>().mockResolvedValue([]);
Expand All @@ -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);
Expand Down
Loading