
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: