Ir para o conteúdo

Content-Security-Policy (tempestCsp)

Uma Content-Security-Policy diz ao navegador de onde a página pode carregar script, estilo, imagem — e para onde ela pode se conectar. É uma das defesas mais baratas contra XSS, e todo PWA sério tem uma.

O problema é o modo como ela falha. Quando o app chama uma origem que não está no connect-src, o navegador bloqueia a requisição antes de abrir a conexão:

  • a aba Network não mostra request nenhum em vermelho;
  • o servidor não registra nada — o log fica vazio;
  • o app recebe TypeError: Failed to fetch, igual a offline, DNS errado ou CORS;
  • curl funciona, porque curl não aplica CSP;
  • e, como a CSP escrita à mão costuma ser um plugin apply: "build", dev nunca quebra. Só produção.

O único lugar que diz o que aconteceu é o console do navegador, com o app aberto:

Connecting to 'https://sso.example.com/realms/app/protocol/openid-connect/token'
violates the following Content Security Policy directive:
"connect-src 'self' https://api.example.com". The action has been blocked.

O tempestCsp fecha as duas pontas: deriva o connect-src das mesmas variáveis de ambiente que o runtime lê, e aplica a política também no vite dev — então uma origem esquecida quebra na sua máquina, no primeiro reload, e não no celular do usuário semanas depois. 🚀

O exemplo mínimo

// vite.config.ts
import { createViteConfig, tempestCsp } from "tempest-react-sdk/vite";

export default createViteConfig({
    plugins: [tempestCsp()],
});

Com este .env:

VITE_API_URL=https://api.example.com/v1
VITE_OIDC_ISSUER=https://sso.example.com/realms/app

o build imprime o que entrou na política, e de onde veio:

tempest-csp: connect-src 'self' blob: data: https://api.example.com https://sso.example.com wss://api.example.com wss://sso.example.com
  https://api.example.com  ← env VITE_API_URL
  https://sso.example.com  ← env VITE_OIDC_ISSUER
  wss://api.example.com  ← sockets (https://api.example.com)
  wss://sso.example.com  ← sockets (https://sso.example.com)

E o dist/index.html sai com a <meta http-equiv="Content-Security-Policy"> logo depois do <meta charset>, antes de qualquer script.

O scaffold já vem assim

create-tempest-app (com ou sem --pwa) já liga tempestCsp() no vite.config.ts. Você só precisa manter as origens no .env.

De onde vêm as origens

Do .env (o padrão)

Por padrão (env: true), toda variável que o Vite expõe em import.meta.env — as do envPrefix, VITE_ por default — cujo valor é uma URL absoluta http(s)/ws(s) entra no connect-src. Chave VAPID, flag e texto são ignorados, porque não são URL.

Essa é a ponte estrutural: adicionar VITE_OIDC_ISSUER para uma chamada nova já coloca a origem na política. Não existe segunda lista para esquecer.

Prefere ser explícito? Passe os nomes. Um nome que não resolve vira aviso no build, em vez de sumir calado:

// vite.config.ts
import { createViteConfig, tempestCsp } from "tempest-react-sdk/vite";

export default createViteConfig({
    plugins: [tempestCsp({ env: ["VITE_API_URL", "VITE_OIDC_ISSUER"] })],
});

De connect (URL literal)

Para uma origem que não vem do .env:

// vite.config.ts
import { createViteConfig, tempestCsp } from "tempest-react-sdk/vite";

export default createViteConfig({
    plugins: [
        tempestCsp({
            connect: ["https://uploads.example.com/files/", "wss://realtime.example.com/socket"],
        }),
    ],
});

Três regras valem para connect e para o .env:

  • Origem, não URL. https://sso.example.com/realms/app entra como https://sso.example.com. Uma fonte com caminho casa só aquele caminho, e a chamada para /realms/app/protocol/openid-connect/token seria bloqueada igual.
  • Valor vazio some. undefined, null, false e "" não viram buraco na diretiva: são pulados com um aviso no build.
  • Valor inválido derruba o build. api.example.com (sem esquema) lança erro no vite build — falha na sua máquina, não em produção. Caminho relativo (/api) é mesma origem e já está coberto por 'self'.

Fallback literal no código não aparece

import.meta.env.VITE_API_URL ?? "http://127.0.0.1:8000" chama uma origem que não está no .env. Se a variável não estiver definida, o plugin não tem como saber do fallback — e o vite dev vai mostrar o bloqueio no console. Crie o .env (cp .env.example .env) ou liste a origem em connect.

Origens que o próprio SDK chama

O useViaCEP chama https://viacep.com.br. No build, essa origem entra só se o hook estiver no bundle; no dev, sempre (o dev server ainda não conhece o grafo de módulos quando serve o index.html).

WebSocket: o gêmeo wss: de cada origem

Medido no Chromium (Playwright, 23/09/2026) com uma página de <meta> CSP que tenta cada conexão e escuta securitypolicyviolation:

Política Chamada Resultado
connect-src 'self' https://example.com new WebSocket("wss://example.com") bloqueado
connect-src 'self' new WebSocket("ws://<mesma origem>") permitido

Ou seja: https://api não cobre wss://api. App que deriva a URL do socket da URL da API quebraria. Por isso, com sockets: true (o padrão), cada origem http(s) ganha o gêmeo ws(s). Desligue com sockets: false.

O HMR do vite dev usa a mesma origem da página e fica coberto por 'self'. Se você mudou server.hmr para outra porta, adicione a origem dele em directives: { "connect-src": [...] }.

Os defaults, e o motivo de cada um

Diretiva Valor Por quê
default-src 'self' base de tudo
script-src 'self' 'wasm-unsafe-eval' ONNX Runtime (/vision, /tabular) compila WebAssembly; sem isso, WebAssembly.compile lança CompileError
style-src 'self' 'unsafe-inline' o applyTheme injeta um <style>
img-src 'self' data: blob: preview de imagem por object URL
font-src 'self' data: fonte embutida
media-src 'self' blob: áudio/vídeo gravado
connect-src 'self' blob: data: + origens decodeImage aceita URL blob:/data:
worker-src 'self' blob: service worker e worker por blob:
manifest-src 'self' web manifest do PWA
object-src 'none' nenhum plugin
base-uri, form-action 'self' impedem desvio de <base> e de formulário

A prop style do React não precisa de 'unsafe-inline'

Na mesma medição, element.style.color = "…" (que é o que o React faz com a prop style) passou com style-src 'self': CSSOM não é governado por CSP. O que é bloqueado é elemento <style> e atributo style="". Se o app nunca chama applyTheme, passe inlineStyles: false e o style-src fica 'self' — os <style> inline do index.html passam a entrar por hash. No vite dev o 'unsafe-inline' de estilo continua, porque é assim que o Vite injeta CSS.

Ajuste qualquer diretiva com directives: um array soma fontes, false remove a diretiva.

// vite.config.ts
import { createViteConfig, tempestCsp } from "tempest-react-sdk/vite";

export default createViteConfig({
    plugins: [
        tempestCsp({
            directives: {
                "img-src": ["https://tiles.example.com"],
                "frame-src": ["https://www.youtube.com"],
            },
        }),
    ],
});

Script inline entra por hash

Um <script> inline no index.html — o anti-flash do themeInitScript(), por exemplo — é liberado pelo SHA-256 do conteúdo, nunca por 'unsafe-inline'. O plugin roda como transform post, então enxerga o HTML final. No dev, o preâmbulo do React Refresh entra do mesmo jeito.

Note

Um plugin que reescreva script inline depois do tempestCsp invalida o hash. Deixe-o por último no plugins.

O app já tinha uma CSP

Se o index.html já declara uma <meta http-equiv="Content-Security-Policy">, o plugin mescla, não sobrescreve:

o resultado nunca é mais permissivo do que a política que você escreveu. Cada diretiva cai numa destas linhas:

A diretiva no seu <meta> O que o plugin emite Exemplo
declarada exatamente como está; fontes em directives para ela não entram e viram aviso no build; false em directives a remove (remover é escolha explícita sua) script-src 'self' continua script-src 'self', sem hash nem 'wasm-unsafe-eval'
connect-src declarado o seu valor + as origens resolvidas; com uma lista que não casa nada — 'none' em qualquer caixa ('NONE' também) ou vazia —, declarada ou herdada do default-src, nada entra e o build avisa cada origem que ficou de fora connect-src 'self' → connect-src 'self' https://api.example.com
não declarada, mas herdada pela cadeia de fallback do CSP3 (img-src → default-src; worker-src → child-src → script-src → default-src) nada — ela continua caindo no valor herdado com default-src 'none', nenhum img-src 'self' data: blob: aparece
connect-src herdado o valor herdado + as origens resolvidas; nada, se não há origem default-src 'self' → connect-src 'self' https://api.example.com
herdada, com fontes em directives o valor herdado + as fontes de directives default-src 'self' + { "img-src": ["https://tiles.example.com"] } → img-src 'self' https://tiles.example.com
herdada, com default 'none' (object-src) o default, que só restringe object-src 'none'
sem fallback (base-uri, form-action) o default, que só restringe base-uri 'self'
nada no seu <meta> a governa o default sem default-src, o img-src vem como 'self' data: blob:

Herdar do default-src desliga o que o default liberava

Com default-src 'self' e sem script-src, o 'wasm-unsafe-eval' do /vision e o hash do script inline não entram: afrouxariam a sua política. Um <script> inline que a sua política bloqueia — julgado pelo script-src-elem quando você o declara, que é quem governa <script> — vira aviso no build, com o hash pronto para colar no script-src. Se o app precisa do WebAssembly, declare a diretiva no <meta> ou passe as fontes em directives — por exemplo directives: { "script-src": ["'wasm-unsafe-eval'"] }. O <style> que o applyTheme injeta em runtime o build não vê: com default-src sem 'unsafe-inline', declare um style-src que o libere.

Duas <meta> de CSP na mesma página derrubam o build: o navegador aplica a interseção das duas, e juntá-las numa só afrouxaria a política.

Header HTTP: frame-ancestors e companhia

frame-ancestors, report-uri e sandbox são ignorados quando a política chega por <meta>. Declare-os em directives e eles vão só para header, que você recebe em onPolicy para escrever no servidor (nginx, _headers, …):

// vite.config.ts
import { writeFileSync } from "node:fs";
import { createViteConfig, tempestCsp } from "tempest-react-sdk/vite";

export default createViteConfig({
    plugins: [
        tempestCsp({
            directives: { "frame-ancestors": ["'none'"] },
            onPolicy: (policy) => {
                if (policy.command === "build") writeFileSync("dist/csp-header.txt", policy.header);
            },
        }),
    ],
});

Referência das opções

Opção Tipo Padrão O que faz
connect (string \| null \| undefined \| false)[] [] URLs chamadas em runtime; entra só a origem
env boolean \| string[] true lê as origens do import.meta.env
sockets boolean true adiciona o gêmeo ws(s) de cada origem http(s)
wasm boolean true 'wasm-unsafe-eval' em script-src
inlineStyles boolean true 'unsafe-inline' em style-src (sempre no dev)
directives Record<string, string[] \| false> {} soma fontes ou remove diretivas
dev boolean true aplica a política também no vite dev
log boolean true imprime o connect-src e a origem de cada entrada
onPolicy (policy) => void — recebe meta, header, directives, connect e command

Recap

  • Origem que o app chama em runtime precisa estar no connect-src; quando falta, o sintoma é TypeError: Failed to fetch sem request na Network.
  • tempestCsp() lê as origens do .env, reduz a origem, pula vazio e derruba o build em valor inválido.
  • A política vale no dev também — o erro aparece na sua máquina.
  • https://api não cobre wss://api; o gêmeo entra sozinho.
  • CSP que o app já tinha é mesclada, nunca afrouxada.
  • ✅ O build imprime cada origem e de onde ela veio.