From df9892fad33ed48b1bfa41ae81f417e7a6a1314b Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Thu, 26 Feb 2026 20:25:14 +0100 Subject: [PATCH 01/11] fix: prevent open redirect via Host header (CANONICAL_HOST) --- cmd/server/main.go | 3 ++- internal/app/middleware.go | 12 +++++++--- internal/app/middleware_test.go | 40 +++++++++++++++++++++++++++++++++ internal/config/config.go | 7 +++++- internal/config/config_test.go | 27 ++++++++++++++++++++++ 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 994275e..8a1cb2c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -44,7 +44,8 @@ func main() { handler := app.NewHandler(repo) secCfg := app.SecurityHeadersConfig{ - RequireHTTPS: cfg.RequireHTTPS, + RequireHTTPS: cfg.RequireHTTPS, + CanonicalHost: cfg.CanonicalHost, } router := app.NewRouter(handler, rdb, secCfg) diff --git a/internal/app/middleware.go b/internal/app/middleware.go index 41ae2c8..8503a06 100644 --- a/internal/app/middleware.go +++ b/internal/app/middleware.go @@ -41,7 +41,8 @@ func ContentLengthValidator(maxSize int64) func(http.Handler) http.Handler { // SecurityHeadersConfig holds configuration for security headers middleware. type SecurityHeadersConfig struct { - RequireHTTPS bool + RequireHTTPS bool + CanonicalHost string // used for HTTPS redirects; falls back to r.Host if empty } // SecurityHeaders adds security-related HTTP headers to responses. @@ -54,8 +55,13 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler // Check if request is over HTTPS (direct TLS or via proxy) isHTTPS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" if !isHTTPS { - // Redirect HTTP to HTTPS - target := "https://" + r.Host + r.URL.RequestURI() + // Use configured canonical host to prevent open redirect via + // attacker-controlled Host header. + host := cfg.CanonicalHost + if host == "" { + host = r.Host + } + target := "https://" + host + r.URL.RequestURI() http.Redirect(w, r, target, http.StatusMovedPermanently) return } diff --git a/internal/app/middleware_test.go b/internal/app/middleware_test.go index c62311a..1feabb4 100644 --- a/internal/app/middleware_test.go +++ b/internal/app/middleware_test.go @@ -139,6 +139,46 @@ func TestSecurityHeaders_HTTPSEnforcement(t *testing.T) { t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code) } }) + + t.Run("redirect uses CanonicalHost not attacker-controlled Host header", func(t *testing.T) { + wrapped := SecurityHeaders(SecurityHeadersConfig{ + RequireHTTPS: true, + CanonicalHost: "secretapi.example.com", + })(handler) + + req := httptest.NewRequest(http.MethodGet, "/secret-path", nil) + req.Host = "evil.com" + rr := httptest.NewRecorder() + + wrapped.ServeHTTP(rr, req) + + if rr.Code != http.StatusMovedPermanently { + t.Errorf("expected redirect status %d, got %d", + http.StatusMovedPermanently, rr.Code) + } + location := rr.Header().Get("Location") + if location != "https://secretapi.example.com/secret-path" { + t.Errorf("expected redirect to canonical host, got %q", location) + } + if strings.Contains(location, "evil.com") { + t.Errorf("redirect must not use attacker-controlled Host header, got %q", location) + } + }) + + t.Run("redirect falls back to Host header when CanonicalHost is empty", func(t *testing.T) { + wrapped := SecurityHeaders(SecurityHeadersConfig{RequireHTTPS: true})(handler) + + req := httptest.NewRequest(http.MethodGet, "/path", nil) + req.Host = "myapp.example.com" + rr := httptest.NewRecorder() + + wrapped.ServeHTTP(rr, req) + + location := rr.Header().Get("Location") + if location != "https://myapp.example.com/path" { + t.Errorf("expected fallback redirect to r.Host, got %q", location) + } + }) } func TestRateLimiter(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index dd732c0..013c905 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,7 +31,8 @@ type Config struct { ShutdownTimeout time.Duration // Security settings - RequireHTTPS bool // enforce HTTPS with HSTS header (disable with NO_HTTPS=1) + RequireHTTPS bool // enforce HTTPS with HSTS header (disable with NO_HTTPS=1) + CanonicalHost string // canonical hostname for HTTPS redirects (CANONICAL_HOST) } // DefaultConfig returns a Config with sensible defaults. @@ -106,6 +107,10 @@ func Load() (Config, error) { cfg.RequireHTTPS = false } + if canonicalHost := os.Getenv("CANONICAL_HOST"); canonicalHost != "" { + cfg.CanonicalHost = canonicalHost + } + return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d995ee5..4c434cc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -211,3 +211,30 @@ func TestLoad_NoHTTPSIgnoresOtherValues(t *testing.T) { }) } } + +func TestLoad_CanonicalHostDefault(t *testing.T) { + os.Unsetenv("CANONICAL_HOST") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.CanonicalHost != "" { + t.Errorf("expected empty CanonicalHost by default, got %q", cfg.CanonicalHost) + } +} + +func TestLoad_CanonicalHost(t *testing.T) { + os.Setenv("CANONICAL_HOST", "secretapi.example.com") + defer os.Unsetenv("CANONICAL_HOST") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.CanonicalHost != "secretapi.example.com" { + t.Errorf("expected CanonicalHost %q, got %q", "secretapi.example.com", cfg.CanonicalHost) + } +} From 8c0c956b79bf0d6cfd9519cbd22c3743be413981 Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Thu, 26 Feb 2026 21:14:19 +0100 Subject: [PATCH 02/11] fix: gate proxy header trust on TRUSTED_PROXY_CIDR --- cmd/server/main.go | 5 ++- internal/app/middleware.go | 69 +++++++++++++++++++++--------- internal/app/middleware_test.go | 76 +++++++++++++++++++++++++++++++++ internal/app/router.go | 4 +- internal/app/router_test.go | 12 +++--- internal/config/config.go | 13 +++++- internal/config/config_test.go | 37 ++++++++++++++++ 7 files changed, 186 insertions(+), 30 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 8a1cb2c..a921934 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -48,7 +48,10 @@ func main() { CanonicalHost: cfg.CanonicalHost, } - router := app.NewRouter(handler, rdb, secCfg) + rlCfg := app.DefaultRateLimitConfig() + rlCfg.TrustedProxyCIDR = cfg.TrustedProxyCIDR + + router := app.NewRouter(handler, rdb, secCfg, rlCfg) srv := &http.Server{ Addr: cfg.ListenAddr(), diff --git a/internal/app/middleware.go b/internal/app/middleware.go index 8503a06..facd716 100644 --- a/internal/app/middleware.go +++ b/internal/app/middleware.go @@ -3,6 +3,7 @@ package app import ( "fmt" "log" + "net" "net/http" "strings" "time" @@ -95,9 +96,10 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler // RateLimitConfig holds configuration for rate limiting. type RateLimitConfig struct { - PostLimit int // max POST requests per window - GetLimit int // max GET requests per window - Window time.Duration // time window for rate limiting + PostLimit int // max POST requests per window + GetLimit int // max GET requests per window + Window time.Duration // time window for rate limiting + TrustedProxyCIDR string // CIDR from which X-Real-IP/X-Forwarded-For are trusted } // DefaultRateLimitConfig returns sensible default rate limits. @@ -109,21 +111,44 @@ func DefaultRateLimitConfig() RateLimitConfig { } } +// stripPort removes the port from an addr of the form "host:port". +func stripPort(addr string) string { + if host, _, err := net.SplitHostPort(addr); err == nil { + return host + } + return addr +} + +// ipInCIDR reports whether addr (host or host:port) falls within the given CIDR. +func ipInCIDR(addr, cidr string) bool { + ip := net.ParseIP(stripPort(addr)) + if ip == nil { + return false + } + _, network, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + return network.Contains(ip) +} + // RateLimiterMiddleware uses Redis for distributed rate limiting. type RateLimiterMiddleware struct { - rdb *redis.Client - postLimit int - getLimit int - window time.Duration + rdb *redis.Client + postLimit int + getLimit int + window time.Duration + trustedProxyCIDR string } // NewRateLimiter creates a new Redis-based rate limiter middleware. func NewRateLimiter(rdb *redis.Client, cfg RateLimitConfig) *RateLimiterMiddleware { return &RateLimiterMiddleware{ - rdb: rdb, - postLimit: cfg.PostLimit, - getLimit: cfg.GetLimit, - window: cfg.Window, + rdb: rdb, + postLimit: cfg.PostLimit, + getLimit: cfg.GetLimit, + window: cfg.Window, + trustedProxyCIDR: cfg.TrustedProxyCIDR, } } @@ -136,14 +161,20 @@ func (m *RateLimiterMiddleware) Handler(next http.Handler) http.Handler { return } - ip := r.RemoteAddr - if realIP := r.Header.Get("X-Real-IP"); realIP != "" { - ip = realIP - } else if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" { - if idx := strings.Index(forwardedFor, ","); idx != -1 { - ip = strings.TrimSpace(forwardedFor[:idx]) - } else { - ip = strings.TrimSpace(forwardedFor) + // Only trust proxy headers when the request originates from within the + // configured trusted CIDR. Without this guard a client that bypasses + // the reverse proxy can spoof X-Real-IP / X-Forwarded-For and rotate + // IPs freely to defeat rate limiting. + ip := stripPort(r.RemoteAddr) + if m.trustedProxyCIDR != "" && ipInCIDR(r.RemoteAddr, m.trustedProxyCIDR) { + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + ip = realIP + } else if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" { + if idx := strings.Index(forwardedFor, ","); idx != -1 { + ip = strings.TrimSpace(forwardedFor[:idx]) + } else { + ip = strings.TrimSpace(forwardedFor) + } } } diff --git a/internal/app/middleware_test.go b/internal/app/middleware_test.go index 1feabb4..861d3ab 100644 --- a/internal/app/middleware_test.go +++ b/internal/app/middleware_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func TestSecurityHeaders(t *testing.T) { @@ -216,6 +217,81 @@ func TestRateLimiter(t *testing.T) { }) } +func TestStripPort(t *testing.T) { + cases := []struct{ input, want string }{ + {"192.168.1.1:12345", "192.168.1.1"}, + {"[::1]:80", "::1"}, + {"10.0.0.1", "10.0.0.1"}, + } + for _, c := range cases { + if got := stripPort(c.input); got != c.want { + t.Errorf("stripPort(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +func TestIPInCIDR(t *testing.T) { + cases := []struct { + addr string + cidr string + want bool + }{ + {"10.0.0.1:1234", "10.0.0.0/8", true}, + {"10.255.255.255:1234", "10.0.0.0/8", true}, + {"192.168.1.1:1234", "10.0.0.0/8", false}, + {"172.16.0.5:8080", "172.16.0.0/12", true}, + {"bad-addr", "10.0.0.0/8", false}, + {"10.0.0.1:1234", "invalid-cidr", false}, + } + for _, c := range cases { + got := ipInCIDR(c.addr, c.cidr) + if got != c.want { + t.Errorf("ipInCIDR(%q, %q) = %v, want %v", c.addr, c.cidr, got, c.want) + } + } +} + +func TestRateLimiter_IPExtraction(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + t.Run("uses RemoteAddr when TrustedProxyCIDR is empty", func(t *testing.T) { + // With no CIDR set, proxy headers must be ignored even if present. + // We verify this indirectly: nil-redis limiter still passes through, + // so we just confirm the config field is wired correctly. + cfg := DefaultRateLimitConfig() + if cfg.TrustedProxyCIDR != "" { + t.Error("expected empty TrustedProxyCIDR in default config") + } + rl := NewRateLimiter(nil, cfg) + wrapped := rl.Handler(handler) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "1.2.3.4:9999" + req.Header.Set("X-Real-IP", "9.9.9.9") + rr := httptest.NewRecorder() + wrapped.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected %d, got %d", http.StatusOK, rr.Code) + } + }) + + t.Run("TrustedProxyCIDR propagates to middleware", func(t *testing.T) { + cfg := RateLimitConfig{ + PostLimit: 10, + GetLimit: 10, + Window: time.Minute, + TrustedProxyCIDR: "10.0.0.0/8", + } + rl := NewRateLimiter(nil, cfg) + if rl.trustedProxyCIDR != "10.0.0.0/8" { + t.Errorf("expected trustedProxyCIDR %q, got %q", "10.0.0.0/8", rl.trustedProxyCIDR) + } + }) +} + func TestContentLengthValidator(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/app/router.go b/internal/app/router.go index d7e1f29..43af364 100644 --- a/internal/app/router.go +++ b/internal/app/router.go @@ -21,9 +21,9 @@ func cacheControl(h http.Handler, maxAge time.Duration) http.Handler { }) } -func NewRouter(h *Handler, rdb *redis.Client, secCfg SecurityHeadersConfig) http.Handler { +func NewRouter(h *Handler, rdb *redis.Client, secCfg SecurityHeadersConfig, rlCfg RateLimitConfig) http.Handler { r := chi.NewRouter() - rl := NewRateLimiter(rdb, DefaultRateLimitConfig()) + rl := NewRateLimiter(rdb, rlCfg) r.Use(middleware.RequestID) r.Use(middleware.RealIP) diff --git a/internal/app/router_test.go b/internal/app/router_test.go index c87df9a..7471762 100644 --- a/internal/app/router_test.go +++ b/internal/app/router_test.go @@ -23,7 +23,7 @@ func TestNewRouter_Routes(t *testing.T) { }, } handler := NewHandler(mockRepo) - router := NewRouter(handler, nil, SecurityHeadersConfig{}) + router := NewRouter(handler, nil, SecurityHeadersConfig{}, DefaultRateLimitConfig()) testCases := []struct { name string @@ -62,7 +62,7 @@ func TestNewRouter_CreateEndpoint(t *testing.T) { }, } handler := NewHandler(mockRepo) - router := NewRouter(handler, nil, SecurityHeadersConfig{}) + router := NewRouter(handler, nil, SecurityHeadersConfig{}, DefaultRateLimitConfig()) reqBody := `{"secret":"test-secret"}` req := httptest.NewRequest(http.MethodPost, "/create", strings.NewReader(reqBody)) @@ -85,7 +85,7 @@ func TestNewRouter_ReadEndpoint_ValidUUID(t *testing.T) { }, } handler := NewHandler(mockRepo) - router := NewRouter(handler, nil, SecurityHeadersConfig{}) + router := NewRouter(handler, nil, SecurityHeadersConfig{}, DefaultRateLimitConfig()) // Valid UUID format uuid := "550e8400-e29b-41d4-a716-446655440000" @@ -106,7 +106,7 @@ func TestNewRouter_ReadEndpoint_InvalidUUID(t *testing.T) { mockRepo := &mockSecretRepository{} handler := NewHandler(mockRepo) - router := NewRouter(handler, nil, SecurityHeadersConfig{}) + router := NewRouter(handler, nil, SecurityHeadersConfig{}, DefaultRateLimitConfig()) // Invalid UUID format - should not match route req := httptest.NewRequest(http.MethodPost, "/read/invalid-id", nil) @@ -127,7 +127,7 @@ func TestNewRouter_SecurityHeaders(t *testing.T) { mockRepo := &mockSecretRepository{} handler := NewHandler(mockRepo) - router := NewRouter(handler, nil, SecurityHeadersConfig{}) + router := NewRouter(handler, nil, SecurityHeadersConfig{}, DefaultRateLimitConfig()) req := httptest.NewRequest(http.MethodGet, "/health", nil) rr := httptest.NewRecorder() @@ -148,7 +148,7 @@ func TestNewRouter_RedirectSlashes(t *testing.T) { mockRepo := &mockSecretRepository{} handler := NewHandler(mockRepo) - router := NewRouter(handler, nil, SecurityHeadersConfig{}) + router := NewRouter(handler, nil, SecurityHeadersConfig{}, DefaultRateLimitConfig()) // Request with trailing slash should redirect req := httptest.NewRequest(http.MethodGet, "/health/", nil) diff --git a/internal/config/config.go b/internal/config/config.go index 013c905..52e16f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "net" "os" "strconv" "time" @@ -31,8 +32,9 @@ type Config struct { ShutdownTimeout time.Duration // Security settings - RequireHTTPS bool // enforce HTTPS with HSTS header (disable with NO_HTTPS=1) - CanonicalHost string // canonical hostname for HTTPS redirects (CANONICAL_HOST) + RequireHTTPS bool // enforce HTTPS with HSTS header (disable with NO_HTTPS=1) + CanonicalHost string // canonical hostname for HTTPS redirects (CANONICAL_HOST) + TrustedProxyCIDR string // CIDR from which proxy headers are trusted (TRUSTED_PROXY_CIDR) } // DefaultConfig returns a Config with sensible defaults. @@ -111,6 +113,13 @@ func Load() (Config, error) { cfg.CanonicalHost = canonicalHost } + if cidr := os.Getenv("TRUSTED_PROXY_CIDR"); cidr != "" { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return Config{}, fmt.Errorf("TRUSTED_PROXY_CIDR must be a valid CIDR: %w", err) + } + cfg.TrustedProxyCIDR = cidr + } + return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4c434cc..8434cd6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -212,6 +212,43 @@ func TestLoad_NoHTTPSIgnoresOtherValues(t *testing.T) { } } +func TestLoad_TrustedProxyCIDRDefault(t *testing.T) { + os.Unsetenv("TRUSTED_PROXY_CIDR") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.TrustedProxyCIDR != "" { + t.Errorf("expected empty TrustedProxyCIDR by default, got %q", cfg.TrustedProxyCIDR) + } +} + +func TestLoad_TrustedProxyCIDR(t *testing.T) { + os.Setenv("TRUSTED_PROXY_CIDR", "10.0.0.0/8") + defer os.Unsetenv("TRUSTED_PROXY_CIDR") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.TrustedProxyCIDR != "10.0.0.0/8" { + t.Errorf("expected TrustedProxyCIDR %q, got %q", "10.0.0.0/8", cfg.TrustedProxyCIDR) + } +} + +func TestLoad_TrustedProxyCIDRInvalid(t *testing.T) { + os.Setenv("TRUSTED_PROXY_CIDR", "not-a-cidr") + defer os.Unsetenv("TRUSTED_PROXY_CIDR") + + _, err := Load() + if err == nil { + t.Error("expected error for invalid TRUSTED_PROXY_CIDR") + } +} + func TestLoad_CanonicalHostDefault(t *testing.T) { os.Unsetenv("CANONICAL_HOST") From bc8bfa8c9a2b0b165a6b8fc887a8cd7eb2414ca5 Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Thu, 26 Feb 2026 21:15:01 +0100 Subject: [PATCH 03/11] fix: require Redis authentication in docker-compose --- .env.example | 16 ++++++++++++++++ docker-compose.yml | 7 ++++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7995d0a --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy this file to .env and fill in the values before running docker-compose. + +# Required: password for the Redis instance. +# Generate a strong random value, e.g.: openssl rand -hex 32 +REDIS_PASSWORD=change-me + +# Optional: canonical hostname used for HTTPS redirects (prevents open redirect +# via a spoofed Host header). Set to the public hostname of your deployment. +# Example: CANONICAL_HOST=secretapi.example.com +CANONICAL_HOST= + +# Optional: CIDR range of your trusted reverse proxy. When set, X-Real-IP and +# X-Forwarded-For headers are only trusted when the direct connection comes from +# within this range. Leave empty to always use RemoteAddr for rate limiting. +# Example: TRUSTED_PROXY_CIDR=10.0.0.0/8 +TRUSTED_PROXY_CIDR= diff --git a/docker-compose.yml b/docker-compose.yml index d08b812..79b7b6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: restart: unless-stopped environment: PORT: 8080 - REDIS_URL: redis://redis:6379/0 + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 NO_HTTPS: 1 # Disable HTTPS enforcement for local development depends_on: redis: @@ -28,9 +28,10 @@ services: restart: unless-stopped volumes: - redis_data:/data - command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] + command: ["redis-server", "--save", "60", "1", "--loglevel", "warning", + "--requirepass", "${REDIS_PASSWORD}"] healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s timeout: 5s retries: 5 From 9cfd71ad3275f46e03d945cc1c6e9859051d77ec Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Thu, 26 Feb 2026 21:15:31 +0100 Subject: [PATCH 04/11] fix: apply rate limiting to /config endpoint --- internal/app/router.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/router.go b/internal/app/router.go index 43af364..432ec12 100644 --- a/internal/app/router.go +++ b/internal/app/router.go @@ -35,7 +35,6 @@ func NewRouter(h *Handler, rdb *redis.Client, secCfg SecurityHeadersConfig, rlCf r.Get("/robots.txt", h.HandleRobotsTXT) r.Get("/health", h.HandleHealth) - r.Get("/config", h.HandleConfig) fs := http.FileServer(http.Dir("web/static")) r.Handle("/static/*", @@ -49,6 +48,7 @@ func NewRouter(h *Handler, rdb *redis.Client, secCfg SecurityHeadersConfig, rlCf // API routes (rate limited) r.Group(func(r chi.Router) { r.Use(rl.Handler) + r.Get("/config", h.HandleConfig) r.Post("/create", h.HandleCreate) r.Post("/read/{id:[0-9a-fA-F-]{36}}", h.HandleRead) }) From 859011d7801cae1298770278494db026a7696924 Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Thu, 26 Feb 2026 21:16:15 +0100 Subject: [PATCH 05/11] fix: remove unsafe-inline from CSP style-src --- internal/app/middleware.go | 2 +- internal/app/middleware_test.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/app/middleware.go b/internal/app/middleware.go index facd716..3fc83a4 100644 --- a/internal/app/middleware.go +++ b/internal/app/middleware.go @@ -79,7 +79,7 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") // Content Security Policy csp := "default-src 'self'; script-src 'self'; " + - "style-src 'self' 'unsafe-inline'; " + + "style-src 'self'; " + "img-src 'self' data:; font-src 'self'; connect-src 'self'; " + "frame-ancestors 'none'; base-uri 'self'; form-action 'self'" w.Header().Set("Content-Security-Policy", csp) diff --git a/internal/app/middleware_test.go b/internal/app/middleware_test.go index 861d3ab..293339b 100644 --- a/internal/app/middleware_test.go +++ b/internal/app/middleware_test.go @@ -50,6 +50,9 @@ func TestSecurityHeaders(t *testing.T) { t.Errorf("expected CSP to contain %q", check) } } + if strings.Contains(csp, "unsafe-inline") { + t.Errorf("CSP must not contain 'unsafe-inline', got: %s", csp) + } // Verify Permissions-Policy pp := rr.Header().Get("Permissions-Policy") From 7e0233ae884cf63b187ecf46de5a310150e66c7b Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Thu, 26 Feb 2026 21:16:39 +0100 Subject: [PATCH 06/11] chore: update CI actions/checkout to v4 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eecdb6..270efda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: From 45539210bb2f61c2f8fa6447cefc2d449876766b Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Thu, 26 Feb 2026 21:16:57 +0100 Subject: [PATCH 07/11] chore: add govulncheck step to CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 270efda..6171302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,5 @@ jobs: run: go build -v ./... - name: Test run: go test -v ./... + - name: Vulnerability scan + run: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./... From c2b5516920aefe5ce34dd770bad6b077c963252d Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Thu, 26 Feb 2026 21:18:46 +0100 Subject: [PATCH 08/11] chore: pin Docker base images to digest --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 29894a3..45f2207 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # frontend build -FROM node:20-alpine AS frontend-builder +FROM node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 AS frontend-builder WORKDIR /src/web COPY web/package.json web/package-lock.json* ./ @@ -8,7 +8,7 @@ COPY web . RUN npm run build # backend build -FROM golang:1.24-alpine AS builder +FROM golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder ENV CGO_ENABLED=0 GOOS=linux GO111MODULE=on @@ -31,7 +31,7 @@ RUN go build -trimpath -mod=readonly -buildvcs=false -ldflags="-s -w" \ -o /out/secret-api ./cmd/server # runtime -FROM gcr.io/distroless/base:nonroot +FROM gcr.io/distroless/base:nonroot@sha256:746b9dbe3065a124395d4a7698241dbd6f3febbf01b73e48f942aabd7b8e5eac WORKDIR /app From 0f1f32e17be646c4ef155c0034457c5322c3bfa4 Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Sun, 8 Mar 2026 10:36:53 +0100 Subject: [PATCH 09/11] docs: expand .env.example with all supported env vars in logical sections --- .env.example | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 7995d0a..e010322 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,29 @@ # Copy this file to .env and fill in the values before running docker-compose. -# Required: password for the Redis instance. -# Generate a strong random value, e.g.: openssl rand -hex 32 +# ── Server ──────────────────────────────────────────────────────────────────── +PORT=8080 +SHUTDOWN_TIMEOUT=5s + +# ── Redis ───────────────────────────────────────────────────────────────────── +# Password used by docker-compose to configure the Redis instance AND embedded +# into REDIS_URL below. Generate a strong value: openssl rand -hex 32 REDIS_PASSWORD=change-me -# Optional: canonical hostname used for HTTPS redirects (prevents open redirect -# via a spoofed Host header). Set to the public hostname of your deployment. +# Full Redis connection URL. Must include the password set above. +REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:6379/0 +REDIS_POOL_SIZE=10 +REDIS_MIN_IDLE=2 + +# ── Security ────────────────────────────────────────────────────────────────── +# Canonical hostname for HTTPS redirects (prevents open redirect via spoofed +# Host header). Set to the public hostname of your deployment. # Example: CANONICAL_HOST=secretapi.example.com CANONICAL_HOST= -# Optional: CIDR range of your trusted reverse proxy. When set, X-Real-IP and -# X-Forwarded-For headers are only trusted when the direct connection comes from -# within this range. Leave empty to always use RemoteAddr for rate limiting. +# CIDR range of your trusted reverse proxy. X-Real-IP / X-Forwarded-For headers +# are only trusted when the connection comes from within this range. # Example: TRUSTED_PROXY_CIDR=10.0.0.0/8 TRUSTED_PROXY_CIDR= + +# Set to 1 to disable HTTPS enforcement and HSTS (development only). +NO_HTTPS= From 6a927d89ad4fb54a716a094e804b2d5f1c62d0e4 Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Sun, 8 Mar 2026 10:37:04 +0100 Subject: [PATCH 10/11] docs: add CANONICAL_HOST, TRUSTED_PROXY_CIDR, REDIS_PASSWORD to env vars table --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 22aed5f..5f7ba0e 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ Environment variables: | `REDIS_MIN_IDLE` | `2` | Minimum idle Redis connections | | `SHUTDOWN_TIMEOUT` | `5s` | Graceful shutdown timeout | | `NO_HTTPS` | (unset) | Set to `1` to disable HTTPS enforcement (for development) | +| `CANONICAL_HOST` | (unset) | Canonical hostname for HTTPS redirects; prevents open redirect via a spoofed `Host` header. Example: `secretapi.example.com` | +| `TRUSTED_PROXY_CIDR` | (unset) | CIDR range of your trusted reverse proxy. `X-Real-IP`/`X-Forwarded-For` headers are only trusted from this range. Example: `10.0.0.0/8` | +| `REDIS_PASSWORD` | (unset) | Redis password. Used by `docker-compose` to configure Redis and embedded in `REDIS_URL` (`redis://:password@host:port/db`). Not read directly by the Go binary. | ## Usage From 1fcd6364e42148bfc94ca4a22476f06924f899a5 Mon Sep 17 00:00:00 2001 From: PatillaCode Date: Sun, 8 Mar 2026 10:47:55 +0100 Subject: [PATCH 11/11] fix: upgrade chi, go-redis, and Go to address govulncheck findings --- .github/workflows/ci.yml | 2 +- go.mod | 8 +++----- go.sum | 8 ++++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6171302..a79c932 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: 1.24 + go-version: 1.26 - name: Build run: go build -v ./... - name: Test diff --git a/go.mod b/go.mod index 83b145d..7ab11ad 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,11 @@ module github.com/smallwat3r/secretapi -go 1.24.0 - -toolchain go1.24.9 +go 1.26 require ( - github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/chi/v5 v5.2.4 github.com/google/uuid v1.6.0 - github.com/redis/go-redis/v9 v9.6.1 + github.com/redis/go-redis/v9 v9.6.3 golang.org/x/crypto v0.27.0 ) diff --git a/go.sum b/go.sum index 060abc7..e13eb11 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,12 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= -github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= -github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/redis/go-redis/v9 v9.6.3 h1:8Dr5ygF1QFXRxIH/m3Xg9MMG1rS8YCtAgosrsewT6i0= +github.com/redis/go-redis/v9 v9.6.3/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=