// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package web provides the HTTP middleware chain shared by the public // API and the admin UI: gzip, security headers with per-request CSP // nonces, and client-IP resolution. package web import ( "bytes" "compress/gzip" "context" "crypto/rand" "net" "net/http" "net/netip" "strconv" "strings" ) type nonceKey struct{} // PermissionsPolicy is the Permissions-Policy header sent on every // response: every listed feature is denied. const PermissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()" var baseCSPDirectives = []string{ "default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:", "font-src 'self'", "connect-src 'self'", "form-action 'self'", "frame-ancestors 'none'", "base-uri 'self'", "object-src 'none'", } // ClientIP resolves the client address. // // X-Forwarded-For is honoured only when the deployment is behind a proxy, // and only for a peer the configuration trusts: with trusted prefixes set, // a request that did not arrive from one of them is answered with its own // address, so a client that can reach the listener directly cannot choose // the key it is rate-limited by. The last entry of the header is used, // because a proxy appends the address it accepted the connection from; // everything to its left is client-supplied. func ClientIP(r *http.Request, trusted []netip.Prefix) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } if len(trusted) == 0 { return host } peer, err := netip.ParseAddr(host) if err != nil || !inPrefixes(peer, trusted) { return host } forwarded := r.Header.Get("X-Forwarded-For") if forwarded == "" { return host } _, after, ok := strings.CutLast(forwarded, ",") if !ok { return strings.TrimSpace(forwarded) } return strings.TrimSpace(after) } func inPrefixes(addr netip.Addr, prefixes []netip.Prefix) bool { for _, prefix := range prefixes { if prefix.Contains(addr) { return true } } return false } // Nonce returns the CSP nonce generated for this request, if any. func Nonce(ctx context.Context) string { if nonce, ok := ctx.Value(nonceKey{}).(string); ok { return nonce } return "" } // SecurityHeaders sets the baseline security headers on every // response. Admin paths additionally get a per-request CSP nonce and // no-store caching. func SecurityHeaders(cookieSecure bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Headers are set before the handler runs: net/http // snapshots the header map at the first WriteHeader. // The packaged assets under /admin/assets/ are the // exception to the admin's no-store: they are static, // revalidated by their content ETag instead. ctx := r.Context() path := r.URL.Path isAdmin := strings.HasPrefix(path, "/admin") && !strings.HasPrefix(path, "/admin/assets/") if isAdmin { ctx = context.WithValue(ctx, nonceKey{}, newNonce()) } header := w.Header() setDefault(header, "X-Content-Type-Options", "nosniff") setDefault(header, "Referrer-Policy", "strict-origin-when-cross-origin") setDefault(header, "X-Frame-Options", "DENY") setDefault(header, "Permissions-Policy", PermissionsPolicy) csp := baseCSPDirectives if isAdmin { nonce := Nonce(ctx) directives := make([]string, 0, len(baseCSPDirectives)+2) for _, d := range baseCSPDirectives { if strings.HasPrefix(d, "script-src") || strings.HasPrefix(d, "style-src") { continue } directives = append(directives, d) } directives = append(directives, "script-src 'self' 'nonce-"+nonce+"'", "style-src 'self' 'nonce-"+nonce+"'", ) csp = directives setDefault(header, "Cache-Control", "no-store") } header.Set("Content-Security-Policy", strings.Join(csp, "; ")) if cookieSecure { setDefault(header, "Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r.WithContext(ctx)) }) } } func setDefault(header http.Header, key, value string) { if header.Get(key) == "" { header.Set(key, value) } } func newNonce() string { // 128 bits of randomness in a URL-safe alphabet; crypto/rand.Text // panics on a system failure rather than returning a weak nonce. return rand.Text() } // gzipResponse buffers the handler output and compresses it when the // client asked for gzip and the body is large enough. type gzipResponse struct { http.ResponseWriter buf bytes.Buffer status int wroteHeader bool } func (g *gzipResponse) WriteHeader(code int) { if !g.wroteHeader { g.status = code g.wroteHeader = true } } func (g *gzipResponse) Write(b []byte) (int, error) { g.wroteHeader = true return g.buf.Write(b) } // acceptsGzip reports whether the Accept-Encoding header names gzip with // a non-zero quality. It is a token list, not a substring test: // "gzip;q=0" is an explicit refusal, and answering it with a compressed // body would hand the client something it cannot decode. func acceptsGzip(header string) bool { for part := range strings.SplitSeq(header, ",") { token, params, _ := strings.Cut(part, ";") name := strings.TrimSpace(token) if name != "gzip" && name != "x-gzip" { continue } q := 1.0 if key, value, ok := strings.Cut(params, "="); ok && strings.TrimSpace(key) == "q" { if parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil { q = parsed } } if q > 0 { return true } } return false } // Gzip compresses response bodies of at least minSize bytes when the // client supports it. func Gzip(minSize int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !acceptsGzip(r.Header.Get("Accept-Encoding")) { next.ServeHTTP(w, r) return } g := &gzipResponse{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(g, r) body := g.buf.Bytes() header := w.Header() header.Del("Content-Length") // Compressed or not, the body depends on the request's // Accept-Encoding, so a shared cache must be told. header.Add("Vary", "Accept-Encoding") if g.status == http.StatusNotModified || len(body) < minSize { w.WriteHeader(g.status) if r.Method != http.MethodHead { _, _ = w.Write(body) } return } header.Set("Content-Encoding", "gzip") w.WriteHeader(g.status) if r.Method == http.MethodHead { return } zw := gzip.NewWriter(w) _, _ = zw.Write(body) _ = zw.Close() }) } }