DEV Community

Faizan Akhtar
Faizan Akhtar

Posted on Originally published at faizanakhtarcyber.blogspot.com

Cross-Account JavaScript Execution in Basecamp's Fizzy: How Pirikara Chained Five Quiet Bugs Into a One-Click Token Theft — Analysis & Lessons

Original research by Pirikara — read the full writeup here: https://hackerone.com/reports/3943339 · Researcher's profile: https://hackerone.com/pirikara

Analysis by Faizan Akhtar.

One link. One click. A stranger's browser quietly mints an API token — and hands it to you. That is the shape of the finding independent researcher Pirikara disclosed against Basecamp's Fizzy application in early October 2026 (HackerOne report #3943339), rated CVSS 8.7 High. What makes it worth studying is not a single clever payload but the architecture of the attack: five small, individually unremarkable weaknesses — a careless parameter hand-off, an exact-string content-type check, a trusting authorization default, a front-end framework that activates scripts on your behalf, and a token endpoint that trusts same-origin requests — linked into one seamless chain. No shared account. No second click. This is my original analysis of Pirikara's research: what each link was, why the whole thing held together, and what hunters and defenders should take from it.

Background

Fizzy is Basecamp's newer product line, built from familiar Rails pieces: Active Storage for uploads, Turbo for page updates, and a JSON API with personal access tokens. Basecamp runs a public bug bounty program on HackerOne, which is where Pirikara brought this chain in October 2026.

The report's lifecycle is worth noting first. The researcher reproduced the full chain end to end against a local deployment pinned to a specific commit, configured the way production is configured (S3-backed Active Storage, via an isolated S3-compatible stand-in), using two unrelated accounts — attacker and victim — without modifying the application's source. The victim's only action was opening one crafted URL pointing at the attacker's public board. Basecamp confirmed the finding, shipped a fix to production, and the report was disclosed. One detail stands out: despite the High rating, no bounty was paid — the report was marked not eligible. A great technical finding and a great payday are not the same thing.

The demonstrated impact was cross-account compromise of confidentiality and integrity: the stolen token disclosed the victim's account memberships and authorized read, write, and delete API requests with the victim's privileges. The proof created a card in the victim's unrelated account and deleted it — a clean, reversible demonstration that the token truly carried the victim's authority, usable outside the browser with no session cookie until revoked.

The Vulnerability

Five root causes, each one load-bearing.

1. Pagination URLs were built from untrusted parameters. A pagination helper forwarded every request parameter into Rails' URL builder — including script_name, a reserved option that rewrites the path prefix of generated URLs. Adding it to the query string bent the auto-generated "page 2" URL to point at the app's Active Storage blob-proxy route, while an encoded # turned the rest of the intended path into a fragment. The public board auto-loads further pages through a Turbo Frame when the pagination link scrolls into view — and the attack URL's own fragment scrolled the poisoned link into view by itself. The victim opens the URL; the page fetches the attacker's content with no further interaction.

2. Parameterized HTML slipped past the binary-content check. The direct-upload endpoint let the uploader declare the blob's content type, and Active Storage's force-download decision is an exact-string membership test against a dangerous-types list containing text/html — but not text/html;charset=utf-8. The parameterized variant sailed through, and the proxy served the blob back as HTML. Tellingly, the disk-service path canonicalized the MIME type first and rejected the same trick; only the S3 path was fooled. Content-Disposition: attachment did not help — a Turbo Frame fetch parses the response as a document regardless.

3. Orphaned blobs were treated as everyone's blobs. The proxy authorized any blob with zero attachments to any signed-in user. The attacker simply never attached the malicious HTML blob, so the victim — authenticated but in a completely different account — was authorized to load it. "Nobody claimed it" was implemented as "everybody may read it."

4. Turbo executed the script and laundered the CSP nonce. The blob contained the turbo-frame the pagination code expected, plus an inline script. Turbo's frame renderer activates scripts in frame responses — and copies the hosting page's CSP nonce onto the created script element. Since the app publishes its nonce in a meta tag, the attacker never needed to know it. A nonce is only as secret as the DOM that carries it.

5. A same-origin script is a skeleton key. With JavaScript in the application's origin, the rest was the application working as designed: the script read the page's CSRF token, called the access-token endpoint, minted a write-scoped Bearer token on the victim's global identity, and navigated to an attacker server with the raw token in the query string. CSRF protection is not XSS protection — once attacker script runs in the origin, it makes fully "legitimate" requests.

How It Was Found

This is a source-review finding elevated by methodical chaining, and the discovery path is as instructive as the bug.

Pirikara worked from the application's code at a pinned commit, running locally in production-like configuration. The first catch was the pagination helper forwarding all request parameters into the URL builder — a pattern experienced Rails auditors flag on sight, because the framework reserves URL-generation options (script_name, host, protocol, port, only_path) and route-selection keys (controller, action) that must never come from user input.

Each hypothesis was then tested in sequence. Could a parameterized content type survive the upload path and return as HTML? Yes on the S3 adapter, no on disk — a reminder to test every storage backend instead of assuming identical behavior. Who may read a blob nobody attached? Anyone signed in. Would the front-end framework execute script content inside the expected frame? Yes — and supply the CSP nonce too.

The final step was composition: one crafted board URL that poisoned the pagination target, pointed it at the unattached HTML blob, relied on the page's own scroll observer to trigger the fetch, and let the token endpoint and the attacker's collector finish the job. The end-to-end proof ran in August 2026 with two accounts, and the report arrived with suggested fixes for every link — root causes, exact reproduction, remediation. That completeness is a large part of why it went from submission to a production fix and public disclosure in days.

Why It Worked

The uncomfortable lesson: every component behaved "correctly" in isolation. The URL builder did what the parameters said. The binary check matched strings exactly as written. The authorization default favored availability over least privilege. Script activation is documented framework behavior. The token endpoint validated the CSRF token properly.

The trust boundary was crossed exactly once — when attacker script executed in the application's origin — and everything after that was the application functioning as designed. This is the composition problem in web security: individually defensible decisions combine into a path nobody intended.

Lessons for Hunters

Grep for parameter forwarding into URL builders. Any helper passing request parameters into a URL-generation function is a sink worth testing. Try the framework's reserved options and watch what the generated URLs become.

Fuzz content-type handling with parameters. Wherever a security decision is an exact string match on a MIME type, try ;charset=utf-8, case variations, and whitespace. Parameterized-type bypasses are an old family, and this report proves they still pay.

Hunt orphaned objects. Uploads never attached, drafts never published, resources nobody claimed — ask who can read them. Defaults written for convenience are authorization bugs wearing a helpful face.

Learn your framework's script-activation semantics. Turbo, htmx, jQuery's HTML insertion — each has rules about when scripts inside injected markup execute and what they inherit. A CSP nonce cannot protect a page if the framework copies it onto attacker-influenced scripts.

After same-origin execution, inventory the app's own APIs. Token issuance, export, and settings endpoints will happily attack the application on your behalf.

Prove it across accounts. A two-account end-to-end demonstration — attacker and victim unrelated, impact shown with the victim's privileges — turns an interesting chain into a triaged High. Triage moves on demonstrated impact, not theory.

Mitigation

The report's suggested fixes map one-to-one onto the five links, and they generalize well:

  1. Allowlist parameters for URL generation. Never forward framework-reserved URL options or route-selection keys from request input.
  2. Bind uploads to their creator. An unattached blob should be visible only to the identity that uploaded it.
  3. Normalize before comparing. Reduce declared MIME types to their bare media type before binary/inline decisions, and force application/octet-stream for HTML, XML, SVG, and unattached blobs at the proxy boundary.
  4. Serve user uploads from a separate, cookieless origin that cannot touch application sessions and is absent from script-src.
  5. Add a regression test that pushes parameterized HTML through the S3 direct-upload path and renders it through the automatic-pagination frame — the exact shape of this attack.

One structural improvement beyond the report: treat token-issuance endpoints as sensitive actions. Requiring confirmation before minting write-scoped tokens would not have stopped the script execution, but it would have broken the most valuable link in the chain.


If you found this breakdown useful, read the previous entry in this series — my analysis of another chained disclosure, the DuckDuckGo CI/CD RCE reported by 6r1ff1n: https://faizanakhtarcyber.blogspot.com/2026/10/duckduckgo-pull-request-target-rce-writeup-analysis.html

Report by Faizan Akhtar — The Cyber Security Researcher

Top comments (0)