diff --git a/docs/api-reference/veryfront/security.md b/docs/api-reference/veryfront/security.md index 2b061e5e47..e511d8dd59 100644 --- a/docs/api-reference/veryfront/security.md +++ b/docs/api-reference/veryfront/security.md @@ -55,7 +55,7 @@ applySecurityHeaders(response.headers, false, generateNonce(), null); | `applyCORSHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/headers.ts#L86) | | `applyCORSHeadersSync` | Apply CORS synchronously. Promise-returning values still fail closed at runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/headers.ts#L112) | | `applyCsrfCookie` | Set CSRF cookie on GET/HEAD responses when not already present. Uses httpOnly: false so client JS can read the cookie for double-submit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L150) | -| `applySecurityHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L337) | +| `applySecurityHeaders` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L343) | | `buildCacheControl` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/cache-handler.ts#L86) | | `cors` | Create CORS middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/middleware.ts#L10) | | `corsSimple` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/middleware.ts#L39) | @@ -65,8 +65,8 @@ applySecurityHeaders(response.headers, false, generateNonce(), null); | `createValidationError` | Create an input validation error. Convenience wrapper around INPUT_VALIDATION_FAILED.create(). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/errors.ts#L12) | | `createValidator` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/path-validation/index.ts#L446) | | `generateCsrfToken` | Generate a CSRF token and return value + Set-Cookie header string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/csrf/helpers.ts#L70) | -| `generateNonce` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L69) | -| `getSecurityHeader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L324) | +| `generateNonce` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L71) | +| `getSecurityHeader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/security-handler.ts#L330) | | `handleCORSPreflight` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/preflight.ts#L126) | | `isPreflightRequest` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/preflight.ts#L186) | | `isRequestBodyTooLargeError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/limits.ts#L100) | @@ -94,9 +94,9 @@ applySecurityHeaders(response.headers, false, generateNonce(), null); | Name | Description | Source | | ---------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- | -| `AuthHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/auth.ts#L155) | +| `AuthHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/auth.ts#L156) | | `BaseHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/base-handler.ts#L45) | -| `CsrfHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/csrf/csrf-handler.ts#L55) | +| `CsrfHandler` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/csrf/csrf-handler.ts#L56) | | `ResponseBuilder` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/response/builder.ts#L9) | | `SecureFs` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/secure-fs.ts#L645) | | `SecurityConfigLoader` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/config.ts#L292) | diff --git a/docs/guides/security-headers.md b/docs/guides/security-headers.md index cbff875166..1896a9ed06 100644 --- a/docs/guides/security-headers.md +++ b/docs/guides/security-headers.md @@ -24,10 +24,12 @@ object-src 'none'; frame-src 'self'; frame-ancestors 'none'; base-uri 'self'; -form-action 'self' +form-action 'self'; +report-to veryfront-csp; +report-uri /_vf/csp-report ``` -Alongside it: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Strict-Transport-Security`, and `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy` set to `same-origin`. +Alongside it: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Strict-Transport-Security`, `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy` set to `same-origin`, and `Reporting-Endpoints: veryfront-csp="/_vf/csp-report"`, which defines the group the two reporting directives name. Development serves no CSP at all, so HMR and dev tooling are never blocked and a local allowance can never widen your production policy. @@ -37,6 +39,14 @@ Three directives are worth understanding: - **`style-src` and `font-src` include the Google Fonts origins** because `veryfront/fonts` writes those tags into the document itself. Google Fonts therefore works with no configuration. If your project never uses it, see [Tightening the policy](#tightening-the-policy). - **`frame-ancestors`** is `'none'` on your own domain. On `*.veryfront.com` addresses it instead allows the Studio origins, so the Studio preview iframe works. +## Violation reports + +The policy asks browsers to report what it blocks, to `/_vf/csp-report` on your own origin. Both spellings are sent because `report-to` is the current one and `report-uri` is still the only one several shipping browsers honour. + +You do not configure this and cannot switch it off. Reports are recorded with the violating document, the directive, the blocked URL and the status. Query strings are removed, so identifiers in a URL do not reach a log. They are rate-limited, and the endpoint always answers `204`. + +The endpoint is exempt from `security.auth` and `security.csrf`. A browser reports a violation without credentials and without a CSRF token, because a report is not a user action, so a protected project would otherwise report nothing at all. Exempting it discloses nothing: it reads no credentials, changes no state, and its response never varies. + ## Adding an origin Set `security.csp` in `veryfront.config.ts`. Values are **added to** the defaults, so you never restate them: diff --git a/src/security/http/auth.ts b/src/security/http/auth.ts index fdebbc6a1c..78af7ca1e0 100644 --- a/src/security/http/auth.ts +++ b/src/security/http/auth.ts @@ -1,3 +1,4 @@ +import { isCspReportRequest } from "#veryfront/security/http/csp-report-endpoint.ts"; import { BaseHandler } from "./base-handler.ts"; import type { HandlerContext, @@ -162,6 +163,13 @@ export class AuthHandler extends BaseHandler { handle(req: Request, ctx: HandlerContext): Promise { if (req.method.toUpperCase() === "OPTIONS") return Promise.resolve(this.continue()); + // Same reasoning as the CSRF gate: a browser reports a violation without + // credentials, so a protected project would collect nothing. See + // `isCspReportRequest` for why exempting it discloses nothing. + if (isCspReportRequest(req.method, new URL(req.url).pathname)) { + return Promise.resolve(this.continue()); + } + const auth = this.resolveAuth(ctx); if (!auth) return Promise.resolve(this.continue()); diff --git a/src/security/http/csp-report-endpoint.ts b/src/security/http/csp-report-endpoint.ts new file mode 100644 index 0000000000..479a72797e --- /dev/null +++ b/src/security/http/csp-report-endpoint.ts @@ -0,0 +1,33 @@ +/** + * Where the platform CSP asks browsers to send violation reports. + * + * The policy is built in the security module and the reports are received by a + * server handler, so the path and the group name live here, on the side both + * can depend on. They have to agree: a `report-to` directive naming a group the + * `Reporting-Endpoints` header does not define makes the browser send nothing, + * and it fails silently. + */ + +/** Path serving {@link CSP_REPORT_ENDPOINT_NAME}, on the project's own origin. */ +export const CSP_REPORT_PATH = "/_vf/csp-report"; + +/** Reporting group tying the CSP `report-to` directive to `Reporting-Endpoints`. */ +export const CSP_REPORT_ENDPOINT_NAME = "veryfront-csp"; + +/** + * Whether a request is the platform's own CSP report submission. + * + * The auth and CSRF gates both run ahead of the handler and both refuse a + * browser-generated report: it carries no credentials and no CSRF token, + * because a violation report is not a user action. Left alone, any project + * enabling either feature advertises a reporting endpoint that silently + * collects nothing. + * + * Exempting it is safe on the terms those gates exist for. The endpoint reads + * no credentials, changes no state, and answers 204 regardless of the body, so + * it discloses nothing about a protected project; what it writes is a bounded, + * sanitized, rate-limited log line. + */ +export function isCspReportRequest(method: string, pathname: string): boolean { + return method.toUpperCase() === "POST" && pathname === CSP_REPORT_PATH; +} diff --git a/src/security/http/csrf/csrf-handler.test.ts b/src/security/http/csrf/csrf-handler.test.ts index 20c372a37a..840d0c22b2 100644 --- a/src/security/http/csrf/csrf-handler.test.ts +++ b/src/security/http/csrf/csrf-handler.test.ts @@ -4,6 +4,7 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { CsrfHandler } from "./csrf-handler.ts"; import { generateCsrfToken } from "../../csrf/helpers.ts"; import type { HandlerContext } from "#veryfront/types"; +import { CSP_REPORT_PATH } from "#veryfront/security/http/csp-report-endpoint.ts"; function createCtx(csrf?: boolean | Record): HandlerContext { return { @@ -32,6 +33,35 @@ describe("security/http/csrf/csrf-handler", () => { }); }); + describe("platform CSP report endpoint", () => { + it("passes a browser report through even with CSRF enabled", async () => { + // A violation report is not a user action: the browser sends no token, so + // a project enabling CSRF would advertise a reporting endpoint that + // silently collects nothing. + const result = await handler.handle( + new Request(`https://acme.veryfront.com${CSP_REPORT_PATH}`, { + method: "POST", + body: '{"csp-report":{}}', + }), + createCtx(true), + ); + + assertEquals(result.response, undefined); + }); + + it("still rejects a token-less POST to any other path", async () => { + const result = await handler.handle( + new Request("https://acme.veryfront.com/_vf/csp-report-other", { + method: "POST", + body: "{}", + }), + createCtx(true), + ); + + assertEquals(result.response?.status, 403); + }); + }); + describe("when CSRF is not configured", () => { it("should pass through all requests when securityConfig is null", async () => { const ctx = createCtx(); diff --git a/src/security/http/csrf/csrf-handler.ts b/src/security/http/csrf/csrf-handler.ts index 0719c82aa1..bbff9f42a3 100644 --- a/src/security/http/csrf/csrf-handler.ts +++ b/src/security/http/csrf/csrf-handler.ts @@ -41,6 +41,7 @@ * @module security/http/csrf/csrf-handler */ +import { isCspReportRequest } from "#veryfront/security/http/csp-report-endpoint.ts"; import { BaseHandler } from "../base-handler.ts"; import { validateCsrf } from "../../csrf/helpers.ts"; import type { @@ -73,6 +74,11 @@ export class CsrfHandler extends BaseHandler { const { pathname } = new URL(req.url); + // A CSP violation report is not a user action and carries no token. See + // `isCspReportRequest`; relying on a project to add it to `excludePaths` + // would make reporting another thing a project has to configure first. + if (isCspReportRequest(method, pathname)) return this.continue(); + // Check exclude paths if (typeof csrfConfig === "object" && csrfConfig.excludePaths?.length) { for (const excludePath of csrfConfig.excludePaths) { diff --git a/src/security/http/response/security-handler.test.ts b/src/security/http/response/security-handler.test.ts index 7a2bc469d7..dc2029de77 100644 --- a/src/security/http/response/security-handler.test.ts +++ b/src/security/http/response/security-handler.test.ts @@ -1,3 +1,4 @@ +import { CSP_REPORT_PATH } from "#veryfront/security/http/csp-report-endpoint.ts"; import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { assert, assertEquals } from "#veryfront/testing/assert.ts"; @@ -638,6 +639,30 @@ describe("security/http/response/security-handler", () => { ); }); + it("points the policy at a reporting group the response actually defines", () => { + // The failure mode this guards is silent: a `report-to` naming a group + // that `Reporting-Endpoints` does not define makes the browser send + // nothing at all, and the policy still looks correct in devtools. + const headers = applyHeaders(); + const policy = headers.get("Content-Security-Policy") ?? + headers.get("Content-Security-Policy-Report-Only") ?? ""; + + const group = /report-to ([^;]+)/.exec(policy)?.[1]?.trim(); + assert(group, "policy must carry a report-to directive"); + + const endpoints = headers.get("Reporting-Endpoints") ?? ""; + assert( + endpoints.includes(`${group}=`), + `Reporting-Endpoints (${endpoints}) must define the group named by report-to (${group})`, + ); + + // The deprecated spelling is still the only one some browsers honour, and + // it takes a path rather than a group name. + const reportUri = /report-uri ([^;]+)/.exec(policy)?.[1]?.trim(); + assertEquals(reportUri, CSP_REPORT_PATH); + assert(endpoints.includes(`"${CSP_REPORT_PATH}"`), "both spellings must aim at one path"); + }); + it("should set X-Content-Type-Options", () => { const headers = applyHeaders(); assertEquals(headers.get("X-Content-Type-Options"), "nosniff"); diff --git a/src/security/http/response/security-handler.ts b/src/security/http/response/security-handler.ts index a69589f3e1..3e0015b872 100644 --- a/src/security/http/response/security-handler.ts +++ b/src/security/http/response/security-handler.ts @@ -3,6 +3,10 @@ import { recordSecurityHeaders } from "#veryfront/observability"; import { HOSTED_STUDIO_ORIGINS } from "#veryfront/security/http/studio-origin-policy.ts"; import { isCorsPolicyResponseHeaderName } from "#veryfront/utils/cors-policy-limits.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; +import { + CSP_REPORT_ENDPOINT_NAME, + CSP_REPORT_PATH, +} from "#veryfront/security/http/csp-report-endpoint.ts"; import { PLATFORM_FONT_FILE_ORIGINS, PLATFORM_FONT_STYLE_ORIGINS, @@ -35,6 +39,9 @@ export const SECURITY_POLICY_RESPONSE_HEADER_NAMES = Object.freeze( "cross-origin-opener-policy", "cross-origin-resource-policy", "referrer-policy", + // Names where violation reports go. A project-provided value would send + // them somewhere else, or nowhere, and the policy would look healthy. + "reporting-endpoints", "strict-transport-security", "x-content-type-options", "x-frame-options", @@ -42,13 +49,8 @@ export const SECURITY_POLICY_RESPONSE_HEADER_NAMES = Object.freeze( ] as const, ); -const SECURITY_POLICY_RESPONSE_HEADER_NAME_SET: ReadonlySet = new Set( - SECURITY_POLICY_RESPONSE_HEADER_NAMES, -); - -export function isSecurityPolicyResponseHeaderName(name: string): boolean { - return SECURITY_POLICY_RESPONSE_HEADER_NAME_SET.has(name.toLowerCase()); -} +/** Response header defining the reporting groups the policy refers to. */ +const REPORTING_ENDPOINTS_HEADER = "reporting-endpoints"; /** The two names the computed policy may be delivered under. */ const CSP_RESPONSE_HEADER_NAMES: ReadonlySet = new Set([ @@ -138,6 +140,10 @@ function requiredDirectives( "frame-ancestors": isVeryfrontDomain ? [...VERYFRONT_FRAME_ANCESTORS] : ["'none'"], "base-uri": ["'self'"], "form-action": ["'self'"], + // Both spellings: `report-to` is the current one, `report-uri` is + // deprecated but still the only one several shipping browsers honour. + "report-to": [CSP_REPORT_ENDPOINT_NAME], + "report-uri": [CSP_REPORT_PATH], }; } @@ -376,6 +382,12 @@ export function applySecurityHeaders( if (csp) { const hasEnvOverride = Boolean(adapter?.env?.get?.("VERYFRONT_CSP")?.trim()); headers.set(cspHeaderName(config, adapter, hasEnvOverride), csp); + // Names the group the policy's `report-to` refers to. Without this header + // the directive names nothing and the browser sends no reports at all. + headers.set( + REPORTING_ENDPOINTS_HEADER, + `${CSP_REPORT_ENDPOINT_NAME}="${CSP_REPORT_PATH}"`, + ); } if (!isDev) { @@ -422,9 +434,9 @@ export function applySecurityHeaders( // `security.headers` would silently replace the merged platform floor -- // and, now that the floor may be delivered report-only, could also flip // which mode is served. Header names are case-insensitive, so match that - // way. `isSecurityPolicyResponseHeaderName` is deliberately not reused: - // it covers headers this loop is still allowed to override. - if (isCspResponseHeaderName(key)) { + // way. + const headerName = key.toLowerCase(); + if (isCspResponseHeaderName(headerName) || headerName === REPORTING_ENDPOINTS_HEADER) { ignoredCspHeader = true; continue; } diff --git a/src/server/handlers/request/csp-report.handler.test.ts b/src/server/handlers/request/csp-report.handler.test.ts new file mode 100644 index 0000000000..2dafeb8a07 --- /dev/null +++ b/src/server/handlers/request/csp-report.handler.test.ts @@ -0,0 +1,204 @@ +import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createLogWindow, CspReportHandler, normalizeReports } from "./csp-report.handler.ts"; +import { CSP_REPORT_PATH } from "#veryfront/security/http/csp-report-endpoint.ts"; +import type { HandlerContext } from "../types.ts"; + +const ctx = { projectSlug: "acme" } as HandlerContext; + +function post(body: unknown, init: RequestInit = {}): Request { + return new Request(`https://acme.veryfront.com${CSP_REPORT_PATH}`, { + method: "POST", + body: typeof body === "string" ? body : JSON.stringify(body), + ...init, + }); +} + +async function statusOf(req: Request): Promise { + const result = await new CspReportHandler().handle(req, ctx); + return result.response?.status; +} + +describe("server/handlers/request/csp-report", () => { + it("accepts the legacy application/csp-report body", async () => { + assertEquals( + await statusOf(post({ + "csp-report": { + "document-uri": "https://acme.veryfront.com/", + "violated-directive": "img-src", + "blocked-uri": "https://images.example.com/logo.png", + }, + })), + 204, + ); + }); + + it("accepts the Reporting API array body", async () => { + assertEquals( + await statusOf(post([{ + type: "csp-violation", + body: { + documentURL: "https://acme.veryfront.com/", + effectiveDirective: "img-src", + blockedURL: "https://images.example.com/logo.png", + }, + }])), + 204, + ); + }); + + it("answers 204 for a body it cannot use", async () => { + // A browser can do nothing with an error and a hostile poster should learn + // nothing from one, so malformed input is accepted and dropped, not refused. + for (const body of ["not json at all", "", "[]", '{"unrelated":true}', "null"]) { + assertEquals(await statusOf(post(body)), 204, `body ${JSON.stringify(body)}`); + } + }); + + it("stops reading an oversized body instead of buffering it whole", async () => { + // The cap has to hold without a truthful content-length, or it is advisory: + // a streamed body with no declared length would already be in memory by the + // time a length check could reject it. Count what the handler actually + // pulled off the stream. + let bytesPulled = 0; + const chunk = new TextEncoder().encode("x".repeat(8 * 1024)); + const body = new ReadableStream({ + pull(controller) { + bytesPulled += chunk.byteLength; + // Far past the 64 KiB cap; a handler that drains this reads all of it. + if (bytesPulled > 4 * 1024 * 1024) return controller.close(); + controller.enqueue(chunk); + }, + }); + + const req = new Request(`https://acme.veryfront.com${CSP_REPORT_PATH}`, { + method: "POST", + body, + // @ts-expect-error duplex is required for a streaming body and is not in the DOM types + duplex: "half", + }); + + assertEquals((await new CspReportHandler().handle(req, ctx)).response?.status, 204); + assert( + bytesPulled <= 128 * 1024, + `handler pulled ${bytesPulled} bytes; the cap should have stopped it near 64 KiB`, + ); + }); + + it("rejects an oversized body declared up front", async () => { + const huge = JSON.stringify({ "csp-report": { "document-uri": "x".repeat(200_000) } }); + assertEquals(await statusOf(post(huge)), 204); + }); + + it("reads the Reporting API spellings, not only the legacy ones", () => { + // Browsers post camel-case fields under `application/reports+json`. Reading + // only the hyphenated names loses the directive and status — the two fields + // the enforcement decision is made from — while still answering 204, so a + // status-only assertion cannot see the loss. + const [violation] = normalizeReports([{ + type: "csp-violation", + body: { + documentURL: "https://acme.veryfront.com/pricing", + effectiveDirective: "connect-src", + blockedURL: "https://api.example.com/track", + statusCode: 200, + }, + }]); + + assertEquals(violation?.effectiveDirective, "connect-src"); + assertEquals(violation?.statusCode, 200); + assertEquals(violation?.documentUri, "https://acme.veryfront.com/pricing"); + assertEquals(violation?.blockedUri, "https://api.example.com/track"); + }); + + it("prefers effective-directive over the deprecated violated-directive", () => { + const [violation] = normalizeReports({ + "csp-report": { + "effective-directive": "img-src", + "violated-directive": "default-src", + }, + }); + assertEquals(violation?.effectiveDirective, "img-src"); + }); + + it("strips control characters and query strings from fields", () => { + // Fields come from an unauthenticated body. CR/LF would let a poster forge + // extra log records (CWE-117), and a query string can carry session + // identifiers that have no business in the log. + const [violation] = normalizeReports({ + "csp-report": { + "document-uri": "https://acme.veryfront.com/account?session=secret-token", + "violated-directive": "img-src\r\nWARN forged log line", + "blocked-uri": "https://cdn.example.com/a.png?sig=abc#frag", + }, + }); + + assertEquals(violation?.documentUri, "https://acme.veryfront.com/account"); + assertEquals(violation?.blockedUri, "https://cdn.example.com/a.png"); + assertEquals(violation?.effectiveDirective, "img-srcWARN forged log line"); + }); + + it("takes the first violations in a mixed batch rather than the first entries", () => { + // Slicing before filtering would discard real violations queued behind + // other report types a browser batches into the same request. + const batch = [ + ...Array.from({ length: 20 }, () => ({ type: "deprecation", body: {} })), + { type: "csp-violation", body: { effectiveDirective: "font-src" } }, + ]; + const violations = normalizeReports(batch); + assertEquals(violations.length, 1); + assertEquals(violations[0]?.effectiveDirective, "font-src"); + }); + + it("charges the log window per record, not per reservation", () => { + // A batch is up to 16 violations. Charging per request would let a sender + // write 16x the ceiling; the endpoint is unauthenticated, so this bound is + // the only thing protecting the log stream. + const window = createLogWindow(100, 60_000); + + let granted = 0; + for (let i = 0; i < 20; i += 1) granted += window.reserve(1000, 16).allowed; + assertEquals(granted, 100, "20 requests x 16 violations must still yield 100 records"); + + // A new window reopens the budget and reports what the last one swallowed. + const next = window.reserve(1000 + 60_000, 1); + assertEquals(next.allowed, 1); + assertEquals(next.dropped, 220, "320 attempted, 100 written, 220 dropped"); + }); + + it("counts the first record of a fresh window", () => { + // An early return on the rollover path skipped the increment, making one + // record per window free. + const window = createLogWindow(1, 60_000); + assertEquals(window.reserve(0, 1).allowed, 1); + assertEquals(window.reserve(0, 1).allowed, 0, "ceiling of 1 must admit exactly one"); + }); + + it("does not claim requests that are not a POST to its path", async () => { + const handler = new CspReportHandler(); + + const wrongMethod = await handler.handle( + new Request(`https://acme.veryfront.com${CSP_REPORT_PATH}`, { method: "GET" }), + ctx, + ); + assertEquals(wrongMethod.response, undefined); + + const wrongPath = await handler.handle( + new Request("https://acme.veryfront.com/", { method: "POST", body: "{}" }), + ctx, + ); + assertEquals(wrongPath.response, undefined); + }); + + it("keeps answering 204 past the log ceiling", async () => { + // The ceiling bounds how much a single misconfigured project can write to + // the log. It must not turn into backpressure on the browser. + const handler = new CspReportHandler(); + const report = { "csp-report": { "document-uri": "https://acme.veryfront.com/" } }; + + for (let i = 0; i < 250; i += 1) { + const result = await handler.handle(post(report), ctx); + assertEquals(result.response?.status, 204); + } + }); +}); diff --git a/src/server/handlers/request/csp-report.handler.ts b/src/server/handlers/request/csp-report.handler.ts new file mode 100644 index 0000000000..adcbc7e274 --- /dev/null +++ b/src/server/handlers/request/csp-report.handler.ts @@ -0,0 +1,223 @@ +/** + * CSP Violation Report Handler + * + * Receives the reports the platform CSP asks browsers to send, and writes them + * to the server log. + * + * Without this the report-only floor is inert at the platform level: it does + * not enforce, so it protects nothing, and with no reporting endpoint the + * violations it names reach only whoever happens to open devtools on the + * affected page. That leaves the enforcement rollout with no instrument -- + * no way to answer "which projects would break if we enforced?" short of + * breaking them and waiting for complaints, which is how the floor shipped + * the first time. + * + * Endpoint: POST /_vf/csp-report + */ + +import { BaseHandler } from "../response/base.ts"; +import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } from "../types.ts"; +import { HTTP_NO_CONTENT, PRIORITY_HIGH } from "#veryfront/utils/constants/index.ts"; +import { serverLogger } from "#veryfront/utils/logger/logger.ts"; +import { CSP_REPORT_PATH } from "#veryfront/security/http/csp-report-endpoint.ts"; +import { isRequestBodyTooLargeError, readBodyWithLimit } from "#veryfront/security/index.ts"; + +const logger = serverLogger.component("csp-report"); + +/** + * Reports come from browsers, unauthenticated, and anyone can post whatever + * they like to this path. Nothing here is trusted: the body is read against a + * byte budget rather than buffered whole, every logged field is truncated, and + * the response is always 204 so a hostile poster learns nothing about what was + * accepted. + */ +const MAX_BODY_BYTES = 64 * 1024; +const MAX_FIELD_LENGTH = 512; +const MAX_REPORTS_PER_REQUEST = 16; + +/** + * A busy project can emit violations on every page view. Logging each one would + * let a single misconfigured site drown the log stream, so each handler keeps a + * ceiling per window and records how many it dropped. + */ +const LOG_WINDOW_MS = 60_000; +const MAX_LOGGED_PER_WINDOW = 100; + +/** Control characters a poster could use to forge extra log records (CWE-117). */ +// deno-lint-ignore no-control-regex -- intentionally matching control chars to strip them +const LOG_CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/g; + +function readField(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const stripped = value.replace(LOG_CONTROL_CHARS, ""); + if (stripped.length === 0) return undefined; + return stripped.length > MAX_FIELD_LENGTH ? `${stripped.slice(0, MAX_FIELD_LENGTH)}…` : stripped; +} + +/** + * Same as {@link readField}, minus the query string. A violating URL carries + * whatever the page was called with, which can include session identifiers and + * personal data; the origin and path are what identify the violation. + */ +function readUri(value: unknown): string | undefined { + const field = readField(value); + if (field === undefined) return undefined; + const cut = field.search(/[?#]/); + return cut === -1 ? field : field.slice(0, cut); +} + +export interface NormalizedViolation { + documentUri?: string; + effectiveDirective?: string; + blockedUri?: string; + disposition?: string; + statusCode?: number; +} + +function readStatusCode(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +/** + * Two wire formats reach this path and both are still in the field: the legacy + * `application/csp-report` body with a single `csp-report` key, and the + * Reporting API's `application/reports+json` array. Normalize to one shape so + * the log has a single schema regardless of which browser sent it. + */ +export function normalizeReports(payload: unknown): NormalizedViolation[] { + const fromBody = (body: Record): NormalizedViolation => ({ + // Each field has a legacy hyphenated spelling and a Reporting API + // camel-case one, and `violated-directive` is deprecated in favour of + // `effective-directive`. Reading only one spelling silently loses the + // directive and status, which is the data the rollout decision needs. + documentUri: readUri(body["document-uri"] ?? body.documentURL), + effectiveDirective: readField( + body["effective-directive"] ?? body.effectiveDirective ?? body["violated-directive"], + ), + blockedUri: readUri(body["blocked-uri"] ?? body.blockedURL), + disposition: readField(body.disposition), + statusCode: readStatusCode(body["status-code"] ?? body.statusCode), + }); + + if (Array.isArray(payload)) { + // Filter before taking the first N: a batch may carry other report types, + // and slicing first would discard violations queued behind them. + return payload + .filter((entry) => readRecord(entry)?.type === "csp-violation") + .slice(0, MAX_REPORTS_PER_REQUEST) + .map((entry) => fromBody(readRecord(readRecord(entry)?.body) ?? {})); + } + + const record = readRecord(payload); + if (!record) return []; + + const legacy = readRecord(record["csp-report"]); + return legacy ? [fromBody(legacy)] : []; +} + +/** + * Per-window budget for log records. + * + * Charged per record rather than per request: one admission covering a whole + * batch would let a sender post {@link MAX_REPORTS_PER_REQUEST} violations at a + * time and write 16x the ceiling. The endpoint is unauthenticated, so this bound + * is the only thing protecting the log stream. + * + * Separate from the handler so the arithmetic can be tested as arithmetic, + * rather than by intercepting log output. + */ +export function createLogWindow( + maxPerWindow: number = MAX_LOGGED_PER_WINDOW, + windowMs: number = LOG_WINDOW_MS, +): { + /** @returns how many of `lines` may be written, and what the previous window swallowed */ + reserve: (now: number, lines: number) => { allowed: number; dropped: number }; +} { + let startedAt = 0; + let logged = 0; + let droppedInWindow = 0; + + return { + reserve(now: number, lines: number) { + let dropped = 0; + + if (now - startedAt >= windowMs) { + dropped = droppedInWindow; + startedAt = now; + logged = 0; + droppedInWindow = 0; + } + + const allowed = Math.min(lines, Math.max(0, maxPerWindow - logged)); + logged += allowed; + droppedInWindow += lines - allowed; + + return { allowed, dropped }; + }, + }; +} + +export class CspReportHandler extends BaseHandler { + metadata: HandlerMetadata = { + name: "CspReportHandler", + priority: PRIORITY_HIGH as HandlerPriority, + patterns: [{ pattern: CSP_REPORT_PATH, exact: true, method: "POST" }], + }; + + // Instance state rather than module state: one handler is built per registry, + // so production behaviour is the same, and each test gets a fresh window + // without the handler having to export a reset hook it does not otherwise need. + #logWindow = createLogWindow(); + + override async handle(req: Request, ctx: HandlerContext): Promise { + if (!this.shouldHandle(req, ctx)) return this.continue(); + if (req.method !== "POST") return this.continue(); + + // Always 204, whatever the body turned out to be. A browser has nothing to + // do with an error, and a hostile poster should learn nothing from one. + const accepted = this.respond(new Response(null, { status: HTTP_NO_CONTENT })); + + let raw: string; + try { + // Shared reader: Content-Length is an early hint only, the streamed byte + // count is authoritative, and tiny transport chunks are coalesced so + // chunk metadata cannot grow independently of the limit. + raw = await readBodyWithLimit(req, MAX_BODY_BYTES); + } catch (error) { + if (!isRequestBodyTooLargeError(error)) { + logger.debug("Unreadable CSP report body", { projectSlug: ctx.projectSlug }); + } + return accepted; + } + if (raw.length === 0) return accepted; + + let payload: unknown; + try { + payload = JSON.parse(raw); + } catch { + return accepted; + } + + const violations = normalizeReports(payload); + if (violations.length === 0) return accepted; + + const { allowed, dropped } = this.#logWindow.reserve(Date.now(), violations.length); + if (allowed === 0) return accepted; + + for (const violation of violations.slice(0, allowed)) { + logger.warn("CSP violation reported", { + projectSlug: ctx.projectSlug, + ...violation, + ...(dropped > 0 ? { droppedSincePreviousWindow: dropped } : {}), + }); + } + + return accepted; + } +} diff --git a/src/server/handlers/request/index.ts b/src/server/handlers/request/index.ts index 2408dca00b..16c457f5f8 100644 --- a/src/server/handlers/request/index.ts +++ b/src/server/handlers/request/index.ts @@ -14,4 +14,5 @@ export { CSSHandler } from "./css.handler.ts"; export { ModuleHandler } from "./module/index.ts"; export { RSCHandler } from "./rsc/index.ts"; export { SSRHandler } from "./ssr/index.ts"; +export { CspReportHandler } from "./csp-report.handler.ts"; export { StaticHandler } from "./static.handler.ts"; diff --git a/src/server/runtime-handler/index.ts b/src/server/runtime-handler/index.ts index 74f8867868..d1024a9c3f 100644 --- a/src/server/runtime-handler/index.ts +++ b/src/server/runtime-handler/index.ts @@ -44,6 +44,7 @@ import { DevFileHandler } from "../handlers/dev/files/index.ts"; import { DebugContextHandler } from "../handlers/dev/debug-context.handler.ts"; import { StylesCSSHandler } from "../handlers/dev/styles-css.handler.ts"; import { StudioBridgeModulesHandler } from "../handlers/studio/bridge-modules.handler.ts"; +import { CspReportHandler } from "../handlers/request/csp-report.handler.ts"; import { StaticHandler } from "../handlers/request/static.handler.ts"; import { SnippetHandler } from "../handlers/request/snippet.handler.ts"; import { LibModulesHandler } from "../handlers/request/lib-modules.handler.ts"; @@ -164,6 +165,7 @@ export const HANDLER_NAMES = [ "CSSHandler", "DevFileHandler", "SnippetHandler", + "CspReportHandler", "StaticHandler", "LibModulesHandler", "RSCHandler", @@ -236,6 +238,7 @@ const handlerFactories: Record< CSSHandler: () => new CSSHandler(), DevFileHandler: () => new DevFileHandler(), SnippetHandler: () => new SnippetHandler(), + CspReportHandler: () => new CspReportHandler(), StaticHandler: () => new StaticHandler(), LibModulesHandler: () => new LibModulesHandler(), RSCHandler: () => new RSCHandler(), diff --git a/src/utils/cors-policy-limits.ts b/src/utils/cors-policy-limits.ts index 43c390f894..5efc4f6b35 100644 --- a/src/utils/cors-policy-limits.ts +++ b/src/utils/cors-policy-limits.ts @@ -68,8 +68,15 @@ export function isValidCorsMaxAge(value: unknown): value is number { value <= MAX_CORS_MAX_AGE; } -/** Whether a response header is reserved for the dedicated CORS policy layer. */ -export function isCorsPolicyResponseHeaderName(value: unknown): value is string { +/** + * Whether a response header is reserved for the dedicated CORS policy layer. + * + * Returns a plain boolean rather than a `value is string` predicate: the + * narrowing that predicate implied was wrong in the negative branch, where it + * told the compiler a non-matching value was not a string at all, collapsing + * an ordinary header name to `never`. + */ +export function isCorsPolicyResponseHeaderName(value: unknown): boolean { return typeof value === "string" && value.slice(0, CORS_POLICY_RESPONSE_HEADER_PREFIX.length).toLowerCase() === CORS_POLICY_RESPONSE_HEADER_PREFIX;