# One survey link was enough to take over a PostHog account

Unsanitized survey introScreenDescription rendered via dangerouslySetInnerHTML on the authenticated PostHog origin let a survey link run JS in a victim's session and mint a wildcard personal API key.

- Payout: Research
- Severity: high
- Program: PostHog
- Write-up by: Strix
- Disclosed: 2026-09-28
- Write-up quality: 8/10
- Original: https://www.strix.ai/blog/posthog-survey-account-takeover
- Library page: https://bugbountylibrary.com/r/blog%3Astrix-ai-blog-posthog-survey-account-takeover
- Tags: area:web domain:web impact:ato impact:data-leak impact:pii-leak kind:research platform:research sev:high tech:nodejs technique:incomplete-fix technique:source-review vuln:account-takeover vuln:xss

**Target:** Hosted public survey page (posthog-js browser SDK) on the authenticated app origin
**Root cause:** The new optional intro screen field appearance.introScreenDescription was stored via the survey API and rendered with dangerouslySetInnerHTML by posthog-js, but the backend serializer sanitized only older fields like thankYouMessageDescription, not the new one. Public surveys were hosted on the same origin as the authenticated app, protected only by report-only CSP.
**Steps:**
1. Create a survey in account A with an intro screen whose description contains an HTML payload with an event handler (e.g. `<img src=x onerror=...>`).
2. Confirm the backend serializer leaves introScreenDescription intact while thankYouMessageDescription is sanitized.
3. Open the survey link on the real PostHog domain (us.posthog.com) in account B's signed-in session.
4. Injected script runs as the victim and POSTs to create a personal API key; response is HTTP 201 with scopes `*`.
5. Use the minted key in a separate request to read the victim's identity, organization owner membership, customer records and session-recording metadata.
**How found:** During broad source review, an agent noticed the recently added intro screen feature and that introScreenDescription was rendered via dangerouslySetInnerHTML in the shipped posthog-js bundle on the public hosted survey page, guarded only by report-only CSP. It then tested whether the backend serializer let unsafe content through, comparing the new field against the older thank-you field, and traced the sink to posthog-js PR #4436 (shipped 1.415.0).
**Exploitation:** Attacker creates a survey with a malicious introScreenDescription, then sends the hosted survey link on the legitimate PostHog domain to a signed-in victim. Opening the link executes attacker JS in the victim's authenticated origin, which calls the API to mint a personal API key with wildcard scope and exfiltrates it. The attacker then uses the key to read account, organization, customer and recording data. No user interaction beyond opening the link; the survey UI appears normal.
**Escalation:** Stored XSS on the app origin escalated to full account takeover via a wildcard-scoped personal API key, granting access to customer records and session-recording metadata.
**Insight:** When a product adds a new user-controlled field to an existing render path, diff it against sibling fields that already have sanitization: the serializer often whitelists old fields by name, so the new field bypasses it. Also check whether user content is hosted on the authenticated app origin, since same-origin rendering turns stored XSS into API-key minting.

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

**Serializer test showing unsanitized introScreenDescription**

```
serializer valid: True
introScreenDescription : '<img src=x [EVENT HANDLER OMITTED]>'
thankYouMessageDescription: '<img src="y">'
```

**Personal API key minted with wildcard scope**

```
[18:46:56] pat-minted
{
 "status": 201,
 "value": "[REDACTED_API_KEY]",
 [nonessential identifier fields omitted]
 "scopes": [
 "*"
 ]
}
```

**API request using minted key returns victim identity**

```
[response fields before this point omitted]
"email":"[REDACTED_TEST_ACCOUNT_EMAIL]"
[intervening response fields omitted]
"membership_level":15
[remaining response fields omitted]
HTTP 200
```

