Skip to content

feat(security): enforce what a project declared, report what it did not - #3489

Merged
kojiwakayama merged 3 commits into
mainfrom
feat/csp-explicit-enforcement
Aug 9, 2026
Merged

feat(security): enforce what a project declared, report what it did not#3489
kojiwakayama merged 3 commits into
mainfrom
feat/csp-explicit-enforcement

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The problem. Enforcement was all-or-nothing and hinged on whether security.csp existed:

return config?.csp !== undefined && config.csp !== null;

So a project adding one image origin to unblock an asset also bound script-src across its site, and would break the moment it loaded a third-party script it had never listed. Allowing a CDN and binding script execution are different decisions; they should not share a lever.

The change. Separated per directive. A declared directive is enforced, with the project's own sources in it. A directive the project never mentioned keeps reporting. Both policies are served together — browsers apply an enforced and a report-only policy independently — so violations stay visible for everything that does not yet bind.

Config Enforced Reported
nothing object-src, base-uri full floor
csp: { imgSrc: [...] } + img-src with those origins full floor
VERYFRONT_CSP everything

Enforced for everyone: object-src 'none' and base-uri 'self'. Both close real injection routes — <object>/<embed> payloads, and a <base> tag rewriting every relative URL on the page — and neither has a use a site would notice losing. A report-only policy protects nothing, so the directives that are safe to bind unconditionally now do.

Deliberately not enforced: form-action (projects post forms to HubSpot and similar — tomcode's own derived origins include api-eu1.hsforms.com) and frame-ancestors (projects are legitimately embedded).

Removes VERYFRONT_CSP_ENFORCE. It existed to end a staged rollout that is not going to happen. Derivation covers img-src, media-src and font-src, and cannot cover script-src, connect-src or frame-src — deriving script origins from source would grant execution to whatever a scan turned up, so anyone landing a URL in project source would have it allowlisted. A flag that will never be turned on is scaffolding implying a plan that does not exist. VERYFRONT_CSP remains as the ops-level full override.

No project-level opt-in flag. I built one (cspEnforce) and removed it before pushing: nothing needs it yet, zero projects have deliberately asked for enforcement, and the two that had it got there by accident. The day something needs it is the day to design it against a real requirement.

Correction to something I claimed earlier: I said this rule was "the likeliest way a project broke itself". That was speculation — codersociety was broken by #3417 shipping the floor enforced for everyone, not by this. The intent gap is real; my evidence for that particular story was not.

Lint, lint:test-typecheck, typecheck, fmt, docs and the full unit suite green by exit code. Three handler tests read the nonce out of the enforced header and now read the reported one — worth noting that no production code parses the nonce back out of a header, so this was test-only.

Summary by CodeRabbit

  • New Features

    • Content Security Policy now provides a complete report-only policy for monitoring while enforcing essential and explicitly configured directives.
    • Related fallback directives help prevent unintended policy tightening.
  • Documentation

    • Documented CSP enforcement behavior and updated API reference links.
  • Bug Fixes

    • Improved CSP nonce detection across documentation, dynamic, and static responses.

Enforcement was all-or-nothing and hinged on whether `security.csp` existed at
all. So a project adding one image origin to unblock an asset also bound
`script-src` across its site, and would break the moment it loaded a
third-party script it had never listed. Allowing a CDN and binding script
execution are different decisions and should not share a lever.

They are separated per directive now. A declared directive is enforced, with
the project's own sources in it; a directive the project never mentioned keeps
reporting. Both policies are served together, which browsers apply
independently, so violations stay visible for everything that does not yet
bind.

`object-src 'none'` and `base-uri 'self'` are enforced for every project
regardless. Both close real injection routes -- `<object>` payloads and a
`<base>` tag rewriting every relative URL -- and neither has a use a site would
notice losing. `form-action` and `frame-ancestors` are deliberately excluded:
projects post forms to third parties and are legitimately embedded.

Removes VERYFRONT_CSP_ENFORCE. It existed to end a staged rollout that is not
going to happen: derivation covers `img-src`, `media-src` and `font-src`, and
cannot cover `script-src`, `connect-src` or `frame-src`, because deriving
script origins from source would grant execution to whatever a scan turned up.
A flag that will never be turned on is scaffolding implying a plan that does
not exist. `VERYFRONT_CSP` remains as the ops-level full override.

No project-level opt-in flag was added: nothing needs one yet, and the day
something does is the day to design it against a real requirement.
@kojiwakayama
kojiwakayama requested a review from kwakayama as a code owner August 9, 2026 05:23
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 91165b5e-64fd-4388-b4a6-82335af0eaf4

📥 Commits

Reviewing files that changed from the base of the PR and between 4b26554 and 87ab6e9.

📒 Files selected for processing (1)
  • docs/guides/security-headers.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/guides/security-headers.md

📝 Walkthrough

Walkthrough

The security handler now delivers the complete CSP as report-only by default and emits an enforced companion policy containing required and explicitly configured directives. Tests, request handlers, integration checks, and documentation now reflect the dual-header behavior.

Changes

CSP delivery and documentation

Layer / File(s) Summary
CSP policy construction
src/security/http/response/security-handler.ts
The handler adds buildEnforcedCSP, applies fallback-dependent directives, removes configuration-based and VERYFRONT_CSP_ENFORCE enforcement, and emits both CSP headers when required.
CSP behavior validation
src/security/http/response/security-handler.test.ts, src/server/handlers/request/*, tests/integration/server/production-server.test.ts
Tests validate report-only precedence, enforced directives, fallback behavior, both headers, and nonce extraction.
CSP documentation updates
docs/guides/security-headers.md, docs/api-reference/veryfront/security.md
Documentation describes directive enforcement and updates source references.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RequestHandler
  participant SecurityHandler
  participant Response
  RequestHandler->>SecurityHandler: compute CSP policies
  SecurityHandler->>Response: set full report-only CSP
  SecurityHandler->>Response: set enforced companion CSP
  Response-->>RequestHandler: return nonce-aligned headers
Loading

Possibly related PRs

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: per-directive CSP enforcement with report-only handling for undeclared directives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/csp-explicit-enforcement

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/guides/security-headers.md`:
- Around line 46-48: Update the security headers documentation to accurately
describe buildEnforcedCSP: object-src and base-uri are always enforced, every
directive declared in security.csp—including form-action and frame-ancestors—is
enforced, and undeclared directives remain report-only. Replace the “cost you
nothing” wording with compatibility guidance covering object/embed/applet
blocking and cross-origin base elements; document that security.csp adds allowed
sources while VERYFRONT_CSP replaces the complete policy.

In `@src/security/http/response/security-handler.ts`:
- Around line 341-343: Update the declared-directives construction near the
`declared` Set so CSP entries whose value is `undefined` are filtered out before
`toCspDirectiveName` maps their keys. Preserve enforcement for configured values
and add a regression test covering an `undefined` directive such as `scriptSrc`.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cfc35486-2275-4a5d-9787-019cdc0d722b

📥 Commits

Reviewing files that changed from the base of the PR and between f5e07f9 and 0d206e1.

📒 Files selected for processing (7)
  • docs/api-reference/veryfront/security.md
  • docs/guides/security-headers.md
  • src/security/http/response/security-handler.test.ts
  • src/security/http/response/security-handler.ts
  • src/server/handlers/request/openapi-docs.handler.test.ts
  • src/server/handlers/request/rsc/index.test.ts
  • src/server/handlers/request/static.handler.test.ts

Comment thread docs/guides/security-headers.md Outdated
Comment thread src/security/http/response/security-handler.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d206e1829

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/security/http/response/security-handler.ts
Comment thread src/security/http/response/security-handler.ts
Four review findings, one of which would have blocked working sites.

CSP resolves a missing directive by walking a fallback chain, so a policy
carrying `script-src` and no `worker-src` does not leave workers
unconstrained -- it constrains them by `script-src`. Emitting a subset of
directives is therefore not the same as emitting those directives alone. A
project declaring only `scriptSrc` would have found its `blob:` workers blocked
by an enforced `script-src` that never mentioned them, while the reported
`worker-src` said they were fine. The companion policy now carries the fallback
closure of whatever it enforces, with the values the reported policy gives
those directives, so declaring one directive cannot tighten another.

`undefined` is now treated as absent when deciding what was declared, matching
what the merge already does. Without it `scriptSrc: undefined` enforced a
merged `script-src` the project never configured.

The integration suite's `readCsp` preferred the enforced header, which now
carries only the binding subset, so its nonce and policy-content assertions
were reading the wrong header.

The guide claimed the always-enforced pair "costs you nothing" and omitted that
declaring `formAction` or `frameAncestors` binds them too. It now says what
each blocks and states the contract as implemented.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/guides/security-headers.md`:
- Line 53: Update the paragraph describing buildEnforcedCSP enforcement to say
that directives with defined values in security.csp are enforced, while
directives set to undefined are treated as undeclared and remain report-only.
Preserve the existing examples and explanation about independently deciding
which directives to bind.
- Line 46: Update the security-headers documentation text to qualify that the
companion enforced Content-Security-Policy header is added by default, and
explicitly note that setting VERYFRONT_CSP replaces it with the override header
without adding the companion enforced header.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a906d494-1353-43ef-89bb-59a463d1a9fd

📥 Commits

Reviewing files that changed from the base of the PR and between 0d206e1 and 4b26554.

📒 Files selected for processing (5)
  • docs/api-reference/veryfront/security.md
  • docs/guides/security-headers.md
  • src/security/http/response/security-handler.test.ts
  • src/security/http/response/security-handler.ts
  • tests/integration/server/production-server.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • docs/api-reference/veryfront/security.md
  • src/security/http/response/security-handler.ts
  • src/security/http/response/security-handler.test.ts

Comment thread docs/guides/security-headers.md Outdated
Comment thread docs/guides/security-headers.md Outdated
Two inaccuracies in the section I added.

"For every project" was wrong: `VERYFRONT_CSP` replaces the policy wholesale
and is served enforced on its own, with neither the reported floor nor the
always-enforced pair beside it. It is the default that is described here, not
an invariant.

"Anything you declare" was wrong in the same direction: a directive written as
`undefined` counts as unconfigured, matching what the merge does, so it keeps
reporting rather than binding. The text now asks for a value rather than a
mention.
@kojiwakayama
kojiwakayama enabled auto-merge August 9, 2026 05:52
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 366a583 Aug 9, 2026
31 checks passed
@kojiwakayama
kojiwakayama deleted the feat/csp-explicit-enforcement branch August 9, 2026 06:05
@kojiwakayama kojiwakayama mentioned this pull request Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant