
Prova de conceito e análise de um XSS armazenado no filtro de URL isSafeUrl() do Instatic, onde caracteres de controle C0 iniciais contornam o bloqueio do esquema javascript:.
isSafeUrl() via caracteres de controle C0 iniciaisAfetado: Instatic v0.0.13 / commit 63ad5d6 (e todas as revisões anteriores que contenham src/core/html-sanitize/index.ts)
Componente: src/core/html-sanitize/index.ts → isSafeUrl() / safeUrl()
Classe: CWE-79 (XSS Armazenado) via CWE-184 (Lista Incompleta de Entradas Não Permitidas)
isSafeUrl() é o único ponto de estrangulamento que bloqueia URLs javascript:, vbscript:
e data: em todo o publisher. Ele normaliza a entrada com
.replace(/[\t\n\r]/g, '').trim() antes de testar o prefixo do esquema.
O parser de URL WHATWG remove todos os caracteres de controle C0 iniciais
(U+0000–U+001F) e espaço antes de ler um esquema. O
String.prototype.trim() do JavaScript remove apenas U+0009, U+000A, U+000B, U+000C, U+000D,
U+0020 e espaços Unicode — ele deixa U+0000–U+0008 e U+000E–U+001F
no lugar.
Assim, uma URL prefixada com, por exemplo, U+0001 é reportada como segura pela guarda, enquanto todo
navegador a analisa e executa como o esquema javascript:.
Contra o src/core/html-sanitize/index.ts não modificado:
payload : "\x01javascript:alert(document.domain)"
isSafeUrl() : true <-- guard reports "safe"
WHATWG URL scheme : javascript: <-- what the browser actually runs
safeUrl() output : "\x01javascript:alert(document.domain)" (NOT collapsed to "#")
27 dos 32 caracteres de controle C0 contornam a verificação. U+0000 é neutralizado pela
análise de atributos HTML (NUL → U+FFFD), deixando 26 prefixos exploráveis de forma confiável
(U+0001–U+0008, U+000E–U+001F). O mesmo bypass derrota os
filtros vbscript: e data:.
A suíte existente (src/__tests__/publisher/utils.test.ts) cobre
case folding e tabs incorporadas (java\tscript:), mas nunca um caractere de controle
inicial, razão pela qual isso não foi detectado.
base.link declara href: { type: 'url' } e LinkPropsSchema o tipa como
um Type.String({ default: '#' }) sem restrições — não há validação de URL
no momento da escrita. Portanto:
escapeProps() encaminha props do tipo type: 'url' | 'image' | 'media' para
isSafeUrl(value) ? value : '#' — o payload passa bruto e
deliberadamente sem escape HTML.render() de base.link emite `<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%24%7BsafeUrl%28props.href%29%7D" …>`.
safeUrl() verifica novamente com o mesmo isSafeUrl() quebrado, depois
escapeHtml() — que só escapa & < > " ' e não toca em caracteres
de controle.href:
<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%5Cx01javascript%3Aalert%28document.domain%29" target="_self">Click me</a>Todos estes passam pelo mesmo isSafeUrl():
| Sink | Arquivo |
|---|---|
Toda prop de módulo url / image / media (link href, button href, image src, video src/poster, form action, form redirectUrl) | src/core/publisher/escapeProps.ts:108 |
| Atributos HTML personalizados arbitrários definidos pelo usuário em qualquer nó | src/core/htmlAttributes/attributes.ts:66 |
href e src de link/imagem Markdown | src/core/markdown/renderMarkdown.ts:75 |
faviconUrl do site | src/core/publisher/render.ts:322 |
Chamada safeUrl() de todo módulo base | src/modules/base/utils/escape.ts |
Escalação de privilégios de um editor de baixo privilégio para comprometimento total de admin.
O papel Client embutido possui site.content.edit, o que é suficiente para definir
um href de link ou um atributo HTML personalizado. A URL injetada então é renderizada no
canvas do editor de admin, que é um iframe srcdoc — mesma origem que
/admin.
server/securityHeaders.ts:69-72 define apenas
frame-ancestors 'none'; base-uri 'self'; object-src 'none' em /admin, com
um comentário de código explícito de que uma política script-src é deliberadamente não definida
ainda. Sem script-src, nada impede que uma URL javascript: seja executada
na origem do admin.
Quando um Owner ou Admin abre a página afetada no editor e ativa o
elemento, o payload é executado na mesma origem que a SPA de admin. O cookie de sessão é
HttpOnly, então não pode ser lido diretamente — mas o payload pode conduzir a API de admin
como a vítima (criar uma conta de owner, ler segredos ou instalar um plugin,
cujo entrypoint de servidor é um caminho para execução de código).
No site publicado o impacto é mais restrito: cspPlan.ts define
script-src 'none' (ou 'self'), o que bloqueia URLs javascript:. No entanto
server/publish/frontendInjections.ts:377 relaxa isso para
'self' 'unsafe-inline' para qualquer página que carregue um script inline, e
'unsafe-inline' volta a permitir URLs javascript: — então XSS no site publicado é
alcançável nessas páginas.
Observe que a docstring de attributes.ts já identifica exatamente esse modelo de ameaça
("no site publicado E, mais seriamente, dentro do canvas do editor de admin
(mesma origem que /admin)") — a guarda simplesmente não o implementa
completamente.
Confirmado em um navegador (Chromium, iframes srcdoc reproduzindo a CSP de cada origem,
renderizando a saída byte a byte do próprio safeUrl() do repositório):
| Origem reproduzida | CSP aplicada | Resultado |
|---|---|---|
Canvas do editor de admin (/admin) | nenhuma — como securityHeaders.ts emite | javascript: executado |
| Página publicada (baseline) | script-src 'none' — como cspPlan.ts emite | bloqueado (script-src-elem) |
| Página publicada com um script inline | script-src 'self' 'unsafe-inline' — como frontendInjections.ts:377 emite | ver nota |
As duas primeiras linhas são resultados observados. A execução na origem de admin reportou
document.domain como a origem de serviço, confirmando execução na mesma origem
em vez de um contexto opaco.
Ressalva sobre fidelidade: o harness aplica cada política via <meta http-equiv>,
enquanto o Instatic a envia como cabeçalho de resposta HTTP. Eles são equivalentes para
a aplicação de script-src, mas uma reprodução em uma instância real de bun run dev
carregaria os cabeçalhos genuínos.
Remova toda a faixa C0 + espaço em vez de confiar em trim(). Isso é
precisamente o que a própria regex isJavaScriptProtocol do React faz com seu
prefixo ^[\u0000-\u001F ]* — uma verificação cruzada útil de que esta é uma
classe de bypass conhecida e do mundo real, e não teórica.
--- a/src/core/html-sanitize/index.ts
+++ b/src/core/html-sanitize/index.ts
@@ -30,7 +30,11 @@ export function escapeHtml(value: unknown): string {
* normalisation browsers apply during URL parsing.
*/
export function isSafeUrl(url: string): boolean {
- const normalized = url.replace(/[\t\n\r]/g, '').trim().toLowerCase()
+ const normalized = String(url ?? '')
+ .replace(/[\t\n\r]/g, '')
+ .replace(/^[\u0000-\u0020]+/, '')
+ .replace(/[\u0000-\u0020]+$/, '')
+ .toLowerCase()
return (
!normalized.startsWith('javascript:') &&
!normalized.startsWith('vbscript:') &&
Verificado contra o arquivo corrigido: o payload é bloqueado, safeUrl()
o colapsa para #, 0 de 99 URLs perigosas prefixadas com controle/espaço permanecem
aceitas, e todos os 14 casos de teste existentes de isSafeUrl se comportam de forma idêntica.
it('blocks javascript: behind a leading C0 control character', () => {
for (let i = 0x00; i <= 0x1f; i++) {
expect(isSafeUrl(String.fromCharCode(i) + 'javascript:alert(1)')).toBe(false)
}
})