
Docmost ha accettato un URL javascript: all'interno di un nodo allegato, lo ha preservato attraverso l'archiviazione e il rendering, e lo ha ritrasformato in un'ancora cliccabile nell'origine di Docmost.
Docmost accettava un URL javascript: all'interno di un nodo allegato, lo conservava attraverso lo storage e il rendering, e lo trasformava di nuovo in un anchor cliccabile nell'origine di Docmost.
Ho identificato, divulgato responsabilmente e riprodotto un problema di XSS memorizzato ad alta gravità in Docmost, la piattaforma di documentazione collaborativa open-source.
Il sito ufficiale di Docmost la presenta come un wiki on-premises pronto per l'uso enterprise con oltre 3 milioni di download, e afferma che è utilizzata da team di organizzazioni tra cui Vilnius City, Bechtle, il Governo Australiano, la Croce Rossa e ETS Quebec.
Il bug si trovava in un punto facile da trascurare nei sistemi rich-text:
non nell'estensione dei link ordinari, ma in un tipo di nodo personalizzato separato usato per gli allegati di file.
Stavo esaminando la pipeline dell'editor con una domanda molto specifica in mente:
se i link normali bloccano gli URL javascript:, i nodi allegato applicano la stessa regola prima di raggiungere un sink di anchor?
Nelle versioni vulnerabili, non lo facevano.
Docmost accettava un nodo allegato malevolo nel JSON della pagina, conservava il suo attributo url inalterato, e successivamente rendeva quel valore in un elemento <a href="javascript:..."> cliccabile.
Quel problema è diventato CVE-2026-34212.
Docmost: docmost/docmost
Advisory: GHSA-cf68-cff9-hq4w
CVE: CVE-2026-34212
Corretto in: v0.71.0
URL del nodo allegato controllato dall'attaccante -> JSON della pagina accettato e memorizzato inalterato -> rendering HTML/React trasforma quell'URL in href di anchor -> la vittima clicca sull'azione dell'allegato -> JavaScript controllato dall'attaccante viene eseguito nell'origine di Docmost
Docmost memorizza il contenuto della pagina in un formato JSON compatibile con ProseMirror/Tiptap.
Questo modello di contenuto include nodi blocco personalizzati per cose come:
Il nodo allegato memorizza campi come:
urlnamemimesizeattachmentIdIl server accetta il contenuto della pagina in diversi formati:
jsonmarkdownhtmle lo normalizza in JSON ProseMirror prima di memorizzarlo.
Ciò significa che ogni tipo di nodo che può trasportare un URL fa parte di un confine di fiducia diretto.
Se uno di questi tipi di nodo alla fine viene reso in un <a href>, la gestione dello schema URL non è opzionale.
Fa parte del modello di sicurezza.
Le estensioni personalizzate dell'editor sono una frequente fonte di deriva di sicurezza.
Il sistema di base può già sapere come gestire correttamente gli URL pericolosi, ma ogni nodo personalizzato deve comunque riapplicare le stesse regole ai propri sink.
Questo crea una strategia di revisione prevedibile:
È esattamente quello che ha esposto questo bug.
La normale estensione dei link di Docmost già trattava javascript: come pericoloso.
Il suo nodo allegato no.
Una volta vista questa asimmetria, la domanda di sicurezza diventa ovvia:
posso persistere un nodo allegato il cui url è javascript: e ottenerlo renderizzato di nuovo in un anchor attivo?
La risposta è stata sì.
La causa principale era una sanitizzazione inconsistente degli URL tra i tipi di nodo di contenuto.
Il percorso del contenuto lato server accettava URL arbitrari degli allegati purché il contenuto complessivo corrispondesse allo schema ProseMirror.
Nella versione vulnerabile:
CreatePageDto accettava content?: string | objectPageService.parseProsemirrorContent() normalizzava markdown, html, o jsonjsonToNode(prosemirrorJson)Quel passaggio di validazione verificava la validità strutturale, non la sicurezza dell'URL.
La parte critica della logica del server vulnerabile era essenzialmente:
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
}
Non c'era normalizzazione dello schema URL dell'allegato.
Successivamente, l'estensione dell'allegato renderizzava direttamente il valore controllato dall'attaccante.
Il nodo allegato vulnerabile faceva questo:
```ts
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
e poi:
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
Lato client, la vista del nodo React lo rimpacchettava di nuovo in:
<a href={getFileUrl(url)} target="_blank">
Ma getFileUrl() specializzava solo:
http assoluti/api/.../files/...Tutto il resto veniva restituito inalterato.
Quindi un payload come:
javascript:alert(document.domain)
sopravviveva a:
Questo da solo sarebbe già sufficiente per XSS memorizzato.
Ciò che rende la causa principale particolarmente chiara è il punto di confronto.
La normale estensione dei link di Docmost bloccava esplicitamente javascript::
javascript: in parseHTML()javascript: in renderHTML()Quindi il prodotto sapeva già che questo schema era pericoloso.
Il nodo allegato semplicemente non applicava la stessa politica.
Ecco perché questo non era "XSS generico nell'editor."
Era un gap nel confine di fiducia specifico del nodo.
Questo bug non riguardava solo un'estetica HTML non sicura.
Permetteva a un attaccante in grado di modificare una pagina di persistere un payload malevolo che successivamente sarebbe stato eseguito nell'origine di Docmost quando un altro utente interagiva con l'allegato renderizzato.
Questo è importante perché uno script in-origine può:
Il requisito di un clic non riduce questo a un problema banale.
Il clic fa parte del comportamento normale del prodotto: l'interfaccia utente presenta intenzionalmente l'allegato come un link/icona cliccabile.
Quindi la domanda di sicurezza non è "l'attaccante può forzare JS arbitrario senza alcuna interazione?"
La domanda reale è:
l'applicazione memorizza contenuti malevoli portatori di script controllati dall'attaccante e li presenta successivamente ad altri utenti come un percorso di interazione fidato?
Nelle versioni vulnerabili, lo faceva.
Questo è XSS memorizzato.
Il percorso di sfruttamento era semplice: