Safeguard
Application Security•

TinaCMS CVE-2026-108261: One Link, and the Preview Frame Becomes the Attacker's

One link opened by a signed-in TinaCMS editor could hand an attacker that editor's access to the content API. How the preview origin check was defeated, why filtering the URL would not have worked, and two sibling bugs fixed in the same release.

Safeguard Security Team
8 min read

A single link, opened by a signed-in TinaCMS editor, was enough to give an attacker read and write access to the site's content API as that editor. CVE-2026-108261, rated CVSS 9.3, came from two decisions in the TinaCMS admin that are each easy to make: building the preview iframe's address from the URL fragment, and deriving the origin trusted for postMessage from that same address.

TinaCMS is a Git-backed headless CMS used with Next.js, Astro and similar frameworks. This post is for teams running it, and for anyone building an admin UI that frames previews. It covers the affected versions, how the origin check was defeated, how the fix closes four separate bypasses, and two sibling bugs published the same day.

The short answer

CVE-2026-108261 affects tinacms 3.13.0 and earlier and @tinacms/app 2.5.13 and earlier, and is fixed in tinacms 3.14.0 and @tinacms/app 2.5.14. The admin's preview route is enabled by default. A crafted link made the admin frame an attacker-controlled page and treat its messages as trusted, so the attacker could run GraphQL queries and mutations with the editor's token. The fixed releases shipped on npm on 14 September 2026; the advisory was published on 9 October. Update tinacms, @tinacms/app, @tinacms/cli and @tinacms/web-components together.

PackageAdvisoryCVSSAffectedFixed in
tinacms, @tinacms/appCVE-2026-108261 (GHSA-x34j-47hf-4xg7)9.33.13.0 and earlier, 2.5.13 and earlier3.14.0, 2.5.14
@tinacms/cliCVE-2026-108259 (GHSA-pwhx-cvv3-qj5c)8.22.7.0 and earlier3.0.0
@tinacms/web-componentsCVE-2026-108260 (GHSA-c42q-qvc3-j6vg)7.60.2.0 and earlier0.2.1

GitHub's advisory credits sondt99 with reporting CVE-2026-108261.

How one link took over the preview

The TinaCMS admin has a hash-router route, /~/*, that shows a live preview of the site in an iframe. Per the advisory, the admin built the iframe's src by putting a slash in front of whatever followed /~/ in the fragment: `/${splat}`. The leading slash was meant to force a same-site path.

It doesn't, if the splat itself starts with a slash. A link ending in #/~//attacker.example/p produces //attacker.example/p, which browsers read as a protocol-relative URL: "this scheme, that host". The admin framed the attacker's page. The iframe had no sandbox attribute and the admin shipped no Content Security Policy to stop it.

On its own, that is an open-frame bug. What made it critical is the second decision. The admin and the preview talk over postMessage, and the admin only accepts messages whose event.origin matches an expected origin. That expected origin was computed as new URL(url, adminOrigin).origin, where url was the same attacker-supplied value. So the attacker's page was, by construction, the expected origin. Its messages passed the check, the admin forwarded its GraphQL operations, including mutations, with the editor's credentials, and posted the results back to the attacker.

The advisory also notes this is a fix on a fix. TinaCMS 3.9.3 and @tinacms/app 2.5.6 had already added the origin check on incoming messages, but it compared against a value the URL controlled. For the general rules on checking message origins, see postMessage has no default access control.

Why filtering the URL would not have worked

The comment at the top of the new resolvePreviewPath function says it plainly: do not filter the splat, because the URL parser treats \ as / and drops leading tabs, so a character filter does not hold. The fix resolves the value instead, rejects anything that left the admin's origin, and then resolves the resulting path a second time.

You can see why each step matters in a few lines of Node. The naive function reproduces the vulnerable logic. resolvePreviewPath is a simplified copy of the fix.

// splat.mjs: node splat.mjs
const ADMIN = 'https://cms.example.com';

// Before the fix: prefix a slash and trust whatever origin the result has.
const naive = (splat) => new URL(`/${splat}`, ADMIN).origin;

// After the fix (simplified from TinaCMS's resolvePreviewPath): resolve, check, resolve again.
function resolvePreviewPath(splat) {
  try {
    const first = new URL(`/${splat}`, ADMIN);
    if (first.origin !== ADMIN) return '/';
    const path = first.pathname + first.search + first.hash;
    if (new URL(path, ADMIN).origin !== ADMIN) return '/';   // "..//x" normalises to "//x"
    return path;
  } catch { return '/'; }
}

for (const splat of ['posts/hello', '/evil.example/p', '\\evil.example/p', '\t/evil.example/p', '..//evil.example/p']) {
  console.log(JSON.stringify(splat).padEnd(24), 'naive origin:', naive(splat).padEnd(26), 'fixed path:', resolvePreviewPath(splat));
}

Output on Node 24:

"posts/hello"            naive origin: https://cms.example.com    fixed path: /posts/hello
"/evil.example/p"        naive origin: https://evil.example       fixed path: /
"\\evil.example/p"       naive origin: https://evil.example       fixed path: /
"\t/evil.example/p"      naive origin: https://evil.example       fixed path: /
"..//evil.example/p"     naive origin: https://cms.example.com    fixed path: /

Three different spellings reach evil.example through the naive path: a doubled slash, a backslash, and a tab. A filter that blocks // misses the other two. The fourth case is subtler. /..//evil.example/p resolves on the admin's own origin, but its normalised pathname is //evil.example/p. When the browser later resolves that pathname as the iframe src, it becomes protocol-relative and leaves the origin after all. That is why the fix resolves twice.

The fix also changes where trust comes from. The expected origin for preview messages is now simply window.location.origin, a function that takes no argument. Nothing in the URL can nominate the origin it will then be trusted from. If you build anything that frames user-addressable content, that is the pattern to copy: derive trust from where you are, never from where you were told to go. The redirect version of the same mistake is covered in our open redirect guide.

The two siblings

CVE-2026-108259: a Git branch name becomes code. @tinacms/cli builds an API URL from the current branch, read from VERCEL_GIT_COMMIT_REF, GITHUB_BRANCH or HEAD, and writes it into the generated client.ts as url: '${apiURL}'. Git branch names may contain single quotes and plus signs, so a branch name can close the string and add a JavaScript expression that runs when the generated module is imported during the build. Anyone who can push a branch to a repository that builds previews per branch could reach that code path. The general fix for any code generator is to emit values with JSON.stringify, never inside hand-written quotes:

// codegen.mjs: node codegen.mjs  (prints generated source; executes nothing)
const branch = "feature'+alert(1)+'";          // a branch name Git accepts
const apiURL = `https://content.example.io/github/${branch}`;

const unsafe = `export const client = createClient({ url: '${apiURL}' });`;
const safe   = `export const client = createClient({ url: ${JSON.stringify(apiURL)} });`;

console.log('unsafe:', unsafe);
console.log('safe:  ', safe);
unsafe: export const client = createClient({ url: 'https://content.example.io/github/feature'+alert(1)+'' });
safe:   export const client = createClient({ url: "https://content.example.io/github/feature'+alert(1)+'" });

Git accepts that branch name (git check-ref-format --branch returns it unchanged), which is the whole problem.

CVE-2026-108260: javascript: links in rendered markdown. The <tina-markdown> web component assigned a link node's URL straight to the anchor's href. Every other rich-text renderer in the repository already passed URLs through a sanitizeUrl helper that allows only http, https, mailto, tel and xref. A content author could store a javascript: link that ran script for any visitor who clicked it. Version 0.2.1 applies the same sanitiser.

What to do

  1. Check what you run. npm ls tinacms @tinacms/app @tinacms/cli @tinacms/web-components lists every installed copy. At the time of writing the latest releases are tinacms 3.14.4, @tinacms/app 2.5.15, @tinacms/cli 4.0.2 and @tinacms/web-components 0.2.2.
  2. Note the major version on the CLI. The CLI fix landed in 3.0.0, and the current line is 4.x. Read the changelog before bumping a pinned 2.x CLI in CI.
  3. Review editor activity for the period you ran an affected version, if your admin is reachable from the internet. The attack needed a signed-in editor to open a link, so look for content changes you can't account for.
  4. Restrict who can create branches in repositories whose builds run TinaCMS codegen, until the CLI is updated.
  5. Add a frame policy to your admin. A Content Security Policy with a frame-src limited to your own origin would have stopped the first half of this chain, whatever the application code did.

How bad was it really?

The 9.3 score assumes a signed-in editor clicks a link, which is a real precondition. Editors are not usually anonymous users, and many TinaCMS sites are small. But editors are also exactly the people who receive "can you check this page?" links all day, and the payoff was the content API with their permissions, mutations included. A precondition that is satisfied by normal work isn't much of a barrier.

The pattern to take away

Every bug in this release has the same shape: a value from outside (a URL fragment, a branch name, a stored link) was placed into a context that interprets it (an iframe src, a JavaScript string, an href) without being checked in that context's terms. The fixes follow the same shape too: resolve the value the way the consumer will, then check the result.

If you want version checks like these running across every repository rather than one lockfile at a time, including framework packages pulled in transitively, that is what Safeguard SCA does.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.