diff --git a/app/api/uploads/sessions/route.ts b/app/api/uploads/sessions/route.ts index 520de34..24cc52b 100644 --- a/app/api/uploads/sessions/route.ts +++ b/app/api/uploads/sessions/route.ts @@ -6,7 +6,7 @@ import { apiErrorResponse, parseJson } from "@/lib/http/api"; import { secureJson } from "@/lib/security/http"; import { artifactIntentSchema, - validateArtifactIntent, + validateArtifactIntentFromUploadRequest, } from "@/lib/security/policy"; import { enforceRateLimit } from "@/lib/security/rate-limit"; import { createR2PresignedUpload } from "@/lib/storage/r2-presign"; @@ -24,7 +24,7 @@ export async function POST(request: Request) { windowMs: 24 * 60 * 60 * 1000, }); const input = await parseJson(request, requestSchema); - const validated = validateArtifactIntent(input); + const validated = validateArtifactIntentFromUploadRequest(input); if (!validated.ok) { return secureJson({ error: validated.error }, { status: 400 }); } diff --git a/lib/security/policy.ts b/lib/security/policy.ts index f18490b..751643a 100644 --- a/lib/security/policy.ts +++ b/lib/security/policy.ts @@ -208,6 +208,20 @@ export function validateArtifactIntent(input: unknown) { }; } +export function validateArtifactIntentFromUploadRequest(input: { + kind: unknown; + fileName: unknown; + contentType: unknown; + byteSize: unknown; +}) { + return validateArtifactIntent({ + kind: input.kind, + fileName: input.fileName, + contentType: input.contentType, + byteSize: input.byteSize, + }); +} + export function normalizeUploadFilename(value: string): string | null { const normalized = value.normalize("NFKC").trim(); if ( diff --git a/package-lock.json b/package-lock.json index 8795df3..9556764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6679,9 +6679,9 @@ "license": "Unlicense" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -7773,9 +7773,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -8959,9 +8959,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/tests/security-policy.test.ts b/tests/security-policy.test.ts index e9985a9..68e5732 100644 --- a/tests/security-policy.test.ts +++ b/tests/security-policy.test.ts @@ -10,6 +10,7 @@ import { parseShowcaseSlug, showcaseDraftSchema, validateArtifactIntent, + validateArtifactIntentFromUploadRequest, } from "../lib/security/policy"; import { secureJson } from "../lib/security/http"; import { zipSync, strToU8 } from "fflate"; @@ -461,6 +462,26 @@ test("upload intent accepts only the declared kind, MIME, and size contract", () ); }); +test("upload request validation projects out the owning showcase id", () => { + const request = { + showcaseId: "11111111-2222-4333-8444-555555555555", + kind: "image", + fileName: "proof.png", + contentType: "image/png", + byteSize: 1024, + } as const; + + assert.deepEqual(validateArtifactIntentFromUploadRequest(request), { + ok: true, + value: { + kind: "image", + fileName: "proof.png", + contentType: "image/png", + byteSize: 1024, + }, + }); +}); + test("direct R2 uploads cryptographically bind size, type, and session", async () => { const environment = { accountId: process.env.R2_ACCOUNT_ID,