diff --git a/netlify.toml b/netlify.toml index 752615e..51ec115 100644 --- a/netlify.toml +++ b/netlify.toml @@ -10,3 +10,12 @@ NODE_VERSION = "26" from = "/*" to = "/index.html" status = 200 + +[[headers]] +for = "/" +[headers.values] +X-Content-Type-Options = "nosniff" +X-Frame-Options = "DENY" +Referrer-Policy = "strict-origin-when-cross-origin" +# 'wasm-unsafe-eval' required by ghostty-web's WASM (oof) +Content-Security-Policy = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' 'sha256-xwh0ztIl9GHVCIxpY9WA9wA4ops+/NNgJ9C0UKIv/L4='; connect-src 'self'" diff --git a/netlify/functions/reports.ts b/netlify/functions/reports.ts index ca27319..5a63bee 100644 --- a/netlify/functions/reports.ts +++ b/netlify/functions/reports.ts @@ -15,6 +15,7 @@ import { saveReportSnapshot, } from "./_shared/report-persistence.js"; import { scheduleDailyTrustReport } from "./_shared/report-schedules.js"; +import { ReportPostBodySchema, parseOrNull } from "../../src/lib/schemas.js"; const RECENT_REPORT_LIMIT = 5; @@ -104,20 +105,15 @@ export default async (req: Request) => { if (id) return new Response("Not found", { status: 404 }); - let body: { - orgs?: string[]; - scope?: unknown; - scopeLabel?: string; - capturedAt?: string; - payload?: unknown; - }; + let raw: unknown; try { - body = await req.json(); + raw = await req.json(); } catch { return new Response("Invalid JSON", { status: 400 }); } - if (!body || typeof body !== "object" || body.payload == null) { - return new Response("Missing payload", { status: 400 }); + const body = parseOrNull(ReportPostBodySchema, raw); + if (!body) { + return new Response("Missing or invalid report body", { status: 400 }); } const orgs = Array.isArray(body.orgs) ? body.orgs.map(String) : []; const scope = parseScope(body.scope, body.scopeLabel); diff --git a/package.json b/package.json index 4127dea..1f77b11 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "drizzle-orm": "1.0.0-rc.4-5d5b77c", "ghostty-web": "^0.4.0", "packumeta": "0.4.1", - "svelte": "^5.56.4" + "svelte": "^5.56.4", + "valibot": "^1.4.2" }, "devDependencies": { "@axe-core/playwright": "^4.12.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1a1061..0f5fa2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,7 +224,7 @@ importers: version: 7.1.2(svelte@5.56.4(@typescript-eslint/types@8.62.0))(vite@8.1.0(@types/node@26.0.1)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) drizzle-orm: specifier: 1.0.0-rc.4-5d5b77c - version: 1.0.0-rc.4-5d5b77c(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.4-5d5b77c(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(pg@8.22.0)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) ghostty-web: specifier: ^0.4.0 version: 0.4.0 @@ -234,6 +234,9 @@ importers: svelte: specifier: ^5.56.4 version: 5.56.4(@typescript-eslint/types@8.62.0) + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@6.0.3) devDependencies: '@axe-core/playwright': specifier: ^4.12.1 @@ -4579,6 +4582,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -6990,11 +7001,12 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4-5d5b77c(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(pg@8.22.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.4-5d5b77c(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.0)(pg@8.22.0)(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3): optionalDependencies: '@neondatabase/serverless': 1.1.0 '@opentelemetry/api': 1.9.0 pg: 8.22.0 + valibot: 1.4.2(typescript@6.0.3) zod: 4.4.3 dunder-proto@1.0.1: @@ -9027,6 +9039,10 @@ snapshots: util-deprecate@1.0.2: {} + valibot@1.4.2(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/src/App.svelte b/src/App.svelte index 5a7aa88..4d7c300 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -15,6 +15,7 @@ import { scopeLabelFor } from "./lib/reportHistory"; import { runUserPublishes } from "./lib/reports"; import { runAudit, type AuditResult } from "./lib/runAudit"; + import { parseOrNull, SaveResponseSchema } from "./lib/schemas"; import type { AuditConfig, ReportKind, UserPublishReport } from "./lib/types"; type TerminalHandle = { @@ -142,7 +143,10 @@ }), }); if (!response.ok) throw new Error(`Save failed (${response.status})`); - const { id } = (await response.json()) as { id: string }; + const data = await response.json(); + if (!parseOrNull(SaveResponseSchema, data)) + throw new Error("Save failed (unexpected response)"); + const { id } = data as { id: string }; if (attempt !== saveAttempt) return; savedReportId = id; savedReportCanTrackDaily = context.scope === "all" && !!nextResult.recent; @@ -166,7 +170,7 @@ error = "Select at least one report."; return; } - if (selected.external && members.length === 0 && selectedKinds.length === 1) { + if (selected.external && members.length === 0) { error = "The external report needs your npm org member list. npm does not expose org membership publicly."; return; diff --git a/src/SharedReport.svelte b/src/SharedReport.svelte index 1ebaa39..40dc95d 100644 --- a/src/SharedReport.svelte +++ b/src/SharedReport.svelte @@ -4,6 +4,7 @@ import ResultsView from "./components/ResultsView.svelte"; import ThemeToggle from "./components/ThemeToggle.svelte"; import type { AuditResult } from "./lib/runAudit"; + import { parseOrNull, ReportRecordSchema } from "./lib/schemas"; interface ReportRecord { id: string; @@ -54,7 +55,11 @@ .then(async (response) => { if (response.status === 404) throw new Error("This report could not be found."); if (!response.ok) throw new Error(`Failed to load report (${response.status}).`); - return (await response.json()) as ReportRecord; + const data: unknown = await response.json(); + if (!parseOrNull(ReportRecordSchema, data)) { + throw new Error("This report is in an unexpected format and can't be displayed."); + } + return data as ReportRecord; }) .then((next) => { if (!cancelled) record = next; diff --git a/src/components/DailyTrackingButton.svelte b/src/components/DailyTrackingButton.svelte index d5862a6..025b759 100644 --- a/src/components/DailyTrackingButton.svelte +++ b/src/components/DailyTrackingButton.svelte @@ -1,6 +1,7 @@