diff --git a/config/initializers/uploaded_script_guard.rb b/config/initializers/uploaded_script_guard.rb new file mode 100644 index 0000000..18cf59e --- /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 an hour 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 0000000..c627c9f --- /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