Files
volumen/internal/web/web.go
T

232 lines
6.7 KiB
Go
Raw Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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()
})
}
}