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.
This commit is contained in:
Jeremy Daer committed 2026-10-15 19:01:12 -07:00
1 parent 821ff92dda
commit cad2d7211d
2 files changed
+68

No files matched your search

@@ -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
@@ -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