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¶
- Fragment/router-splat values that become frame or channel trust anchors are OAuth-grade security state. The TinaCMS shape — URL fragment → iframe
src→ also theexpectedOrigincompared against incomingpostMessage— 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) usespostMessagewith 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). - Fingerprint the protocol-relative hop.
/${userValue}where the value begins with/is the one-keystroke class://evilbecomes 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. - 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 bygitenv 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. - 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. - 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-pluginsand mise.tool-versionslegs.