
Docmost aceptó una URL javascript: dentro de un nodo de adjunto, la preservó a través del almacenamiento y la representación, y la convirtió de nuevo en un ancla cliqueable en el origen de Docmost.
Docmost aceptó una URL javascript: dentro de un nodo de adjunto, la preservó durante el almacenamiento y renderizado, y la devolvió a un ancla enlaceable en el origen de Docmost.
Identifiqué, divulgué responsablemente y reproduje un problema de XSS almacenado de gravedad Alta en Docmost, la plataforma colaborativa de documentación de código abierto.
El sitio oficial de Docmost la presenta como una wiki local lista para la empresa con más de 3 millones de descargas, y dice que es confiada por equipos de organizaciones como Vilnius City, Bechtle, el Gobierno Australiano, la Cruz Roja y ETS Quebec.
El error se encontraba en un lugar fácil de pasar por alto en los sistemas de texto enriquecido:
no en la extensión de enlace normal, sino en un tipo de nodo personalizado separado utilizado para archivos adjuntos.
Estaba revisando la tubería del editor con una pregunta muy específica en mente:
si los enlaces normales bloquean las URLs javascript:, ¿los nodos de adjuntos aplican la misma regla antes de llegar a un sumidero de ancla?
En versiones vulnerables, no lo hacían.
Docmost aceptaba un nodo de adjunto malicioso en el JSON de la página, almacenaba su atributo url sin cambios, y luego renderizaba ese valor de vuelta a un elemento <a href="javascript:..."> enlaceable.
Ese problema se convirtió en CVE-2026-34212.
Docmost: docmost/docmost
Aviso: GHSA-cf68-cff9-hq4w
CVE: CVE-2026-34212
Corregido en: v0.71.0

URL del nodo de adjunto controlada por atacante -> JSON de página aceptado y almacenado sin cambios -> Renderizado HTML/React convierte esa URL en href de ancla -> víctima hace clic en la acción del adjunto -> JavaScript controlado por atacante se ejecuta en el origen de Docmost
Docmost almacena el contenido de la página en un formato JSON compatible con ProseMirror/Tiptap.
Ese modelo de contenido incluye nodos de bloque personalizados para cosas como:
El nodo de adjunto almacena campos como:
urlnamemimesizeattachmentIdEl servidor acepta contenido de página en varios formatos:
jsonmarkdownhtmly lo normaliza a JSON de ProseMirror antes de almacenarlo.
Eso significa que cualquier tipo de nodo que pueda transportar una URL es parte de un límite de confianza directo.
Si uno de esos tipos de nodo eventualmente se renderiza en un <a href>, el manejo del esquema de URL no es opcional.
Es parte del modelo de seguridad.
Las extensiones personalizadas del editor son una fuente frecuente de desviación en la seguridad.
El sistema base puede saber cómo manejar URLs peligrosas correctamente, pero cada nodo personalizado aún debe reaplicar las mismas reglas en sus propios sumideros.
Eso crea una estrategia de revisión predecible:
Eso es exactamente lo que expuso este error.
La extensión de enlace normal de Docmost ya trataba javascript: como peligroso.
Su nodo de adjunto no lo hacía.
Una vez que ves esa asimetría, la pregunta de seguridad se vuelve obvia:
¿puedo persistir un nodo de adjunto cuya url es javascript: y conseguir que se renderice de vuelta en un ancla en vivo?
La respuesta fue sí.
La causa raíz fue la sanitización inconsistente de URLs entre tipos de nodos de contenido.
La ruta de contenido del lado del servidor aceptaba URLs de adjunto arbitrarias siempre que el contenido general coincidiera con el esquema de ProseMirror.
En la versión vulnerable:
CreatePageDto aceptaba content?: string | objectPageService.parseProsemirrorContent() normalizaba markdown, html o jsonjsonToNode(prosemirrorJson)Ese paso de validación verificaba la validez estructural, no la seguridad de la URL.
La parte crítica de la lógica vulnerable del servidor era efectivamente:
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
No ocurría ninguna normalización del esquema de URL de adjunto allí.
Más tarde, la extensión de adjunto renderizaba el valor controlado por el atacante directamente.
El nodo de adjunto vulnerable hacía esto:
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
y luego:
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
En el lado del cliente, la vista de nodo React lo envolvía de nuevo en:
<a href={getFileUrl(url)} target="_blank">
Pero getFileUrl() solo tenía casos especiales para:
http/api/.../files/...Cualquier otra cosa se devolvía sin cambios.
Así que una carga útil como:
javascript:alert(document.domain)
sobrevivía a:
Eso solo ya sería suficiente para XSS almacenado.
Lo que hace especialmente clara la causa raíz es el punto de comparación.
La extensión de enlace normal de Docmost bloqueaba explícitamente javascript::
javascript: en parseHTML()javascript: en renderHTML()Así que el producto ya sabía que este esquema era peligroso.
El nodo de adjunto simplemente no aplicaba la misma política.
Por eso esto no era "XSS genérico en el editor".
Era una brecha en el límite de confianza específica del nodo.
Este error no se trataba meramente de una estética HTML insegura.
Permitía que un atacante que pudiera editar una página persistiera una carga útil maliciosa que luego se ejecutaría en el origen de Docmost cuando otro usuario interactuara con el adjunto renderizado.
Eso importa porque un script en el origen puede:
El requisito de un clic no reduce esto a un problema trivial.
El clic es parte del comportamiento normal del producto: la UI presenta intencionalmente el adjunto como un enlace/icono accionable.
Así que la pregunta de seguridad no es "¿puede el atacante forzar JS arbitrario sin ninguna interacción?"
La verdadera pregunta es:
¿almacena la aplicación contenido controlado por atacante que contiene scripts y luego lo presenta de vuelta a otros usuarios como una ruta de interacción confiable?
En versiones vulnerables, lo hacía.
Eso es XSS almacenado.
La ruta de explotación era directa:
Esto también convertía a los usuarios con privilegios más altos en objetivos realistas.
Si el propietario del espacio de trabajo, un administrador o un editor ampliamente confiado veía contenido controlado por atacante y hacía clic en la acción del adjunto, el script del atacante se ejecutaría en ese contexto de sesión más privilegiado.
Ese es el punto práctico importante:
el requisito de privilegio del atacante era solo bajo. El nivel de privilegio de la víctima determinaba cuánto valor llevaba la sesión XSS.
Validé el problema en vivo contra Docmost v0.70.3.
La PoC solo usaba solicitudes HTTP normales y las API de página de la aplicación.
El flujo era:
POST /api/pages/update con format: "json" y un nodo de adjunto cuya url es una carga útil javascript:.POST /api/pages/info.href sigue siendo javascript:....El contenido malicioso mínimo era:
{
"pageId": "<pageId>",
"content": {
"type": "doc",
"content": [
{
"type": "attachment",
"attrs": {
"url": "javascript:alert(document.domain)",
"name": "policy.pdf",
"mime": "application/pdf",
"size": 1
}
}
]
},
"operation": "replace",
"format": "json"
}
El resultado en vivo observado de mi prueba fue:
019d18cf-4212-70b0-894a-fe20080fb0f1POST /api/pages/info devolvió el JSON almacenado con:"url": "javascript:alert(document.domain)"
POST /api/pages/info con format: "html" devolvió HTML que contenía:<div data-type="attachment" data-attachment-url="javascript:alert(document.domain)" data-attachment-name="policy.pdf" data-attachment-mime="application/pdf" data-attachment-size="1"><a href="javascript:alert(document.domain)" class="attachment" target="blank">policy.pdf</a></div>
Esa respuesta HTML es la prueba crítica.
No necesitaba basarme en una afirmación vaga de que "un navegador podría hacer algo interesante."
La propia aplicación renderizaba el sumidero ejecutable exacto.
Una vez que un usuario hace clic en ese enlace/icono de adjunto, el navegador ejecuta la URL javascript: en el origen de la página que la creó.
Para XSS impulsado por editor, las capturas de pantalla por sí solas son evidencia débil.
Muestran síntomas, no la falla del límite.
Por eso estructure la PoC alrededor de dos puntos de verificación explícitos:
La prueba de almacenamiento mostró que el servidor aceptó y preservó el esquema peligroso.
La prueba de sumidero renderizado mostró que la aplicación convirtió ese valor almacenado de vuelta en:
<a href="javascript:...">
Esa división importa.
Si un producto almacena entrada peligrosa pero la neutraliza antes de cada sumidero, puedes tener una brecha de endurecimiento pero no necesariamente un XSS vivo.
Si el producto almacena entrada peligrosa y luego la renderiza en un sumidero de ejecución real, tienes la cadena completa de vulnerabilidad.
Eso es lo que pasó aquí.
La corrección se envió en v0.71.0 y abordó la ruta de explotación renderizada aplicando sanitización de URL a las URLs de adjuntos.
La extensión de adjunto ahora importa y usa sanitizeUrl, incluyendo:
data-attachment-url durante el análisisdata-attachment-url durante el renderizadohref del anclaConceptualmente, el parche cambió el nodo de adjunto de:
a:
El ayudante del lado del cliente getFileUrl() también se actualizó para que los esquemas desconocidos ya no pasen sin cambios.
En la versión parcheada, la ruta de respaldo devuelve sanitizeUrl(src) en lugar de devolver src tal cual.
Esa es una parte importante de la corrección porque el diseño vulnerable tenía dos problemas que se reforzaban mutuamente:
href sin procesarEl parche eliminó ambas suposiciones.
Esta fue una buena corrección para la ruta de XSS en vivo porque alineó el manejo de URL de adjuntos con el resto del modelo de seguridad del editor.
Dicho esto, todavía hay una lección de endurecimiento más amplia:
la sanitización del lado del cliente o en tiempo de renderizado es necesaria aquí, pero el rechazo del lado del servidor de esquemas peligrosos durante la creación/actualización de páginas sería un invariante aún más fuerte.
El modelo más seguro a largo plazo es:
La defensa en profundidad importa en los sistemas de contenido enriquecido.
Para una cobertura a largo plazo, estos son los casos que más importan:
attachment.attrs.url = "javascript:..."data-attachment-url="javascript:..."href="javascript:..."/api/files/... y /files/... deben seguir funcionando normalmenteEl punto clave es la consistencia.
Si los enlaces normales se sanitizan pero los nodos personalizados que llevan URL no, el editor realmente no tiene una política de seguridad de URL única.
Tiene fragmentos, y los fragmentos son donde viven los errores XSS.
El aviso publicado clasificó este problema como:
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
Eso se sitúa en 7.6 / Alto.
Esta es una clasificación defendible.
Las propiedades importantes son:
La interacción del usuario sigue siendo necesaria porque la víctima tiene que activar el enlace/icono del adjunto.
Por eso UI:R es correcto.
Pero una vez que ocurre esa interacción, el límite de seguridad ya ha fallado mucho antes: la aplicación almacenó un esquema peligroso y lo renderizó de vuelta en un sumidero de ejecución.
Reporté el problema de forma privada a través de GitHub Security Advisories con:
El problema fue aceptado, asignado CVE-2026-34212, y publicado el 14 de abril de 2026.
El aviso público actualmente lista:
0.70.30.71.0Mi validación en vivo se realizó en v0.70.3, que coincidía con la versión vulnerable publicada.
La lección principal no es simplemente "sanitizar URLs".
Todo el mundo ya lo sabe.
La lección más interesante es:
si una aplicación tiene un tipo de nodo que lleva URL seguro y otro inseguro, el inseguro es la política real.
Los sistemas de texto enriquecido a menudo acumulan extensiones personalizadas más rápido de lo que acumulan revisión de seguridad.
Eso crea exactamente este tipo de asimetría:
Este error también muestra por qué la validación de esquema no es suficiente.
jsonToNode() verificó que el contenido era estructuralmente válido como datos de ProseMirror.
No demostró que el contenido fuera seguro de renderizar.
Esas son preguntas diferentes.
La revisión de seguridad se vuelve mucho más precisa cuando mantienes esas preguntas separadas:
El nodo de adjunto pasó la primera pregunta y falló la tercera.
Así es como los errores de contenido almacenado sobreviven dentro de tuberías de editor por lo demás bien estructuradas.
data-attachment-url y href del ancla directamente desde la entrada controlada por el atacante.getFileUrl() devolvía esquemas desconocidos sin cambios.javascript:, pero los nodos de adjunto no.v0.71.0 agregó manejo de sanitizeUrl al nodo de adjunto y a la ruta de respaldo del cliente.Esta vulnerabilidad no se trataba de una peculiaridad del navegador.
Se trataba de un nodo de contenido personalizado que eludía las propias suposiciones de seguridad de URL de la aplicación.
Docmost aceptó una URL de adjunto controlada por el atacante, la preservó durante el almacenamiento y luego la renderizó de vuelta en un ancla en vivo en el origen de la aplicación.
Por eso se convirtió en CVE-2026-34212.
El parche en v0.71.0 cerró limpiamente la ruta de XSS activa, pero la lección más amplia es la que vale la pena conservar:
en aplicaciones con mucho editor, cada nodo personalizado que puede llevar una URL es su propio límite de seguridad, y debe revisarse como tal.