From 2d8feeed65eb88243833ff1cbb74eb2f82a9faef Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:25:44 +0000 Subject: [PATCH] feat(web): enforce 2 MiB SBOM/VEX upload cap in scan form (#32) Co-authored-by: hsinatfootprintai <149764663+hsinatfootprintai@users.noreply.github.com> --- README.md | 5 ++++- web/src/components/scan-form.tsx | 26 +++++++++++++++++++++----- web/src/components/scan-page.test.tsx | 12 ++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a36771b..21da68e 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,10 @@ touchstone scan --sbom vendor-sbom.json https://vendor.example touchstone scan --sbom-url https://vendor.example/sbom.json https://vendor.example ``` -The web page has the same two inputs: an upload field and a URL field. +The web page has the same two inputs: an upload field and a URL field. The +form rejects an uploaded document over 2 MiB before sending it, and a +component built from the supplied SBOM/VEX shows as High confidence with +`SBOM_SUPPLIED` evidence in the report. ### Web page: `touchstone serve` diff --git a/web/src/components/scan-form.tsx b/web/src/components/scan-form.tsx index e8cc585..a6a5786 100644 --- a/web/src/components/scan-form.tsx +++ b/web/src/components/scan-form.tsx @@ -4,10 +4,13 @@ import { useState, type FormEvent } from "react"; import type { ScanInput } from "../lib/api"; import { isScannableUrl } from "../lib/url"; +/** The server's cap on an uploaded SBOM/VEX document (D1). */ +export const MAX_SBOM_DOCUMENT_BYTES = 2 * 1024 * 1024; + /** * One URL input, plus an optional SBOM/VEX the reviewer already has, by URL or - * upload. A value that is not an absolute http(s) URL is refused here, before - * any request. + * upload. A value that is not an absolute http(s) URL, or a file over the + * cap, is refused here, before any request. */ export function ScanForm({ onScan, busy }: { onScan: (input: ScanInput) => void; busy: boolean }) { const [value, setValue] = useState(""); @@ -15,6 +18,7 @@ export function ScanForm({ onScan, busy }: { onScan: (input: ScanInput) => void; const [sbomFile, setSbomFile] = useState(undefined); const [invalid, setInvalid] = useState(false); const [sbomUrlInvalid, setSbomUrlInvalid] = useState(false); + const [fileTooLarge, setFileTooLarge] = useState(false); const [fileError, setFileError] = useState(false); async function submit(event: FormEvent) { @@ -23,10 +27,12 @@ export function ScanForm({ onScan, busy }: { onScan: (input: ScanInput) => void; const supplied = sbomUrl.trim(); const ok = isScannableUrl(url); const suppliedOk = supplied === "" || isScannableUrl(supplied); + const tooLarge = sbomFile !== undefined && sbomFile.size > MAX_SBOM_DOCUMENT_BYTES; setInvalid(!ok); setSbomUrlInvalid(!suppliedOk); + setFileTooLarge(tooLarge); setFileError(false); - if (!ok || !suppliedOk) { + if (!ok || !suppliedOk || tooLarge) { return; } const input: ScanInput = { url }; @@ -104,12 +110,22 @@ export function ScanForm({ onScan, busy }: { onScan: (input: ScanInput) => void; name="sbomDocument" type="file" accept=".json,application/json" - onChange={(e) => setSbomFile(e.target.files?.[0])} + onChange={(e) => { + setSbomFile(e.target.files?.[0]); + setFileTooLarge(false); + }} + aria-invalid={fileTooLarge} + aria-describedby={fileTooLarge ? "sbom-file-error" : undefined} className="text-sm" /> + {fileTooLarge && ( +

+ The SBOM/VEX file is too large; the limit is 2 MiB. +

+ )} {fileError &&

The SBOM/VEX file could not be read.

} -

CycloneDX, SPDX 2.3, OpenVEX or CSAF, as JSON.

+

CycloneDX, SPDX 2.3, OpenVEX or CSAF, as JSON, up to 2 MiB.

); diff --git a/web/src/components/scan-page.test.tsx b/web/src/components/scan-page.test.tsx index 899a2ed..a45118b 100644 --- a/web/src/components/scan-page.test.tsx +++ b/web/src/components/scan-page.test.tsx @@ -78,6 +78,18 @@ describe("ScanPage", () => { expect(fetchMock.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ url: "https://example.com", sbomDocument: "e30=" })); }); + it("refuses an SBOM/VEX file over 2 MiB without sending a request", () => { + const fetchMock = stubFetch(async () => new Response(goldenText)); + render(); + + const big = new File([new Uint8Array(2 * 1024 * 1024 + 1)], "big-sbom.json", { type: "application/json" }); + fireEvent.change(screen.getByLabelText("SBOM/VEX file"), { target: { files: [big] } }); + scanFor("https://example.com"); + + expect(screen.getByText(/too large/)).toBeTruthy(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("shows the gateway's message as an error, not an empty report", async () => { stubFetch( async () => new Response('{"code":13,"message":"internal error","details":[]}', { status: 500 }),