Skip to content

Commit e50437a

Browse files
wthrbtnclaude
andcommitted
feat: add --trusted-proxies for X-Forwarded-For client IP logging
When cooked runs behind a reverse proxy, request logs show the proxy IP instead of the real client. The --trusted-proxies flag accepts comma-separated IPs and CIDRs (e.g. "127.0.0.1,10.0.0.0/8"). When the direct peer matches, cooked reads X-Forwarded-For right-to-left and logs the first non-trusted IP as client_ip. The client_ip field is only added to request logs when trusted-proxies is configured, keeping default log output unchanged. Closes cooked-2tn. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c9536f commit e50437a

9 files changed

Lines changed: 324 additions & 3 deletions

.beads/.br_history/issues.20260403_142846_685131893.jsonl

Lines changed: 112 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"target":{"kind":"relative","path":"issues.jsonl"}}

.beads/.br_history/issues.20260403_142846_734424730.jsonl

Lines changed: 112 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"target":{"kind":"relative","path":"issues.jsonl"}}

.beads/issues.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
{"id":"cooked-15c","title":"Add IsPrivateAddress test for DNS resolution error path","notes":"Gremlins: 1 NOT COVERED mutant at url.go:51:9. All test cases use IP literals, never reach net.LookupIP error.","status":"closed","priority":4,"issue_type":"task","owner":"14095054+wthrbtn@users.noreply.github.com","created_at":"2026-02-07T07:07:23.190437+01:00","created_by":"Jörgen","updated_at":"2026-02-07T07:11:30.977357+01:00","closed_at":"2026-02-07T07:11:30.977357+01:00","close_reason":"Tests written and verified. Remaining NOT COVERED in gremlins is a Go coverage instrumentation limitation with switch case conditions."}
88
{"id":"cooked-192","title":"F-08: Build asset SHA-256 integrity verification","description":"Add SHA-256 checksums for embedded assets in Makefile and Dockerfile. Verify after download.","status":"closed","priority":2,"issue_type":"bug","owner":"14095054+wthrbtn@users.noreply.github.com","created_at":"2026-02-07T03:35:41.850851+01:00","created_by":"Jörgen","updated_at":"2026-02-07T03:47:34.275009+01:00","closed_at":"2026-02-07T03:47:34.275009+01:00","close_reason":"Implemented and tested in security remediation session"}
99
{"id":"cooked-1u1","title":"WI-1: Project bootstrap - main.go, config, embedded assets","notes":"Plan: WI-1. Create cmd/cooked/main.go with version vars, CLI flag parsing with env var fallback, config struct, go:embed declarations.","status":"closed","priority":0,"issue_type":"task","owner":"14095054+wthrbtn@users.noreply.github.com","created_at":"2026-02-07T00:47:20.511595+01:00","created_by":"Jörgen","updated_at":"2026-02-07T00:50:08.182667+01:00","closed_at":"2026-02-07T00:50:08.182667+01:00","close_reason":"Closed"}
10-
{"id":"cooked-2tn","title":"Trusted proxy headers (X-Forwarded-For, X-Forwarded-Proto)","description":"1. When cooked runs behind nginx/caddy, all request logs show the proxy IP (127.0.0.1) instead of the real client IP\n2. Add a --trusted-proxies flag that accepts CIDRs or IPs (e.g. 127.0.0.1, 10.0.0.0/8)\n3. When set, cooked reads X-Forwarded-For to extract the real client IP for logging\n4. Also read X-Forwarded-Proto to detect https when behind a TLS-terminating proxy, which affects --base-url auto-detection\n5. Without this, operators cannot correlate cooked logs with client requests","status":"open","priority":3,"issue_type":"task","created_at":"2026-04-03T13:50:10.485869642Z","created_by":"jorgen","updated_at":"2026-04-03T13:50:10.485869642Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["logging","ops","proxy"]}
10+
{"id":"cooked-2tn","title":"Trusted proxy headers (X-Forwarded-For, X-Forwarded-Proto)","description":"1. When cooked runs behind nginx/caddy, all request logs show the proxy IP (127.0.0.1) instead of the real client IP\n2. Add a --trusted-proxies flag that accepts CIDRs or IPs (e.g. 127.0.0.1, 10.0.0.0/8)\n3. When set, cooked reads X-Forwarded-For to extract the real client IP for logging\n4. Also read X-Forwarded-Proto to detect https when behind a TLS-terminating proxy, which affects --base-url auto-detection\n5. Without this, operators cannot correlate cooked logs with client requests","status":"closed","priority":3,"issue_type":"task","assignee":"jorgen","created_at":"2026-04-03T13:50:10.485869642Z","created_by":"jorgen","updated_at":"2026-04-03T14:28:46.720154678Z","closed_at":"2026-04-03T14:28:46.719916356Z","close_reason":"Added --trusted-proxies flag with X-Forwarded-For client IP extraction","source_repo":".","compaction_level":0,"original_size":0,"labels":["logging","ops","proxy"]}
1111
{"id":"cooked-323","title":"Git-aware mode","notes":"Won't fix — cooked is URL-agnostic by design. It fetches whatever URL you give it without knowing about git forges. Coupling to cgit/gitea/forgejo URL patterns adds complexity for unclear value. Users already construct the correct raw-file URL.","status":"closed","priority":4,"issue_type":"feature","owner":"14095054+wthrbtn@users.noreply.github.com","created_at":"2026-02-07T01:53:59.885773Z","created_by":"Jörgen","updated_at":"2026-03-31T17:40:15.468418339Z","closed_at":"2026-03-31T17:40:15.467892325Z","source_repo":".","compaction_level":0,"original_size":0}
1212
{"id":"cooked-3av","title":"Fix TestSetup_JSONOutput coverage theater — test Setup() not stdlib","description":"logging_test.go:12-37 never calls Setup(), tests slog.NewJSONHandler directly. If Setup() had a bug (wrong level, wrong output), this test wouldn't catch it. Fix: test Setup() directly by verifying it sets slog.Default() and produces JSON.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-03-31T16:10:20.247718800Z","created_by":"jorgen","updated_at":"2026-03-31T16:13:51.192098325Z","closed_at":"2026-03-31T16:13:51.191573419Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["high","testing"]}
1313
{"id":"cooked-3i4","title":"F-06: Add HTTP server timeouts","description":"Set ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout, MaxHeaderBytes on http.Server in main.go.","status":"closed","priority":1,"issue_type":"bug","owner":"14095054+wthrbtn@users.noreply.github.com","created_at":"2026-02-07T03:35:41.700232+01:00","created_by":"Jörgen","updated_at":"2026-02-07T03:47:34.234999+01:00","closed_at":"2026-02-07T03:47:34.234999+01:00","close_reason":"Implemented and tested in security remediation session"}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ make build # Build the binary
4343
| `--default-theme` | `COOKED_DEFAULT_THEME` | `auto` | Default theme: auto, light, or dark |
4444
| `--tls-skip-verify` | `COOKED_TLS_SKIP_VERIFY` | `false` | Disable TLS certificate verification for upstream fetches |
4545
| `--frame-ancestors` | `COOKED_FRAME_ANCESTORS` | `none` | CSP frame-ancestors: `none`, `self`, or space-separated origins |
46+
| `--trusted-proxies` | `COOKED_TRUSTED_PROXIES` | *(empty)* | Comma-separated trusted proxy IPs/CIDRs for `X-Forwarded-For` client IP extraction |
4647

4748
## Security
4849

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Config struct {
2121
DefaultTheme string
2222
TLSSkipVerify bool
2323
FrameAncestors string
24+
TrustedProxies string
2425
}
2526

2627
// Parse reads configuration from CLI flags with environment variable fallback.
@@ -39,6 +40,7 @@ func Parse(args []string) (*Config, error) {
3940
fs.StringVar(&cfg.DefaultTheme, "default-theme", envOr("COOKED_DEFAULT_THEME", "auto"), "Default theme: auto, light, or dark")
4041
fs.BoolVar(&cfg.TLSSkipVerify, "tls-skip-verify", envBoolOr("COOKED_TLS_SKIP_VERIFY", false), "Disable TLS certificate verification for upstream fetches")
4142
fs.StringVar(&cfg.FrameAncestors, "frame-ancestors", envOr("COOKED_FRAME_ANCESTORS", "none"), "CSP frame-ancestors: none, self, or space-separated origins (e.g. \"https://gitea.internal\")")
43+
fs.StringVar(&cfg.TrustedProxies, "trusted-proxies", envOr("COOKED_TRUSTED_PROXIES", ""), "Comma-separated trusted proxy IPs or CIDRs for X-Forwarded-For (e.g. \"127.0.0.1,10.0.0.0/8\")")
4244

4345
if err := fs.Parse(args); err != nil {
4446
return nil, err

internal/logging/logging.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type RequestFields struct {
4545
TotalMs int64
4646
ContentType string
4747
Bytes int64
48+
ClientIP string
4849
}
4950

5051
// LogRequest logs a completed request with structured fields.
@@ -56,7 +57,7 @@ func LogRequest(logger *slog.Logger, f RequestFields) {
5657
level = slog.LevelWarn
5758
}
5859

59-
logger.Log(context.Background(), level, "request",
60+
attrs := []any{
6061
"method", f.Method,
6162
"path", f.Path,
6263
"upstream", f.Upstream,
@@ -67,7 +68,11 @@ func LogRequest(logger *slog.Logger, f RequestFields) {
6768
"total_ms", f.TotalMs,
6869
"content_type", f.ContentType,
6970
"bytes", f.Bytes,
70-
)
71+
}
72+
if f.ClientIP != "" {
73+
attrs = append(attrs, "client_ip", f.ClientIP)
74+
}
75+
logger.Log(context.Background(), level, "request", attrs...)
7176
}
7277

7378
// ByteCountingWriter wraps http.ResponseWriter to capture status code and bytes written.

internal/server/server.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"html/template"
66
"io/fs"
77
"log/slog"
8+
"net"
89
"net/http"
910
"net/url"
1011
"strconv"
@@ -33,6 +34,7 @@ type Server struct {
3334
tmpl *cookedtemplate.Renderer
3435
assets fs.FS
3536
allowlist *Allowlist
37+
trustedProxies []*net.IPNet
3638
mux *http.ServeMux
3739
readmePage []byte // pre-rendered docs page (nil if README not embedded)
3840
}
@@ -82,6 +84,7 @@ func New(cfg *config.Config, version string, assets fs.FS, extraFetchOpts ...fet
8284
tmpl: cookedtemplate.NewRenderer(),
8385
assets: assets,
8486
allowlist: allowlist,
87+
trustedProxies: parseTrustedProxies(cfg.TrustedProxies),
8588
mux: http.NewServeMux(),
8689
}
8790

@@ -518,6 +521,7 @@ func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
518521
TotalMs: time.Since(start).Milliseconds(),
519522
ContentType: wrapped.Header().Get("X-Cooked-Content-Type"),
520523
Bytes: wrapped.Bytes,
524+
ClientIP: s.clientIP(r),
521525
})
522526
})
523527
}
@@ -568,3 +572,86 @@ func searchString(s, substr string) bool {
568572
}
569573
return false
570574
}
575+
576+
// parseTrustedProxies parses a comma-separated list of IPs and CIDRs into
577+
// a list of *net.IPNet for matching. Bare IPs are converted to /32 or /128.
578+
func parseTrustedProxies(raw string) []*net.IPNet {
579+
if raw == "" {
580+
return nil
581+
}
582+
var nets []*net.IPNet
583+
for _, entry := range strings.Split(raw, ",") {
584+
entry = strings.TrimSpace(entry)
585+
if entry == "" {
586+
continue
587+
}
588+
if strings.Contains(entry, "/") {
589+
_, cidr, err := net.ParseCIDR(entry)
590+
if err == nil {
591+
nets = append(nets, cidr)
592+
}
593+
} else {
594+
ip := net.ParseIP(entry)
595+
if ip != nil {
596+
bits := 32
597+
if ip.To4() == nil {
598+
bits = 128
599+
}
600+
nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
601+
}
602+
}
603+
}
604+
return nets
605+
}
606+
607+
// clientIP returns the client IP for logging. If the direct peer is in the
608+
// trusted proxy list and X-Forwarded-For is present, the rightmost non-trusted
609+
// IP is returned. Otherwise, the peer IP from RemoteAddr is returned.
610+
func (s *Server) clientIP(r *http.Request) string {
611+
peerIP, _, _ := net.SplitHostPort(r.RemoteAddr)
612+
if len(s.trustedProxies) == 0 {
613+
return peerIP
614+
}
615+
616+
ip := net.ParseIP(peerIP)
617+
if ip == nil {
618+
return peerIP
619+
}
620+
621+
trusted := false
622+
for _, cidr := range s.trustedProxies {
623+
if cidr.Contains(ip) {
624+
trusted = true
625+
break
626+
}
627+
}
628+
if !trusted {
629+
return peerIP
630+
}
631+
632+
xff := r.Header.Get("X-Forwarded-For")
633+
if xff == "" {
634+
return peerIP
635+
}
636+
637+
// Walk X-Forwarded-For right-to-left, return first non-trusted IP
638+
parts := strings.Split(xff, ",")
639+
for i := len(parts) - 1; i >= 0; i-- {
640+
candidate := strings.TrimSpace(parts[i])
641+
cip := net.ParseIP(candidate)
642+
if cip == nil {
643+
return candidate
644+
}
645+
isTrusted := false
646+
for _, cidr := range s.trustedProxies {
647+
if cidr.Contains(cip) {
648+
isTrusted = true
649+
break
650+
}
651+
}
652+
if !isTrusted {
653+
return candidate
654+
}
655+
}
656+
return peerIP
657+
}

0 commit comments

Comments
 (0)