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
10 changes: 5 additions & 5 deletions docs/api-reference/veryfront/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down
14 changes: 12 additions & 2 deletions docs/guides/security-headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions src/security/http/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isCspReportRequest } from "#veryfront/security/http/csp-report-endpoint.ts";
import { BaseHandler } from "./base-handler.ts";
import type {
HandlerContext,
Expand Down Expand Up @@ -162,6 +163,13 @@ export class AuthHandler extends BaseHandler {
handle(req: Request, ctx: HandlerContext): Promise<HandlerResult> {
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());

Expand Down
33 changes: 33 additions & 0 deletions src/security/http/csp-report-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions src/security/http/csrf/csrf-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): HandlerContext {
return {
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions src/security/http/csrf/csrf-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions src/security/http/response/security-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Loading