Files
etalon 976f5238f8 feat: scaffold app skeleton with config, ULID, health endpoints, compose stack (phase 1)
- internal .env loader (real env wins) + strictly validated config struct
- ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable
- HTTP server with recovery/request-id/trusted-proxy/access-log middleware
- /healthz, /readyz (pluggable checkers), /metricsz (counter registry)
- multi-stage Dockerfile (alpine, non-root) + compose with healthchecks,
  app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:10:30 +02:00

85 lines
2.3 KiB
Go

package httpx
import (
"net/http"
"net/netip"
"strings"
)
// clientInfo is the resolved view of the requester after applying the
// trusted-proxy policy. It feeds rate limiting, sessions, and the audit log.
type clientInfo struct {
IP netip.Addr
Proto string // effective scheme: "http" or "https"
Host string // effective host header
}
func parsePeer(remoteAddr string) netip.Addr {
if ap, err := netip.ParseAddrPort(remoteAddr); err == nil {
return ap.Addr().Unmap()
}
if a, err := netip.ParseAddr(remoteAddr); err == nil {
return a.Unmap()
}
return netip.Addr{}
}
func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
if !a.IsValid() {
return false
}
for _, p := range trusted {
if p.Contains(a) {
return true
}
}
return false
}
// resolveClient applies §12: X-Forwarded-For / X-Forwarded-Proto /
// X-Forwarded-Host are honored only when the direct peer is inside
// TRUSTED_PROXY_CIDRS; otherwise the headers are ignored entirely.
//
// The client IP is found by walking the X-Forwarded-For chain from the
// rightmost entry (appended by our own proxy) towards the left, skipping
// addresses that are themselves trusted proxies; the first untrusted address
// is the real client. A malformed entry stops the walk (everything further
// left is attacker-controllable).
func resolveClient(r *http.Request, trusted []netip.Prefix, defaultProto string) clientInfo {
peer := parsePeer(r.RemoteAddr)
info := clientInfo{IP: peer, Proto: defaultProto, Host: r.Host}
if len(trusted) == 0 || !isTrusted(peer, trusted) {
return info
}
var chain []string
for _, h := range r.Header.Values("X-Forwarded-For") {
for part := range strings.SplitSeq(h, ",") {
if part = strings.TrimSpace(part); part != "" {
chain = append(chain, part)
}
}
}
for i := len(chain) - 1; i >= 0; i-- {
a := parsePeer(chain[i])
if !a.IsValid() {
break
}
info.IP = a
if !isTrusted(a, trusted) {
break
}
// a is itself a trusted proxy: keep walking left. If the whole chain
// is trusted, the leftmost entry stands as the best available answer.
}
switch p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p {
case "http", "https":
info.Proto = p
}
if h := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); h != "" {
info.Host = h
}
return info
}