Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
232 lines
6.7 KiB
Go
232 lines
6.7 KiB
Go
// 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()
|
|
})
|
|
}
|
|
}
|