Skip to content

Enforce a script Content-Security-Policy - #499

Merged
jeremy merged 4 commits into
mainfrom
enforce-script-policy
Oct 16, 2026
Merged

jeremy merged 4 commits into
mainfrom
enforce-script-policy

Conversation

@jeremy

@jeremy jeremy commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Turns on an enforcing Content-Security-Policy that limits scripts to the ones Writebook ships. Replaces #464, which was report-only. The enforcing frame-src for configured embed providers stays as it is.

What changes

For attackers: if markup gets past the markdown renderer and sanitizer, it can no longer run script. Inline <script> and inline event handlers are refused. An uploaded file can't be loaded as a script either, since uploads are now served as downloads unless they're plainly media.

For self-hosters: nothing, for most installs. The policy adds script-src, object-src 'none' and base-uri 'self' next to the existing frame-src. Images, media, fonts, styles (custom styles included) and connections behave as before.

  • script-src 'self', plus the SHA-256 of the two inline scripts the importmap helper emits. There's no per-request nonce or nonce cookie, so pages served with Cache-Control: public stay valid in shared caches: the hashes only change when the importmap does, at a deploy.
  • A configured asset_host is allowed by origin. It's read from the importmap URLs Rails' asset helpers build, so a host with or without a scheme, protocol-relative, a %d set or a proc all resolve the same way the page's own tags do.
  • UploadedScriptGuard (a middleware) serves anything Active Storage reads out that isn't image/*, video/*, audio/*, PDF, octet-stream, plain text or a multi-range multipart/byteranges response as application/octet-stream. It does this when the file is served, not when it's uploaded, so Disk URLs already out there are covered too. That includes published books' non-expiring ones.

Release notes

Writebook now sends an enforcing Content-Security-Policy that only allows its own scripts. If your install sits behind a proxy that injects scripts into pages (Cloudflare Rocket Loader or Cloudflare Web Analytics, for example), or an administrator uses custom styles to add a <script> (the </style><script> trick), those scripts will stop running. Set DISABLE_CONTENT_SECURITY_POLICY=true and restart to turn the policy off. Embeds keep their own frame-src either way.

#465 serves custom styles from their own route, which removes the custom-styles trick altogether.

Evidence

  • Full suite on this head, run on a 32-core Linux box: 310 runs, 0 failures, 1 error. The error is ActiveStorageAuthorizationTest#test_unpublishing_a_book_revokes_the_disk_URL_behind_a_page_upload (undefined method 'request_uri' for URI::Generic), and main errors the same way on that box. System tests 3/3. RuboCop clean, importmap audit clean. Brakeman reports no warnings, but bin/brakeman's --ensure-latest gate fails the same way on main: 8.1.0 is out and the lockfile pins 8.0.6.
  • Browser probes (Playwright, Chromium) against dev servers built from main and from this branch:
main this branch
Crawl 30 pages as an editor and edit a page in the markdown editor, through Turbo works works, 0 violations, Turbo and Stimulus loaded
frame-src with the default embed providers sent sent, unchanged
.js uploaded through the page editor's upload endpoint, loaded from its Disk URL as a <script> runs (text/javascript) served as octet-stream, doesn't run
Custom styles containing </style><script> runs refused (the documented breakage)
  • Tests: every inline script on the page is allowed by hash and no nonce cookie is set; six asset_host forms (scheme, bare host, protocol-relative, %d, one- and two-argument procs) allow every script origin the page loads; the escape hatch boots with no global policy, and a page served without one sends only frame-src, or no header when no embeds are configured; all sixteen JavaScript MIME spellings plus parameter and casing variants leave uploads as octet-stream; media keeps its type; a multi-range response keeps its multipart/byteranges boundary; the existing frame-src tests still pass.

Risk and rollback

  • Proxy or customization injecting scripts: those scripts stop. DISABLE_CONTENT_SECURITY_POLICY=true restores today's behavior without a deploy, frame-src included.
  • Cached uploads across the upgrade (accepted): a browser that cached an upload under a script type before this ships can still run it from that cache until the entry expires, up to a year after it was fetched (config/initializers/active_storage.rb sets expires_in 1.year). Revalidation doesn't change it, because a 304 carries no content type.
  • How uploads display: no visible change expected. Active Storage already serves everything outside its inline list (common images and PDF) as an attachment, and already serves HTML and SVG as binary. The guard only changes the Content-Type on files that were already downloads.
  • Rollback: revert the two commits. Neither changes data.

What ships

  • config/initializers/content_security_policy.rb: the enforcing policy, skipped when DISABLE_CONTENT_SECURITY_POLICY=true.
  • app/controllers/concerns/inline_script_hashes.rb: importmap hashes and asset-host origins.
  • config/initializers/uploaded_script_guard.rb: the upload type guard.
  • Tests in test/integration/content_security_policy_test.rb and test/middleware/uploaded_script_guard_test.rb.

Uploaded files come from this origin, so one served with a JavaScript
type could run as one of the app's own scripts. Browsers accept sixteen
spellings of that type, and Disk URLs sign the type they were minted
with, so refusing a list of types at upload can't cover links already
out there. Instead, a middleware rewrites the type of anything Active
Storage reads out that isn't plainly media, a PDF or plain text to
application/octet-stream, whatever the URL says.
script-src 'self', object-src 'none' and base-uri 'self', enforced. The
two inline scripts the importmap helper emits are allowed by their
SHA-256, and the origins its URLs point to (a configured asset host, in
any form Rails accepts) are allowed by name. There is no per-request
nonce, so cached pages stay valid. The enforcing frame-src for
configured embed providers is unchanged.

DISABLE_CONTENT_SECURITY_POLICY=true leaves this policy off, for an
install whose proxy or customization injects scripts; frame-src still
applies then, as it does today.
Copilot AI balanced review requested due to automatic review settings October 15, 2026 23:46
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T01:08:01.170899Z 5cf441a New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI 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.

🔵 Needs a closer look

Browser script enforcement and upload content-type handling change security boundaries that warrant final human verification.

0 open findings

What changed in this PR

Adds an enforcing script Content Security Policy to Writebook while preserving existing embed restrictions and cacheable pages.

Changes:

  • Allows application scripts through same-origin access, importmap hashes, and resolved asset-host origins.
  • Rewrites unsafe upload content types to binary.
  • Adds policy, asset-host, escape-hatch, and upload middleware tests.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
test/​middleware/​uploaded_script_guard_test.rb Tests upload type filtering and unaffected responses.
test/​integration/​content_security_policy_test.rb Tests policy enforcement, script allowances, and opt-out.
config/​initializers/​uploaded_script_guard.rb Filters upload response content types.
config/​initializers/​content_security_policy.rb Enables the policy with an environment opt-out.
app/​controllers/​concerns/​inline_script_hashes.rb Adds inline script hashes and asset origins.
app/​controllers/​application_controller.rb Includes script policy support.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

A request for several byte ranges gets a 206 multipart/byteranges whose
boundary the client needs to split the body. Rewriting it to
octet-stream lost the boundary. No browser runs that type as a script,
so it passes through.

Copilot AI 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.

🔵 Needs a closer look

Application-wide script enforcement and upload-serving security changes warrant final human validation of browser and deployment behavior.

0 open findings

🧠 Review effort: Balanced

With DISABLE_CONTENT_SECURITY_POLICY=true there's no application policy,
and the hash callback started an empty one, so pages sent an empty
Content-Security-Policy header. Skip the callback then.

Copilot AI 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.

🔵 Needs a closer look

Security-sensitive script enforcement and upload handling warrant final human validation despite no confirmed defects.

0 open findings

🧠 Review effort: Balanced

@jeremy
jeremy merged commit 7478e2c into main Oct 16, 2026
8 checks passed
@jeremy
jeremy deleted the enforce-script-policy branch October 16, 2026 01:31
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.

2 participants