Skip to content

TinaCMS four-leg cluster: the admin's own URL fragment picks the iframe origin and its postMessage trust anchor, git branch names escape codegen string literals, and a sibling markdown renderer skips the scheme check its twins all have

Signal: GitHub Security Advisories published a four-advisory cluster against TinaCMS and adjacent packages at 2026-10-09T20:56Z (CVE-2026-108258 through CVE-2026-108261). TinaCMS is a headless CMS whose admin app is a browser-side GraphQL client holding the editor's content token — the critical leg converts one phished link into full content-API read/write as the editor. Each leg is a reusable class with a fingerprint.

Authorized validation only

Lab TinaCMS site with synthetic content and your own editor account; attacker page and observer origin you own. Never frame a real admin into an origin you don't control or submit writes to a live content API.

Advisory table

GHSA CVE Sev Boundary
GHSA-x34j-47hf-4xg7 CVE-2026-108261 critical ≤3.13.0 → 3.14.0 The admin builds its preview <iframe src> from the /~/* hash-router splat with no same-origin check: #/~//attacker.example/p → /${params['*']} → protocol-relative //attacker.example/p. The same unvalidated string also derives expectedOrigin, the sole trust anchor for the admin↔preview postMessage channel — so the attacker's frame is treated as the trusted preview and can submit any GraphQL operation, which the admin executes with the signed-in editor's token and posts back to the attacker. One link, one click by a logged-in editor = unauthenticated attacker reads/writes the site's content API as that editor.
GHSA-pwhx-cvv3-qj5c CVE-2026-108259 high ≤2.7.0 → 3.0.0 @tinacms/cli reads the git branch (VERCEL_GIT_COMMIT_REF/GITHUB_BRANCH/HEAD) and interpolates it raw into the generated client.ts at url: '${apiURL}' (single- and double-quoted sibling sinks). A Git-valid branch name like x'+require("child_process").exec("id")+' closes the string literal and executes when the consumer build imports the generated client. Repo-name/branch → build-time code execution: supply-chain leg where the attacker controls only a branch name (fork + PR with a crafted branch, renamed branch, CI that generates the client).
GHSA-c42q-qvc3-j6vg CVE-2026-108260 high ≤0.2.0 → 0.2.1 <tina-markdown> assigns rich-text a node URLs straight to el.href with no scheme check — a CMS-authored javascript: link renders as a live anchor; visitor click executes in the site origin. Every sibling renderer in the same repo sanitizes these URLs; the DOMPurify call that exists in the file handles raw-HTML nodes and returns before the anchor branch, so it never inspects node.url. Sibling-renderer parity + sanitizer-coverage-gap-in-the-same-function.
GHSA-47c3-hpmg-7j6p CVE-2026-108258 medium 1.4.0–1.6.3 → 1.6.4 Shiny for Python joins client-supplied _state_id_ into <cwd>/shiny_bookmarks/<id> with no validation; ../absolute paths escape the bookmark store. On default apps the restore fires for any query string even with bookmarking disabled (bookmark_store="disable") — unauthenticated oracle for existence + JSON-validity of two fixed filenames (input.json, values.json) at attacker-chosen paths; with bookmark_store="server" + ui.input_file(), copies the file contents back = file read. Feature-disabled-but-handler-live, again.

Durable operator rules

  1. Fragment/router-splat values that become frame or channel trust anchors are OAuth-grade security state. The TinaCMS shape — URL fragment → iframe src → also the expectedOrigin compared against incoming postMessage — means the "trusted peer" for the message bus is chosen by the attacker's link. Audit any app that (a) takes a destination from fragment/query/hash routing and (b) uses postMessage with an origin it derived from user input: the two uses fail together. Probe in a lab admin: craft #/~//<your-observer>/, watch whether the frame loads and whether it receives/has-trust-for the message channel (verify against your own observer page only).
  2. Fingerprint the protocol-relative hop. /${userValue} where the value begins with / is the one-keystroke class: //evil becomes scheme-less-absolute. Any string concatenation producing a URL from a splat/param deserves a leading-// test — hash routers hide it from server logs entirely (fragments aren't sent), which also means WAF/server-side controls never see the payload.
  3. Build-time string interpolation of VCS metadata is code execution. Branch names, tags, commit messages, and remote URLs that reach codegen templates need JSON.stringify-class escaping, not concatenation — grep generators for '${...}' patterns fed by git env vars. PoC boundary: inert marker expressions in your own repo's generated client; never ship an executable payload through a PR to a third party.
  4. Sanitizer-coverage diff inside a single render function. The pattern "DOMPurify runs on node type X, returns, and node type Y's URL fields never pass through any check" is the same sibling-parity disease as this morning's Vikunja wave: enumerate every DOM sink in the renderer (href, src, poster, style, formaction) and test each with a scheme canary; verify the sanitizer's coverage set, not its presence.
  5. Disabled-feature handlers are still routes. Shiny's restore ran with bookmarking disabled — check whether "off" configuration gates the handler or only the UI. The two-filename existence oracle is a low-severity but reportable primitive; the same "config says off, code path lives" shape recurs across the CMS/dashboard pages on this wiki.

Cross-references

  • Sibling-parity and fix-drift genealogy: Oct 9 Vikunja wave (same evening), Oct 5 VM2 incomplete-fix page.
  • Prior TinaCMS leg (FilesystemBridge symlink/junction path containment): Mar 31 page — same product, third-week re-sweep of a different boundary.
  • DOM-clobbering / attribute-sink lineage: Sept 19 Svelte attribute-spread page; sanitizer-foreign-content battery: Oct 9 SmarterMail MathML mXSS page.
  • Build-context trust (repo-controlled metadata → generator execution): June 22 build-context page, .pdm-plugins and mise .tool-versions legs.