Add the HTTP hardening the policy requires before 1.0 (closes #108)
check / check (push) Waiting to run

Every answer carries the six security headers REPO_POLICIES.md lists.
The Content-Security-Policy allows inline content only with a nonce made
for that answer; the pages put it on their style and script blocks and
drop their four style attributes.

The request log and /ip/ take a forwarded address only from a proxy
listed in TRUSTED_PROXIES, which stops the start when set but invalid:
the last X-Forwarded-For entry that is not a trusted proxy. X-Real-IP
and True-Client-IP are no longer read.

The JSON routes and the health check send fixed messages in place of
database, parser and resolver errors, and log the detail.

Model: opus-5-5
This commit is contained in:
2026-10-19 23:24:27 +00:00
parent 76c2e523b5
commit c95ac471f1
14 changed files with 641 additions and 60 deletions
+48 -2
View File
@@ -4,10 +4,12 @@ package config
import (
"errors"
"fmt"
"net/netip"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
@@ -32,6 +34,8 @@ var (
errRelativeXDGDataHome = errors.New("XDG_DATA_HOME must be an absolute path")
errUnsupportedOS = errors.New("unsupported operating system")
errInvalidPort = errors.New("PORT must be a whole number")
errInvalidProxy = errors.New(
"TRUSTED_PROXIES must list IP addresses and CIDR ranges, comma-separated")
)
// Config holds configuration for the entire application
@@ -42,6 +46,10 @@ type Config struct {
// Port is the TCP port the HTTP server listens on
Port int
// TrustedProxies are the reverse proxies whose X-Forwarded-For header
// the HTTP server believes
TrustedProxies []netip.Prefix
// MaxRuntime is the maximum runtime (0 = run forever)
MaxRuntime time.Duration
@@ -68,9 +76,15 @@ func New() (*Config, error) {
return nil, err
}
trustedProxies, err := getTrustedProxies()
if err != nil {
return nil, err
}
return &Config{
StateDir: stateDir,
Port: port,
StateDir: stateDir,
Port: port,
TrustedProxies: trustedProxies,
// Run forever by default
MaxRuntime: 0,
// Enable batching by default
@@ -145,6 +159,38 @@ func getPort() (int, error) {
return int(port), nil
}
// getTrustedProxies returns the addresses and CIDR ranges listed in
// TRUSTED_PROXIES, such as "10.0.0.0/8,192.0.2.1", or none when it is not set.
// An address is returned as a range holding only that address.
func getTrustedProxies() ([]netip.Prefix, error) {
value := os.Getenv("TRUSTED_PROXIES")
if value == "" {
return nil, nil
}
var proxies []netip.Prefix
for entry := range strings.SplitSeq(value, ",") {
entry = strings.TrimSpace(entry)
addr, err := netip.ParseAddr(entry)
if err == nil {
proxies = append(proxies, netip.PrefixFrom(addr, addr.BitLen()))
continue
}
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return nil, fmt.Errorf("%w, got %q", errInvalidProxy, entry)
}
proxies = append(proxies, prefix)
}
return proxies, nil
}
// EnsureDirectories creates all necessary directories if they don't exist
func (c *Config) EnsureDirectories() error {
// Ensure state directory exists
+51
View File
@@ -1,7 +1,9 @@
package config
import (
"net/netip"
"runtime"
"slices"
"testing"
)
@@ -44,6 +46,55 @@ func TestNewRefusesInvalidPort(t *testing.T) {
}
}
func TestNewReadsTrustedProxies(t *testing.T) {
t.Setenv("PORT", "")
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("TRUSTED_PROXIES", "")
cfg, err := New()
if err != nil {
t.Fatalf("New() without TRUSTED_PROXIES: %v", err)
}
if len(cfg.TrustedProxies) != 0 {
t.Errorf("New() without TRUSTED_PROXIES: TrustedProxies = %v, want none",
cfg.TrustedProxies)
}
t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8, 192.0.2.1,2001:db8::/32")
cfg, err = New()
if err != nil {
t.Fatalf("New() with TRUSTED_PROXIES: %v", err)
}
want := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("192.0.2.1/32"),
netip.MustParsePrefix("2001:db8::/32"),
}
if !slices.Equal(cfg.TrustedProxies, want) {
t.Errorf("TrustedProxies = %v, want %v", cfg.TrustedProxies, want)
}
}
func TestNewRefusesInvalidTrustedProxies(t *testing.T) {
invalid := []string{"proxy.example", "10.0.0.0/33", "192.0.2.1:80", "192.0.2.1,"}
for _, value := range invalid {
t.Run(value, func(t *testing.T) {
t.Setenv("PORT", "")
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("TRUSTED_PROXIES", value)
_, err := New()
if err == nil {
t.Errorf("New() with TRUSTED_PROXIES=%q returned no error", value)
}
})
}
}
func TestNewRefusesRelativeXDGDataHome(t *testing.T) {
if runtime.GOOS == "darwin" {
t.Skip("macOS does not read XDG_DATA_HOME")