Segurança
O Slash é seguro por padrão: você escreve templates do jeito normal e a biblioteca faz a coisa segura. Esta página explica as regras para quem está começando. A referência completa fica no repositório do core, em docs/19-security.
A regra de ouro
Section titled “A regra de ouro”Uma string é sempre dado, nunca HTML. Isso vale no cliente (html) e no servidor (htmlString, renderToString, renderToStream). Toda string é escapada, inclusive as devolvidas por componentes, por state.get() e por reativos. Não existe exceção para “strings que começam com <” e não existe helper para escapar: basta interpolar.
import { html } from '@_bashell/slash/core'
const comentario = '<img src=x onerror="alert(1)">'
html`<p>${comentario}</p>` // mostra o texto literal; nada executaNo servidor é igual:
import { htmlString } from '@_bashell/slash/ssr'
String(htmlString`<p>${'<img src=x onerror=alert(1)>'}</p>`)// <p><img src=x onerror=alert(1)></p>Para marcação confiável existe um tipo próprio, o SafeHtml. Você o recebe de dois lugares: de um template htmlString (por isso templates aninhados, componentes e listas como items.map(...) funcionam sem nenhum wrapper) e de unsafeHtml(...).
As duas saídas explícitas
Section titled “As duas saídas explícitas”Às vezes você realmente tem marcação ou uma URL confiável. Existem exatamente duas formas de dizer isso:
| Função | Quando usar | Retorna |
|---|---|---|
unsafeHtml(html) | marcação confiável que você mesmo gerou (um ícone SVG, um Markdown já renderizado e sanitizado) | SafeHtml |
unsafeUrl(url) | URL confiável com um esquema que a política bloqueia (por exemplo myapp://abrir/42) | SafeUrl |
import { html, unsafeHtml, unsafeUrl } from '@_bashell/slash/core'
html`<button>${unsafeHtml('<svg viewBox="0 0 8 8"><circle cx="4" cy="4" r="3"/></svg>')} Salvar</button>`html`<a href=${unsafeUrl('myapp://abrir/42')}>Abrir no app</a>`isSafeHtml(x) e isSafeUrl(x) identificam esses valores. Eles não podem ser forjados por JSON vindo da rede, então dados de uma API nunca viram SafeHtml.
Montando a página no servidor
Section titled “Montando a página no servidor”A casca da página (<!DOCTYPE>, <head>, scripts) é um template literal comum, não um htmlString. O conteúdo do Slash entra como renderToString(...).html e o estado entra com serializeStateForScript:
import { htmlString, renderToString, serializeStateForScript } from '@_bashell/slash/ssr'
const App = () => htmlString`<h1>Olá, SSR!</h1>`
const { html, state } = renderToString(App)
const pagina = `<!DOCTYPE html><html> <body> <div id="app">${html}</div> <script id="__SLASH_STATE__" type="application/json">${serializeStateForScript(state)}</script> <script type="module" src="/client.js"></script> </body></html>`Duas coisas importantes:
- Nunca use
JSON.stringifydentro de<script>. Um valor como"</script><img onerror=...>"fecha a tag e executa código.serializeStateForScript(eserializeLoaderData) escapam<,>,&, U+2028 e U+2029, e oJSON.parsedevolve exatamente o valor original. - Um template literal comum não escapa nada. Se você colocar um dado de usuário nele à mão (um
<title>, por exemplo), escape você mesmo:
const escapeHtml = (valor: string) => valor .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''')
const titulo = `<title>${escapeHtml(post.title)}</title>`<script> e <style> com valores dinâmicos
Section titled “<script> e <style> com valores dinâmicos”Dentro de html e htmlString, o texto estático de um <script> ou <style> é mantido como está, mas valores dinâmicos (strings, números, arrays, componentes, templates aninhados e reativos) são descartados, no cliente e no servidor, com aviso em dev. Só passam o texto estático e unsafeHtml(...). Para JSON em outro <script>:
htmlString`<script type="application/ld+json">${unsafeHtml(serializeStateForScript(dados))}</script>`No cliente, unsafeHtml precisa ser filho direto do <script>/<style>; vindo de um componente, de um array ou de uma função, ele também é descartado. As props text, textContent e innerText desses elementos também exigem unsafeHtml. Chamadas diretas a h()/hString() (uso avançado) tratam uma string como texto estático confiável, então nunca passe entrada de usuário a elas.
Uma limitação do htm: um < literal dentro de um <script> estático (if (a < b)) é lido como início de tag. Coloque esse código em unsafeHtml(...).
Política de URLs
Section titled “Política de URLs”Atributos que carregam URL (href, src, action, formaction, xlink:href, poster, srcset, entre outros) só aceitam:
http:,https:,mailto:,tel:esms:;- URLs relativas:
/x,./x,../x,?q,#he caminhos sem esquema; data:image/png|jpeg|gif|webp|avif, somente em atributos de imagem (img src,srcset,poster);blob:, somente emsrcde mídia (img,audio,video,source,track);data:image/svg+xml, somente emimg src,img srcsete emurl()de CSS (como imagem, o SVG não executa scripts).
Todo o resto (javascript:, vbscript:, data:text/html, file:, ftp:, whatsapp:…) vira about:blank#blocked, com um aviso em dev. blob: e data:image/svg+xml em href, iframe, object ou embed também são bloqueados. Para um esquema que a lista não cobre, use unsafeUrl(...). Truques com espaços, tabs ou caracteres de controle (java\tscript:) também são bloqueados. Um srcset ou content de meta refresh com mais de 16 KB é bloqueado por inteiro.
html`<a href=${'javascript:alert(1)'}>x</a>` // <a href="about:blank#blocked">html`<img src=${'data:image/png;base64,iVBOR...'} />` // permitidohtml`<img src=${URL.createObjectURL(arquivo)} />` // blob: permitido em mídiaA URL do content de <meta http-equiv="refresh" content="5;url=..."> segue a mesma regra. Para uma URL confiável fora da política, use unsafeUrl(...); ela só vale em atributos de URL (e nesse content).
Validando entrada com sanitizeUrl
Section titled “Validando entrada com sanitizeUrl”sanitizeUrl e BLOCKED_URL expõem a mesma política para URLs que não passam por um atributo do Slash (um redirect no servidor, por exemplo). sanitizeUrl valida o esquema, não o destino: //evil.com e https://evil.com passam. Num redirect, confira também a origem:
import { sanitizeUrl, BLOCKED_URL } from '@_bashell/slash/core'
const base = new URL('https://app.example.com')
function destinoSeguro(next: string): string { if (sanitizeUrl('href', next) === BLOCKED_URL) return '/' try { const url = new URL(next, base) return url.origin === base.origin ? url.pathname + url.search + url.hash : '/' } catch { return '/' }}
res.redirect(destinoSeguro(req.query.next ?? '/'))Links do roteador
Section titled “Links do roteador”O Link aceita somente caminhos do app: /x, ?q ou #h. Qualquer outra coisa nunca navega: o clique recebe preventDefault, o href vira about:blank#blocked e há um aviso em dev. Isso inclui ./x, ../x, about (caminho relativo sem barra), //host, javascript: e https://....
Para um link externo de verdade, diga isso com a prop external:
import { html } from '@_bashell/slash/core'import { Link } from '@_bashell/slash/router'
html`<${Link} to="https://example.com/docs" external router=${router}>Docs<//>`// <a href="https://example.com/docs" rel="noopener noreferrer">Docs</a>external só libera http(s), mailto:, tel: e sms: e adiciona rel="noopener noreferrer". No SSR o Link gera o mesmo <a href>. ?q e #h são relativos à página atual.
O roteador só intercepta cliques em links http(s) da mesma origem (respeitando <base>). Com ctrl, meta, shift ou alt, botão que não seja o esquerdo, target diferente de _self ou download, o navegador age normalmente. Barras invertidas em router.push() viram o caminho da mesma origem ('/\\evil' vai para /evil). Se o navegador recusar a navegação, a promise rejeita com Navigation failed: ... e o estado do roteador continua igual à URL.
Eventos
Section titled “Eventos”Toda prop cujo nome começa com on (em qualquer caixa) é um evento. Só são anexados: uma função, um objeto com handleEvent ou uma tupla [fn, opções]. Qualquer outro valor (string, booleano, objeto) é descartado com aviso em dev, no cliente e no servidor:
html`<button onClick=${() => salvar()}>Salvar</button>` // okhtml`<button onclick="alert(1)">Salvar</button>` // onclick descartadoSe você precisa de um atributo comum que começa com “on” (online, one-time), use o prefixo data-: data-online.
innerHTML, outerHTML e srcdoc
Section titled “innerHTML, outerHTML e srcdoc”As props innerHTML, outerHTML e insertAdjacentHTML são bloqueadas (no servidor saem apenas como atributo inerte, com o valor escapado). Para inserir marcação confiável, use unsafeHtml como filho:
html`<div>${unsafeHtml(htmlConfiavel)}</div>`srcdoc só aceita SafeHtml:
html`<iframe srcdoc=${unsafeHtml('<p>oi</p>')}></iframe>` // okhtml`<iframe srcdoc=${'<p>oi</p>'}></iframe>` // atributo removidoNomes de atributo inválidos (com espaço ou >, por exemplo) são descartados, e nomes de tag inválidos lançam erro: são erros de programação. A gramática de nome de atributo é a mesma no cliente e no servidor e só aceita ASCII (@click e [x] são descartados). Uma prop com nome de método do DOM (click, focus…) vira atributo e nunca sobrescreve o método; constructor e props de protótipo são bloqueadas.
Estilos (style)
Section titled “Estilos (style)”style aceita string ou objeto, e cada declaração passa por uma política de CSS estrita no cliente e no servidor. Uma declaração insegura é descartada (com aviso em dev) e as demais são mantidas:
html`<div style="color:red;background:url(javascript:alert(1))">x</div>`// <div style="color:red">x</div>Uma declaração é descartada quando:
- contém
/*em qualquer lugar: comentários não são permitidos emstyleinline; - tem uma barra invertida (
\) fora de aspas; dentro de aspas os escapes continuam funcionando (content:"\2022"); - tem uma string com quebra de linha crua, CR, FF ou NUL, com barra invertida seguida de quebra de linha, ou sem fechamento;
- tem um
url(sem aspas com caracteres fora de[A-Za-z0-9-._~:/?#@!$&+,;=%]; qualquer outro caractere exige aspas (url("a b.png")passa,url(a b.png)não); - usa
-moz-binding,behavioroubehaviourcomo propriedade (scroll-behavioré permitido).
Além disso:
url(), com ou sem aspas, segue a mesma lista de permissão dehref/src;image-set(),image(),cross-fade(),element(),paint(),src()eexpression()são fiscalizadas;- o valor não pode conter
{,},<,@import,javascript:nemvbscript:; - quebras de linha entre declarações são válidas, então um template literal em várias linhas funciona;
- um
stylecom mais de 8 KB é descartado por inteiro, e umstyleque fica vazio é omitido.
Dados sem protótipo
Section titled “Dados sem protótipo”formToObject() e o query do roteador devolvem objetos sem protótipo (Object.create(null)). Nomes como __proto__ ou constructor viram chaves comuns e não afetam nada. Em troca, obj.hasOwnProperty(...) não existe; use Object.hasOwn(obj, 'campo') ou 'campo' in obj. O clone interno do estado lança State is circular or nested deeper than 1000 levels para estado circular ou muito profundo.
Guards do cliente são UX
Section titled “Guards do cliente são UX”Guards do roteador, botões escondidos e rotas “protegidas” no navegador melhoram a experiência, mas qualquer pessoa com o DevTools passa por cima. O servidor autoriza cada requisição. No SSR os guards não rodam.
Diferenças conhecidas entre cliente e SSR
Section titled “Diferenças conhecidas entre cliente e SSR”O Slash testa as mesmas entradas nos dois lados e o resultado é igual, com estas exceções:
innerHTML=...,outerHTML=...einsertAdjacentHTML=...: o cliente bloqueia a prop; o SSR emite um atributo comum com o valor escapado (inerte).- Atributos
data-reactive-*são reservados e removidos só no SSR (marcadores de hidratação). styleem objeto: o cliente serializa pelo CSSOM (formatação diferente), com a mesma política de valores.- Em
<script>/<style>, o cliente é mais estrito:unsafeHtmlque chega por componente, array ou função é descartado no cliente. SafeHtmleSafeUrlguardados em estado reativo perdem a marca ao serializar para hidratação e passam a falhar fechado: o HTML vira texto e a URL é sanitizada. Reembrulhe comunsafeHtml/unsafeUrlno cliente se precisar.
Avisos de desenvolvimento
Section titled “Avisos de desenvolvimento”Cada bloqueio emite um aviso (uma vez por tipo) com a correção sugerida, sempre em inglês. O pacote tem dois builds: Vite em dev e webpack em mode: "development" escolhem sozinhos o build com avisos; o build de produção não os traz, mas mantém console.error para erros reais. Para forçar os avisos fora de um bundler, rode com --conditions=development (node --conditions=development app.mjs).
Checklist rápido
Section titled “Checklist rápido”- Interpole strings direto: o Slash escapa.
- Marcação confiável:
unsafeHtml(...), e só depois de sanitizar. - Script de estado: sempre
<script id="__SLASH_STATE__" type="application/json">. - Casca da página: template literal comum,
renderToString(...).htmleserializeStateForScript(state). - Links externos:
Linkcomexternal. - Entrada de usuário em URL:
sanitizeUrl, e restrinja a origem você mesmo. - Handlers: sempre funções (
onClick=${fn}). - Autorização: no servidor, sempre.