
O Docmost aceitou uma URL javascript: dentro de um nó de anexo, preservou-a através do armazenamento e renderização, e a transformou novamente em uma âncora clicável na origem do Docmost.
O Docmost aceitou uma URL javascript: dentro de um nó de anexo, preservou-a durante o armazenamento e a renderização, e a transformou novamente em um âncora clicável na origem do Docmost.
Identifiquei, divulguei responsavelmente e reproduzi um problema de XSS armazenado de gravidade Alta no Docmost, a plataforma de documentação colaborativa de código aberto.
O site oficial do Docmost o apresenta como uma wiki on-premises pronta para empresas com mais de 3 milhões de downloads, e afirma ser confiável por equipes de organizações como Vilnius City, Bechtle, o Governo Australiano, a Cruz Vermelha e ETS Quebec.
O bug estava em um local fácil de passar despercebido em sistemas de texto rico:
não na extensão de link comum, mas em um tipo de nó personalizado separado usado para anexos de arquivos.
Estava revisando o pipeline do editor com uma pergunta muito específica em mente:
se links normais bloqueiam URLs javascript:, os nós de anexo aplicam a mesma regra antes de chegar a um sink de âncora?
Em versões vulneráveis, não aplicavam.
O Docmost aceitou um nó de anexo malicioso no JSON da página, armazenou seu atributo url inalterado e, posteriormente, renderizou esse valor de volta em um elemento <a href="javascript:..."> clicável.
Esse problema se tornou CVE-2026-34212.
Docmost: docmost/docmost
Advisory: GHSA-cf68-cff9-hq4w
CVE: CVE-2026-34212
Corrigido em: v0.71.0
URL do nó de anexo controlada pelo atacante -> JSON da página aceito e armazenado inalterado -> Renderização HTML/React transforma essa URL em href de âncora -> vítima clica na ação do anexo -> JavaScript controlado pelo atacante é executado na origem do Docmost
O Docmost armazena o conteúdo da página em um formato JSON compatível com ProseMirror/Tiptap.
Esse modelo de conteúdo inclui nós de bloco personalizados para coisas como:
O nó de anexo armazena campos como:
urlnamemimesizeattachmentIdO servidor aceita o conteúdo da página em vários formatos:
jsonmarkdownhtmle o normaliza para JSON ProseMirror antes de armazená-lo.
Isso significa que qualquer tipo de nó que possa carregar uma URL faz parte de um limite de confiança direto.
Se um desses tipos de nó eventualmente renderizar em um <a href>, o tratamento de esquema de URL não é opcional.
Faz parte do modelo de segurança.
Extensões de editor personalizadas são uma fonte frequente de desvio de segurança.
O sistema base já pode saber como lidar corretamente com URLs perigosas, mas cada nó personalizado ainda precisa reaplicar as mesmas regras em seus próprios sinks.
Isso cria uma estratégia de revisão previsível:
Foi exatamente isso que expôs esse bug.
A extensão de link normal do Docmost já tratava javascript: como perigoso.
Seu nó de anexo não tratava.
Uma vez que você vê essa assimetria, a questão de segurança se torna óbvia:
posso persistir um nó de anexo cuja url seja javascript: e fazê-lo ser renderizado de volta em uma âncora viva?
A resposta foi sim.
A causa raiz foi sanitização inconsistente de URL entre tipos de nós de conteúdo.
O caminho de conteúdo do lado do servidor aceitava URLs de anexo arbitrárias, desde que o conteúdo geral correspondesse ao esquema ProseMirror.
Na versão vulnerável:
CreatePageDto aceitava content?: string | objectPageService.parseProsemirrorContent() normalizava markdown, html ou jsonjsonToNode(prosemirrorJson)Essa etapa de validação verificava a validade estrutural, não a segurança da URL.
A parte crítica da lógica vulnerável do servidor era efetivamente:
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
Nenhuma normalização de esquema de URL de anexo ocorria ali.
Posteriormente, a extensão de anexo renderizava o valor controlado pelo atacante diretamente.
O nó de anexo vulnerável fazia isso:
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
e depois:
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
No lado do cliente, a visualização do nó React envolvia isso novamente em:
<a href={getFileUrl(url)} target="_blank">
Mas getFileUrl() só tratava casos especiais:
http absolutas/api/.../files/...Qualquer outra coisa era retornada inalterada.
Assim, um payload como:
javascript:alert(document.domain)
sobrevivia a:
Isso por si só já seria suficiente para XSS armazenado.
O que torna a causa raiz especialmente clara é o ponto de comparação.
A extensão de link normal do Docmost bloqueava explicitamente javascript::
javascript: em parseHTML()javascript: em renderHTML()Portanto, o produto já sabia que esse esquema era perigoso.
O nó de anexo simplesmente falhou em aplicar a mesma política.
É por isso que isso não era "XSS genérico no editor."
Era uma lacuna de limite de confiança específica do nó.
Este bug não era meramente sobre estética HTML insegura.
Permitia que um atacante que pudesse editar uma página persistisse um payload malicioso que mais tarde seria executado na origem do Docmost quando outro usuário interagisse com o anexo renderizado.
Isso importa porque um script na origem pode:
A exigência de um clique não reduz isso a um problema trivial.
O clique faz parte do comportamento normal do produto: a interface apresenta intencionalmente o anexo como um link/ícone acionável.
Portanto, a questão de segurança não é "o atacante pode forçar JS arbitrário sem qualquer interação?"
A verdadeira questão é:
a aplicação armazena conteúdo malicioso contendo script controlado pelo atacante e posteriormente o apresenta a outros usuários como um caminho de interação confiável?
Em versões vulneráveis, sim.
Isso é XSS armazenado.
O caminho de exploração era direto:
Isso também tornava usuários com privilégios mais altos alvos realistas.
Se um proprietário do workspace, administrador ou editor amplamente confiável visualizasse conteúdo controlado pelo atacante e clicasse na ação do anexo, o script do atacante seria executado no contexto de sessão mais privilegiado.
Esse é o ponto prático importante:
o requisito de privilégio do atacante era apenas baixo. O nível de privilégio da vítima determinava quanto valor a sessão XSS carregava.
Validei o problema ao vivo contra Docmost v0.70.3.
A PoC usou apenas requisições HTTP normais e as próprias APIs de página da aplicação.
O fluxo foi:
POST /api/pages/update com format: "json" e um nó de anexo cuja url seja um payload javascript:.POST /api/pages/info.href ainda é javascript:....O conteúdo 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"
}
O resultado ao vivo observado do meu teste foi:
019d18cf-4212-70b0-894a-fe20080fb0f1POST /api/pages/info retornou o JSON armazenado com:"url": "javascript:alert(document.domain)"
POST /api/pages/info com format: "html" retornou HTML contendo:<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>
Essa resposta HTML é a prova crítica.
Não precisei confiar em uma afirmação vaga de que "um navegador pode fazer algo interessante."
A própria aplicação renderizou o sink executável exato.
Assim que um usuário clicar nesse link/ícone de anexo, o navegador executa a URL javascript: na origem da página que a criou.
Para XSS orientado a editor, capturas de tela sozinhas são evidências fracas.
Elas mostram sintomas, não a falha de limite.
Por isso, estruturei a PoC em torno de dois pontos de verificação explícitos:
A prova de armazenamento mostrou que o servidor aceitou e preservou o esquema perigoso.
A prova de sink renderizado mostrou que a aplicação transformou esse valor armazenado de volta em:
<a href="javascript:...">
Essa divisão é importante.
Se um produto armazena entrada perigosa mas a neutraliza antes de cada sink, você pode ter uma lacuna de hardening, mas não necessariamente um XSS vivo.
Se o produto armazena entrada perigosa e posteriormente a renderiza em um sink de execução real, você tem a cadeia completa da vulnerabilidade.
Foi o que aconteceu aqui.
A correção foi enviada na v0.71.0 e abordou o caminho de exploração renderizado aplicando sanitização de URL às URLs de anexo.
A extensão de anexo agora importa e usa sanitizeUrl, incluindo:
data-attachment-url durante o parsingdata-attachment-url durante a renderizaçãohref da âncoraConceitualmente, o patch mudou o nó de anexo de:
para:
O helper do lado do cliente getFileUrl() também foi atualizado para que esquemas desconhecidos não passem mais incólumes.
Na versão corrigida, o caminho de fallback retorna sanitizeUrl(src) em vez de retornar src como está.
Essa é uma parte importante da correção porque o design vulnerável tinha dois problemas reforçadores:
href brutoO patch removeu ambas as suposições.
Esta foi uma boa correção para o caminho ativo de XSS porque trouxe o tratamento de URL de anexo de volta ao alinhamento com o restante do modelo de segurança do editor.
Dito isso, ainda há uma lição mais ampla de hardening:
a sanitização no lado do cliente ou no momento da renderização é necessária aqui, mas a rejeição no lado do servidor de esquemas perigosos durante a criação/atualização da página seria uma invariante ainda mais forte.
O modelo mais seguro a longo prazo é:
Defesa em profundidade é importante em sistemas de conteúdo rico.
Para cobertura de longo prazo, estes são os casos que mais importam:
attachment.attrs.url = "javascript:..."data-attachment-url="javascript:..."href="javascript:..."/api/files/... e /files/... devem continuar funcionando normalmenteO ponto chave é consistência.
Se links normais são sanitizados, mas nós personalizados que carregam URL não são, o editor não tem realmente uma política de segurança de URL única.
Ele tem fragmentos, e fragmentos são onde bugs de XSS vivem.
O advisory publicado classificou este problema como:
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
Isso resulta em 7.6 / Alto.
Esta é uma classificação defensável.
As propriedades importantes são:
A interação do usuário permanece necessária porque a vítima precisa ativar o link/ícone do anexo.
É por isso que UI:R está correto.
Mas uma vez que essa interação acontece, o limite de segurança já falhou muito antes: a aplicação armazenou um esquema perigoso e o renderizou de volta em um sink de execução.
Relatei o problema privadamente através do GitHub Security Advisories com:
O problema foi aceito, recebeu o identificador CVE-2026-34212 e foi publicado em 14 de abril de 2026.
O advisory público atualmente lista:
0.70.30.71.0Minha validação ao vivo foi realizada na v0.70.3, que correspondia à versão vulnerável publicada.
A principal lição aqui não é simplesmente "sanitize URLs."
Todo mundo já sabe disso.
A lição mais interessante é:
se uma aplicação tem um tipo de nó que carrega URL seguro e um tipo de nó que carrega URL inseguro, o inseguro é a política real.
Sistemas de texto rico frequentemente acumulam extensões personalizadas mais rápido do que acumulam revisão de segurança.
Isso cria exatamente esse tipo de assimetria:
Este bug também mostra por que a validação de esquema não é suficiente.
jsonToNode() verificou que o conteúdo era estruturalmente válido segundo o ProseMirror.
Não provou que o conteúdo era seguro para renderizar.
Essas são perguntas diferentes.
A revisão de segurança se torna muito mais nítida quando você mantém essas perguntas separadas:
O nó de anexo passou na primeira pergunta e falhou na terceira.
É assim que bugs de conteúdo armazenado sobrevivem dentro de pipelines de editor de outra forma bem estruturados.
data-attachment-url e o href da âncora diretamente a partir da entrada controlada pelo atacante.getFileUrl() retornava esquemas desconhecidos inalterados.javascript:, mas nós de anexo não.v0.71.0 adicionou tratamento sanitizeUrl ao nó de anexo e ao caminho de fallback do cliente.Esta vulnerabilidade não era sobre uma peculiaridade do navegador.
Era sobre um nó de conteúdo personalizado que contornava as próprias suposições de segurança de URL da aplicação.
O Docmost aceitou uma URL de anexo controlada pelo atacante, preservou-a durante o armazenamento e depois a renderizou de volta em uma âncora viva na origem da aplicação.
É por isso que se tornou CVE-2026-34212.
O patch na v0.71.0 fechou o caminho ativo de XSS de forma limpa, mas a lição mais ampla é a que vale a pena guardar:
em aplicações pesadas de editor, cada nó personalizado que pode carregar uma URL é seu próprio limite de segurança, e precisa ser revisado como tal.