From 8db9f24b0ed10b12a87dd123998e863084404fef Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2026 16:36:19 -0700 Subject: [PATCH 1/4] Serve uploads that aren't media as downloads 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. --- config/initializers/uploaded_script_guard.rb | 31 ++++++++++++++++ test/middleware/uploaded_script_guard_test.rb | 37 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 config/initializers/uploaded_script_guard.rb create mode 100644 test/middleware/uploaded_script_guard_test.rb diff --git a/config/initializers/uploaded_script_guard.rb b/config/initializers/uploaded_script_guard.rb new file mode 100644 index 00000000..ac07d045 --- /dev/null +++ b/config/initializers/uploaded_script_guard.rb @@ -0,0 +1,31 @@ +# Uploaded files are served from this origin, so under script-src 'self' an +# upload served with any JavaScript MIME type would run as one of our scripts. +# Browsers accept sixteen spellings of that type, and Disk URLs sign the type +# they were minted with, so a list of types to refuse can't cover old links. +# Instead, anything Active Storage serves that isn't plainly media goes out as +# a download, whatever its URL says. Only reads are touched: uploading +# (direct upload JSON, Disk PUT) answers in its own types. +# +# Accepted on upgrade: a browser that cached an upload under a script type +# before this guard shipped can still run it from that cache until the entry +# expires, up to a year after it was fetched (config/initializers/active_storage.rb). +# Revalidation doesn't help, since a 304 carries no content type. +class UploadedScriptGuard + SERVABLE = %r{\A(image|video|audio)/|\A(application/pdf|application/octet-stream|text/plain)\z} + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + + if env["REQUEST_METHOD"].in?(%w[ GET HEAD ]) && env["PATH_INFO"].start_with?("#{ActiveStorage.routes_prefix}/") && (type = headers["content-type"]) + headers["content-type"] = ActiveStorage.binary_content_type unless type.split(";").first.strip.downcase.match?(SERVABLE) + end + + [ status, headers, body ] + end +end + +Rails.application.config.middleware.use UploadedScriptGuard diff --git a/test/middleware/uploaded_script_guard_test.rb b/test/middleware/uploaded_script_guard_test.rb new file mode 100644 index 00000000..c627c9ff --- /dev/null +++ b/test/middleware/uploaded_script_guard_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class UploadedScriptGuardTest < ActiveSupport::TestCase + # Every JavaScript MIME type essence in the MIME Sniffing Standard, plus + # parameters and casing a signed Disk URL might carry. + SCRIPT_TYPES = %w[ + application/ecmascript application/javascript application/x-ecmascript application/x-javascript + text/ecmascript text/javascript text/javascript1.0 text/javascript1.1 text/javascript1.2 text/javascript1.3 + text/javascript1.4 text/javascript1.5 text/jscript text/livescript text/x-ecmascript text/x-javascript + ] + [ "Text/JavaScript; charset=utf-8" ] + + test "uploads never leave with a script type, whatever the URL says" do + SCRIPT_TYPES.each do |type| + assert_equal ActiveStorage.binary_content_type, served_type(type, "#{ActiveStorage.routes_prefix}/disk/key/probe.js"), type + end + end + + test "media and downloads keep their type" do + %w[ image/png video/mp4 audio/mpeg application/pdf text/plain ].each do |type| + assert_equal type, served_type(type, "#{ActiveStorage.routes_prefix}/disk/key/file"), type + end + end + + test "direct upload responses keep their JSON type" do + assert_equal "application/json", served_type("application/json", "#{ActiveStorage.routes_prefix}/direct_uploads", method: "POST") + end + + test "application responses outside uploads are untouched" do + assert_equal "text/javascript", served_type("text/javascript", "/assets/application-abc123.js") + end + + private + def served_type(type, path, method: "GET") + app = ->(env) { [ 200, { "content-type" => type }, [ "" ] ] } + UploadedScriptGuard.new(app).call(Rack::MockRequest.env_for(path, method: method))[1]["content-type"] + end +end From e9fef592af62948353921d61a3f3aa424d7e3940 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2026 16:36:19 -0700 Subject: [PATCH 2/4] Enforce a script policy that allows the importmap by hash 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. --- app/controllers/application_controller.rb | 2 +- .../concerns/inline_script_hashes.rb | 33 ++++++++ .../initializers/content_security_policy.rb | 39 ++++------ .../content_security_policy_test.rb | 77 ++++++++++++++++++- 4 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 app/controllers/concerns/inline_script_hashes.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 23d59499..9b697173 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Authentication, Authorization, UploadedFiles, VersionHeaders + include Authentication, Authorization, UploadedFiles, VersionHeaders, InlineScriptHashes # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/concerns/inline_script_hashes.rb b/app/controllers/concerns/inline_script_hashes.rb new file mode 100644 index 00000000..c0fde41a --- /dev/null +++ b/app/controllers/concerns/inline_script_hashes.rb @@ -0,0 +1,33 @@ +# Allows the scripts the importmap helper emits: the inline importmap and its +# entry-point module by content hash, and the origins its URLs point to. The +# hashes change only when the importmap does (a deploy), so cached HTML stays +# valid, unlike a per-request nonce. +module InlineScriptHashes + extend ActiveSupport::Concern + + ENTRY_POINT_SCRIPT = %(import "application") + + included do + content_security_policy do |policy| + if sources = policy.directives["script-src"] + importmap = Rails.application.importmap.to_json(resolver: helpers) + policy.directives["script-src"] = sources | script_origins(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT) + end + end + end + + private + # The importmap is resolved by Rails' asset helpers, so its URLs already + # carry any configured asset host, however it's written: with or without a + # scheme, protocol-relative, a %d set, or a proc. + def script_origins(importmap) + JSON.parse(importmap)["imports"].values.filter_map do |url| + origin = URI.join(request.base_url, url).origin + origin unless origin == request.base_url + end.uniq + end + + def script_hashes(*scripts) + scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" } + end +end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index b3076b38..e3435269 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,25 +1,18 @@ -# Be sure to restart your server when you modify this file. - -# Define an application-wide content security policy. -# See the Securing Rails Applications Guide for more information: -# https://guides.rubyonrails.org/security.html#content-security-policy-header - -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end +# Enforcing baseline policy. Scripts run only from this origin; the two inline +# scripts the importmap helper emits are allowed by hash (InlineScriptHashes), +# so there is no per-request nonce and rendered HTML stays cacheable. # -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) +# Nothing else is restricted: images, media, fonts, styles (including an +# account's custom styles) and connections behave as before. # -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +# DISABLE_CONTENT_SECURITY_POLICY=true is the escape hatch for an install whose +# proxy or customization injects scripts the policy would block. +unless ENV["DISABLE_CONTENT_SECURITY_POLICY"] == "true" + Rails.application.configure do + config.content_security_policy do |policy| + policy.script_src :self + policy.object_src :none + policy.base_uri :self + end + end +end diff --git a/test/integration/content_security_policy_test.rb b/test/integration/content_security_policy_test.rb index a98b66cf..7212131f 100644 --- a/test/integration/content_security_policy_test.rb +++ b/test/integration/content_security_policy_test.rb @@ -1,11 +1,75 @@ require "test_helper" class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest - test "no policy is sent while embeds are permissive" do + PAGE_PATH = "/session/new" + + test "no frame-src is sent while embeds are permissive" do + get new_session_path + assert_response :success + + assert_no_match "frame-src", response.headers["Content-Security-Policy"] + end + + test "enforcing script policy allows exactly the inline scripts the page carries" do get new_session_path assert_response :success - assert_nil response.headers["Content-Security-Policy"] + policy = response.headers["Content-Security-Policy"] + assert policy, "expected an enforcing Content-Security-Policy header" + assert_nil response.headers["Content-Security-Policy-Report-Only"] + assert_match "object-src 'none'", policy + assert_match "base-uri 'self'", policy + assert_no_match "unsafe-inline", policy + assert_no_match "nonce-", policy + assert_empty response.headers["Set-Cookie"].to_s.scan(/nonce/i) + + script_src = policy[/script-src ([^;]+)/, 1].split + inline = Nokogiri::HTML5(response.body).css("script").reject { |s| s["src"] } + assert_operator inline.size, :>=, 2 + inline.each do |script| + assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(script.text)}'", "inline #{script["type"]} script not allowed by hash" + end + end + + test "scripts may load from a configured asset host, however it's written" do + { + "https://cdn.example.test" => %r{\Ahttps://cdn\.example\.test\z}, + "cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z}, + "//cdn.example.test" => %r{\Ahttp://cdn\.example\.test\z}, + "https://assets%d.example.test" => %r{\Ahttps://assets[0-3]\.example\.test\z}, + ->(source) { "https://one.example.test" } => %r{\Ahttps://one\.example\.test\z}, + ->(source, request) { "https://#{request.host}.example.test" } => %r{\Ahttps://www\.example\.com\.example\.test\z} + }.each do |asset_host, expected_origin| + with_asset_host(asset_host) do + get PAGE_PATH + follow_redirect! while response.redirect? + assert_response :success + + script_src = response.headers["Content-Security-Policy"][/script-src ([^;]+)/, 1].split + page = Nokogiri::HTML5(response.body) + urls = JSON.parse(page.at_css("script[type=importmap]").text)["imports"].values + + page.css("link[rel=modulepreload]").map { it["href"] } + page.css("script[src]").map { it["src"] } + origins = urls.map { URI.join(request.base_url, it).origin }.uniq - [ request.base_url ] + + assert_not_empty origins, asset_host.inspect + origins.each do |origin| + assert_match expected_origin, origin, asset_host.inspect + assert_includes script_src, origin, asset_host.inspect + end + assert_includes script_src, "'sha256-#{Digest::SHA256.base64digest(page.at_css("script[type=importmap]").text)}'", asset_host.inspect + end + end + end + + test "the escape hatch boots without a policy" do + output = IO.popen({ "DISABLE_CONTENT_SECURITY_POLICY" => "true" }, + [ "bin/rails", "runner", "print Rails.application.config.content_security_policy.inspect" ], chdir: Rails.root, err: File::NULL, &:read) + + assert_equal "nil", output + end + + test "uploaded files are guarded on their way out" do + assert_includes Rails.application.middleware.map(&:klass), UploadedScriptGuard end test "frame-src carries the account's configured providers" do @@ -29,6 +93,15 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest end private + def with_asset_host(asset_host) + previous, ApplicationController.asset_host = ApplicationController.asset_host, asset_host + Rails.application.importmap.send(:clear_cache) + yield + ensure + ApplicationController.asset_host = previous + Rails.application.importmap.send(:clear_cache) + end + def frame_src_tokens get new_session_path assert_response :success From 1c7b0e4e99a47b166486aad01ebcab37540593c7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2026 17:45:59 -0700 Subject: [PATCH 3/4] Keep a multi-range upload response's multipart type 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. --- config/initializers/uploaded_script_guard.rb | 6 ++++-- test/middleware/uploaded_script_guard_test.rb | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/config/initializers/uploaded_script_guard.rb b/config/initializers/uploaded_script_guard.rb index ac07d045..71276964 100644 --- a/config/initializers/uploaded_script_guard.rb +++ b/config/initializers/uploaded_script_guard.rb @@ -3,7 +3,9 @@ # Browsers accept sixteen spellings of that type, and Disk URLs sign the type # they were minted with, so a list of types to refuse can't cover old links. # Instead, anything Active Storage serves that isn't plainly media goes out as -# a download, whatever its URL says. Only reads are touched: uploading +# a download, whatever its URL says. A multi-range response keeps its +# multipart/byteranges type and boundary, which no browser runs as a script. +# Only reads are touched: uploading # (direct upload JSON, Disk PUT) answers in its own types. # # Accepted on upgrade: a browser that cached an upload under a script type @@ -11,7 +13,7 @@ # expires, up to a year after it was fetched (config/initializers/active_storage.rb). # Revalidation doesn't help, since a 304 carries no content type. class UploadedScriptGuard - SERVABLE = %r{\A(image|video|audio)/|\A(application/pdf|application/octet-stream|text/plain)\z} + SERVABLE = %r{\A(image|video|audio)/|\A(application/pdf|application/octet-stream|text/plain|multipart/byteranges)\z} def initialize(app) @app = app diff --git a/test/middleware/uploaded_script_guard_test.rb b/test/middleware/uploaded_script_guard_test.rb index c627c9ff..7663c3c4 100644 --- a/test/middleware/uploaded_script_guard_test.rb +++ b/test/middleware/uploaded_script_guard_test.rb @@ -21,6 +21,11 @@ class UploadedScriptGuardTest < ActiveSupport::TestCase end end + test "multi-range responses keep their boundary" do + type = "multipart/byteranges; boundary=AaB03x" + assert_equal type, served_type(type, "#{ActiveStorage.routes_prefix}/blobs/proxy/key/movie.mp4") + end + test "direct upload responses keep their JSON type" do assert_equal "application/json", served_type("application/json", "#{ActiveStorage.routes_prefix}/direct_uploads", method: "POST") end From 5cf441a8e28783673d7e119a93a84b3f3208adba Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 15 Oct 2026 18:04:08 -0700 Subject: [PATCH 4/4] Leave a request without a policy when the policy is turned off 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. --- .../concerns/inline_script_hashes.rb | 4 ++- .../content_security_policy_test.rb | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/inline_script_hashes.rb b/app/controllers/concerns/inline_script_hashes.rb index c0fde41a..bf2761e8 100644 --- a/app/controllers/concerns/inline_script_hashes.rb +++ b/app/controllers/concerns/inline_script_hashes.rb @@ -8,7 +8,9 @@ module InlineScriptHashes ENTRY_POINT_SCRIPT = %(import "application") included do - content_security_policy do |policy| + # With the policy turned off there's nothing to add to, so leave the + # request without one rather than starting an empty policy + content_security_policy if: -> { request.content_security_policy } do |policy| if sources = policy.directives["script-src"] importmap = Rails.application.importmap.to_json(resolver: helpers) policy.directives["script-src"] = sources | script_origins(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT) diff --git a/test/integration/content_security_policy_test.rb b/test/integration/content_security_policy_test.rb index 7212131f..6ad85f83 100644 --- a/test/integration/content_security_policy_test.rb +++ b/test/integration/content_security_policy_test.rb @@ -61,6 +61,23 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest end end + test "with the policy turned off, pages send only frame-src" do + accounts(:signal).update!(embed_providers: EmbedProvider::DEFAULTS) + + without_policy do + assert_equal EmbedProvider.csp_frame_sources.sort, frame_src_tokens.sort + assert_no_match "script-src", response.headers["Content-Security-Policy"] + end + end + + test "with the policy turned off and no embeds, pages send none" do + without_policy do + get PAGE_PATH + assert_response :success + assert_nil response.headers["Content-Security-Policy"] + end + end + test "the escape hatch boots without a policy" do output = IO.popen({ "DISABLE_CONTENT_SECURITY_POLICY" => "true" }, [ "bin/rails", "runner", "print Rails.application.config.content_security_policy.inspect" ], chdir: Rails.root, err: File::NULL, &:read) @@ -93,6 +110,14 @@ class ContentSecurityPolicyTest < ActionDispatch::IntegrationTest end private + # What DISABLE_CONTENT_SECURITY_POLICY=true leaves at boot: no application policy + def without_policy + previous = Rails.application.env_config.delete("action_dispatch.content_security_policy") + yield + ensure + Rails.application.env_config["action_dispatch.content_security_policy"] = previous + end + def with_asset_host(asset_host) previous, ApplicationController.asset_host = ApplicationController.asset_host, asset_host Rails.application.importmap.send(:clear_cache)