Skip to content
Trilha
Chapters

Reference

Security

Complete configuration of headers, proxies, rate limiting, signed cookies and events.

Config.Security#

FieldDefaultHeader
CSPnonce policy (below)Content-Security-Policy
CSPExtra map[string][]stringadds origins to directives of the default policy
HSTSmax-age=31536000; includeSubDomains (HTTPS only)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 in any field removes the header. X-Content-Type-Options: nosniff is always sent. Default policy:

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() returns the request's nonce; trilha.NonceAttr(c) puts it on an h.Script. trilha.NonceFrom(r) answers the same value to a renderer that only has the *http.Requesthtml/template, templ, a handler of your own — so the shell of an app being migrated does not need a middleware of its own to reach it. Adjust in Setup through a.Security().

When the response belongs to a host#

An app mounted inside a server that already answers for its own responses has two headers too many, not one: the host wrote the policy, and the app writes it again.

FieldEffect
Delegated boolwrites none of the headers — not the six that have an Off, and not the nosniff that has none
Nonce func(*http.Request) stringthe nonce comes from the host, one call per request that asks for it
a.Security().Delegated = true
a.Security().Nonce = func(r *http.Request) string { return host.NonceOf(r) }

Delegated is a decision, not a default: the zero value writes the headers, so a hand-written Security{...} never turns them off by omission. Boot records the delegation once in the log, because a response with no headers should be visible somewhere.

Without Nonce, c.Nonce() invents a value per request, which is right for an app that publishes its own CSP and wrong for one that does not: the host's policy never heard of that nonce, and the browser refuses the script. With Nonce returning an empty string, trilha.NonceAttr(c) renders no attribute at all instead of nonce="".

Trusted proxies#

Config.TrustedProxies []string (CIDR or IP) or TRILHA_TRUSTED_PROXIES=a,b. Effects when the peer is trusted: c.ClientIP() reads X-Forwarded-For (the rightmost IP that is not a proxy), X-Forwarded-Proto: https turns on HSTS and marks cookies as Secure.

Allowed hosts#

Config.AllowedHosts []string or TRILHA_ALLOWED_HOSTS=a,b. A request whose Host is not in the list is answered with 400 before the router, the probes and CORS, and emits a host event. Empty list = no check.

PatternAllowsDoes not allow
example.comexample.com, example.com:8443, EXAMPLE.com.sub.example.com
*.example.comapp.example.comexample.com, a.b.example.com

In Dev, localhost, 127.0.0.1 and ::1 always pass. The value compared is the host the app receives — behind a proxy that rewrites Host, list what the proxy sends.

Rate limiting#

Config.RateLimit{RPS float64, Burst int} applies a token bucket per ClientIP before the middlewares. trilha.Limit(rps, burst) MiddlewareFunc creates an independent limiter for a subtree. Response: 429 with Retry-After (seconds) and a rate event. trilha.ErrRateLimited may be returned by a handler for the same effect.

Signed cookies#

SymbolDescription
c.SetSigned(name, value, ttl) errorwrites a `valueexpireshmac cookie with HttpOnly, SameSite=Lax, Secure over HTTPS; ErrNoSecret` without a key
c.Signed(name) (string, bool)reads and verifies signature and expiry
c.ClearCookie(name)expires a cookie
trilha.NewSigner(keys...), Sign, Verifythe signer (HMAC-SHA256) for direct use
Config.Secret, Config.PreviousSecretTRILHA_SECRET, TRILHA_SECRET_PREVIOUS (base64 or text, ≥ 32 bytes)

Without a secret: in dev an ephemeral key is generated (trilha dev keeps one per session); in prod the app warns in the log and SetSigned returns ErrNoSecret.

Timeouts#

Config.Timeouts{ReadHeader 10s, Read 30s, Write 60s, Idle 120s, MaxHeaderBytes 64 KiB}. For long responses (SSE, download), call c.NoWriteDeadline() before writing.

Security events#

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

Logged with slog.Warn("security", ...) and delivered to Config.OnSecurityEvent, once per request.

trilha audit#

Checks: TRILHA_SECRET, TRILHA_TRUSTED_PROXIES, up-to-date trilha_gen.go, Go version, .gitignore, go vet and govulncheck (--no-vuln to skip). Exit code 1 with a critical item.

A missing TRILHA_SECRET is critical only when the code signs something — SetSigned, Signed, a Signer of its own, Config.Secret, or the auth package. An app whose session is not Trilha's gets a warning instead: a secret that signs nothing still enters the .env, the deploy and the rotation, and the day somebody rotates it nothing happens, which is the worst thing a secret can teach. Set too short is critical either way — whoever set it meant to use it.

It also warns about a write that no Kind reaches. A route.go is an API, and an API does not check the CSRF token, so a POST route in an app that also serves pages usually wants var Kind = trilha.KindPage in a kind.go above it — one line for the whole branch, see File conventions. Setting Config.CSRFForAPI answers the same question the other way and silences the warning too.