Ir para o conteúdo
Trilha
Capítulos

Referência

Segurança

Configuração completa de cabeçalhos, proxies, limite de taxa, cookies assinados e eventos.

Config.Security#

CampoPadrãoCabeçalho
CSPpolítica com nonce (abaixo)Content-Security-Policy
CSPExtra map[string][]string—acrescenta origens a diretivas da política padrão
CSPRemove map[string][]string—tira tokens de diretivas da política padrão
HSTSmax-age=31536000; includeSubDomains (só em HTTPS)Strict-Transport-Security
PermissionsPolicycamera=(), microphone=(), geolocation=(), payment=(), usb=()Permissions-Policy
COOPsame-originCross-Origin-Opener-Policy
FrameOptionsDENYX-Frame-Options
Referrerstrict-origin-when-cross-originReferrer-Policy

trilha.Off em qualquer campo remove o cabeçalho. X-Content-Type-Options: nosniff é sempre enviado. Política padrão:

default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';
base-uri 'self'; form-action 'self'

c.Nonce() devolve o nonce da requisição; trilha.NonceAttr(c) o coloca em um h.Script. trilha.NonceFrom(r) dá o mesmo valor para quem só tem o *http.Request — html/template, templ, um handler seu —, então a casca de um app em migração não precisa de um middleware próprio para alcançá-lo. Ajuste em Setup por a.Security().

CSPExtra acrescenta a uma diretiva e CSPRemove tira, então um app que não usa estilo inline mantém o resto do padrão — o nonce, o frame-ancestors e o 'self' que o Inline relaxa para a rota emoldurada — em vez de reescrever a política inteira para tirar um token:

a.Security().CSPRemove = map[string][]string{"style-src": {"'unsafe-inline'"}, "img-src": {"data:"}}

Uma diretiva que fica sem nada vira 'none'. Os dois são ignorados quando CSP está definido: uma política escrita à mão é a política inteira.

Quando a resposta é do hospedeiro#

Um app montado dentro de um servidor que já responde pelas próprias respostas tem dois cabeçalhos a mais, não um: o hospedeiro escreveu a política, e o app escreve de novo.

CampoEfeito
Delegated boolnão escreve cabeçalho nenhum — nem os seis que têm Off, nem o nosniff que não tem
Nonce func(*http.Request) stringo nonce vem do hospedeiro, uma chamada por requisição que pedir
a.Security().Delegated = true
a.Security().Nonce = func(r *http.Request) string { return host.NonceOf(r) }

Delegated é uma decisão, não um padrão: o valor zero escreve os cabeçalhos, então um Security{...} escrito à mão nunca os desliga por omissão. O boot registra a delegação uma vez no log, porque resposta sem cabeçalho precisa aparecer em algum lugar.

Sem Nonce, c.Nonce() inventa um valor por requisição — o que está certo para um app que publica a própria CSP e errado para um que não publica: a política do hospedeiro nunca ouviu falar daquele nonce, e o navegador recusa o script. Com Nonce devolvendo string vazia, trilha.NonceAttr(c) não renderiza atributo nenhum em vez de nonce="".

Proxies confiáveis#

Config.TrustedProxies []string (CIDR ou IP) ou TRILHA_TRUSTED_PROXIES=a,b. Efeitos quando o peer é confiável: c.ClientIP() lê X-Forwarded-For (o IP mais à direita que não seja proxy), X-Forwarded-Proto: https liga HSTS e marca cookies como Secure.

Hosts permitidos#

Config.AllowedHosts []string ou TRILHA_ALLOWED_HOSTS=a,b. A requisição cujo Host não está na lista é respondida com 400 antes do roteador e do CORS, e emite um evento host. Lista vazia = sem conferência.

PadrãoLiberaNão libera
exemplo.comexemplo.com, exemplo.com:8443, EXEMPLO.com.sub.exemplo.com
*.exemplo.comapp.exemplo.comexemplo.com, a.b.exemplo.com

Em Dev, localhost, 127.0.0.1 e ::1 passam sempre. O que se compara é o host que o app recebe — atrás de um proxy que reescreve o Host, liste o que o proxy manda.

A sonda de saúde (/_trilha/health, /live, /ready) responde antes dessa conferência, com qualquer Host: uma sonda de liveness/readiness é endereçada por IP (o do contêiner, o do pod), nunca por um nome da lista, e nunca reflete o Host de volta — sem link, sem cookie, sem redirect, sem cache por Host. Aponte a sonda do orquestrador para um destes três caminhos; uma rota de saúde escrita pelo próprio app é uma rota como outra qualquer e continua atrás desta conferência.

Limite de taxa#

Config.RateLimit{RPS float64, Burst int} aplica um token bucket por ClientIP antes dos middlewares. trilha.Limit(rps, burst) MiddlewareFunc cria um limitador independente para uma subárvore. Resposta: 429 com Retry-After (segundos) e evento rate. trilha.ErrRateLimited pode ser devolvido por um handler para o mesmo efeito.

O balde sozinho, para quando a chave não é um endereço. O orçamento interessante raramente é o IP: um limite por chave de API, por tenant, por caixa de e-mail — uma recuperação de senha por endereço, e não uma por escritório atrás de um endereço só — ou por tarefa de fundo. O trilha.NewLimiter é o mesmo token bucket do middleware, como um valor que você guarda e indexa:

var perKey = trilha.NewLimiter(trilha.RateLimit{RPS: 10, Burst: 30})

if ok, after := perKey.Allow(chave.ID); !ok {
	c.Header().Set("Retry-After", strconv.Itoa(int(after.Seconds()+0.5)))
	return trilha.ErrRateLimited
}

O Limiter.Allow(chave) responde se aquela chave pode passar agora e, quando não pode, em quanto tempo poderá — que é o número de que o Retry-After precisa e a razão de ele voltar em vez de ir para o log. Os baldes nascem no primeiro uso e são varridos quando se enchem, então uma chave que ninguém usa de novo não fica na memória. O auth.APIKeys limita por chave com este mesmo tipo, e é por isso que ele é exportado: uma aplicação que tem uma string para indexar não deveria estar escrevendo um segundo token bucket, e o que ela escreveria é o que vaza um mapa.

O código que diz não antes da consulta. Uma tela em que alguém digita um número — um protocolo, o código de um comprovante — é enumerada no minuto em que existe, e a recusa mais barata é a que não chega ao banco. O trilha.CheckDigit(base) devolve os dois dígitos verificadores ISO 7064 MOD 97-10 de um código (o esquema do IBAN: A=10 … Z=35, espaços e hífens ignorados), e o trilha.HasCheckDigit(codigo) é a conferência na entrada:

codigo := base + trilha.CheckDigit(base) // emitindo: "20260001" + "04"

if !trilha.HasCheckDigit(c.Form("codigo")) {
	return naoEncontrado(c) // a mesma resposta do "esse código não existe", e sem consulta
}

Dois dígitos pegam todo erro de um caractere e toda troca de dois vizinhos, então quem digitou errado é avisado na hora; e só um código em noventa e sete chega a valer uma busca, então o limite acima é consultado noventa e sete vezes menos. O que eles não são é segredo: os dígitos vêm impressos no comprovante, e um código que não passa neles é recusado exatamente com as palavras que um código inexistente recebe. O trilha add public-lookup é a tela inteira montada assim.

Cookies assinados#

SímboloDescrição
c.SetSigned(nome, valor, ttl) errorgrava cookie `valorexpirahmac com HttpOnly, SameSite=Lax, Secure em HTTPS; ErrNoSecret` sem chave
c.Signed(nome) (string, bool)lê e verifica assinatura e prazo
c.ClearCookie(nome)expira um cookie
trilha.NewSigner(chaves...), Sign, Verifyo assinador (HMAC-SHA256) para uso direto
Config.Secret, Config.PreviousSecretTRILHA_SECRET, TRILHA_SECRET_PREVIOUS, ou o arquivo que cada _FILE aponta (base64 ou texto, pelo menos trilha.MinSecretLen bytes — 32; menor que isso em prod é o hint trilha.ErrSecretShort)

Sem segredo: em dev uma chave efêmera é gerada (o trilha dev mantém uma por sessão); em prod o app avisa no log e SetSigned devolve ErrNoSecret.

Timeouts#

Config.Timeouts{ReadHeader 10s, Read 30s, Write 60s, Idle 120s, MaxHeaderBytes 64 KiB}. Para respostas longas (SSE, download), chame c.NoWriteDeadline() antes de escrever.

Eventos de segurança#

type SecurityEvent struct {
	Kind      string // csrf | auth | body | host | rate | panic
	Status    int
	Method    string
	Path      string
	IP        string
	RequestID string
}

Registrados com slog.Warn("security", ...) e entregues a Config.OnSecurityEvent, uma vez por requisição.

trilha audit#

Verifica: TRILHA_SECRET, TRILHA_TRUSTED_PROXIES, trilha_gen.go atualizado, versão do Go, .gitignore, go vet e govulncheck (--no-vuln para pular). Código de saída 1 com item crítico.

TRILHA_SECRET ausente só é crítico quando o código assina alguma coisa — SetSigned, Signed, um Signer próprio, Config.Secret ou o pacote auth. Num app cuja sessão não é a do Trilha, vira aviso: um segredo que não assina nada entra no .env, no deploy e na rotação, e no dia em que alguém o girar não acontece nada — que é a pior coisa que um segredo pode ensinar. Definido e curto demais segue crítico nos dois casos: quem definiu quis usar.

Ele também avisa da escrita que nenhum Kind alcança. Um route.go é API, e API não confere o token de CSRF, então uma rota de POST num app que também serve páginas quase sempre quer var Kind = trilha.KindPage num kind.go acima dela — uma linha para o ramo inteiro, veja Convenções de arquivo. Ligar o Config.CSRFForAPI responde a mesma pergunta pelo outro lado e também cala o aviso.

CSRF e compressão#

As respostas saem comprimidas, e um segredo que se repete numa página comprimida ao lado de um texto que o atacante consegue refletir nela é o que o BREACH lê de volta, byte a byte, pelo tamanho da resposta. Por isso o token que c.CSRFToken(), trilha.CSRFInput e c.Island escrevem é mascarado: um pad de uso único seguido do segredo do cookie em XOR com ele — bytes diferentes em cada resposta da mesma sessão. A checagem desmascara o campo e compara com o cookie; o valor cru do cookie continua aceito, então um cliente de teste ou um script que lê o cookie segue funcionando.

O que o framework não conhece é o segredo do próprio app numa página: uma chave de API mostrada uma vez, um código de recuperação. Mande-o numa resposta só dele, ou com Cache-Control: no-store e nada refletido ao lado.

Segredos em repouso#

O framework tinha segredo, assinador e cookies assinados. O que não tinha era cifre isto para guardar — e o que se escreve no lugar é um token em claro, depois um AES copiado da internet com IV fixo, depois a chave inteira voltando num GET e aparecendo no DevTools.

sealed, err := trilha.Seal([]byte(chave)) // AES-256-GCM, nonce aleatório
plain, err := trilha.Open(sealed)         // segredo atual, depois o PreviousSecret
SímboloPapel
Seal([]byte) ([]byte, error)cifra para guardar; ErrNoSecret sem segredo, nunca um valor em claro
Open([]byte) ([]byte, error)decifra; ErrSealed para o que não abre, sem dizer por quê
Secretuma string que não vaza por descuido
s.Reveal()o valor, lido de propósito
s.Sealed() / SecretFrom(b)a forma cifrada e a volta
s.Value() / s.Scan()driver.Valuer e sql.Scanner: a coluna guarda o selo
s.String(), s.LogValue(), s.MarshalJSON()a máscara, sempre — as três saídas por onde um valor vaza são o %v, um campo de slog e uma struct respondida como JSON, e este tipo existe para fechar as três
s.UnmarshalJSON(b)o valor como vem, porque um cliente que manda um segredo novo manda o segredo; uma máscara que volta é "não mudou", e nunca é guardada como se fosse o valor
ui.SecretField(c, nome, rótulo, atual)o campo de senha para um formulário escrito à mão

O Value() é o método do driver e o Reveal() é o leitor. A issue que pediu este tipo tinha o contrário; não dá: um Secret é uma string por baixo, e o database/sql converte sozinho qualquer valor de kind string — então, a menos que Value() seja o driver.Valuer, passar um Secret para uma query grava o texto em claro, em silêncio.

Máscara voltando é "não mudou". Um formulário não tem como mandar "não mexi nisto", então uma tela que preenche o campo ou devolve o segredo para o navegador ou o apaga no primeiro save que ninguém redigitou. O Bind trata vazio ou máscara como não mudou, o trilha.Settings desenha um Secret como campo de senha sempre vazio, e a linha de ajuda diz isso.

Rotação. O Open tenta o segredo atual e depois o Config.PreviousSecret; o Seal sempre usa o atual. Então: ponha a chave antiga em TRILHA_PREVIOUS_SECRET, publique o novo TRILHA_SECRET, deixe tudo ser re-cifrado, e só então tire a antiga. O trilha audit avisa quando há trilha.Secret no projeto e nenhum segredo anterior — rodar a chave nessa hora é o momento em que todo token guardado para de abrir.