Initial commit
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
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
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user