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
2 changes: 1 addition & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 35 additions & 0 deletions app/controllers/concerns/inline_script_hashes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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
# 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)
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
39 changes: 16 additions & 23 deletions config/initializers/content_security_policy.rb
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions config/initializers/uploaded_script_guard.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 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. 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
# 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|multipart/byteranges)\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
102 changes: 100 additions & 2 deletions test/integration/content_security_policy_test.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,92 @@
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 "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)

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
Expand All @@ -29,6 +110,23 @@ 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)
yield
ensure
ApplicationController.asset_host = previous
Rails.application.importmap.send(:clear_cache)
end

def frame_src_tokens
get new_session_path
assert_response :success
Expand Down
42 changes: 42 additions & 0 deletions test/middleware/uploaded_script_guard_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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 "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

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
Loading