Honour forwarded headers only from MEALQUEST_TRUSTED_PROXY
Add internal/forwarded: parse the trusted proxy list (comma-separated IPs and CIDRs, validated at load), resolve each request's client IP and scheme, and expose them through context helpers. X-Forwarded-For and X-Forwarded-Proto are honoured only when the TCP peer is trusted; the client is the rightmost X-Forwarded-For address that is not a trusted proxy. Malformed forwarded headers from a trusted peer get 400. The router resolves every request first; a slog handler adds client_ip to request-scoped log lines. The auth routes refuse requests whose resolved scheme differs from MEALQUEST_PUBLIC_URL's, so a proxy that is not trusted fails loudly instead of breaking Secure cookies. The OIDC redirect URI is still built only from MEALQUEST_PUBLIC_URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,8 @@ const (
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
|
||||
)
|
||||
|
||||
// Session lifetime defaults (D-004): long enough that the kitchen tablet
|
||||
@@ -58,6 +61,10 @@ type Config struct {
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
SessionMax time.Duration
|
||||
|
||||
// TrustedProxy lists the reverse proxies whose X-Forwarded-For and
|
||||
// X-Forwarded-Proto headers are honoured. Empty trusts no peer.
|
||||
TrustedProxy forwarded.Trusted
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
@@ -88,6 +95,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
|
||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
|
||||
}
|
||||
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
|
||||
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
|
||||
@@ -177,6 +186,18 @@ func (r *reader) list(name string) []string {
|
||||
return items
|
||||
}
|
||||
|
||||
// trustedProxy parses an optional comma-separated list of IP addresses and
|
||||
// CIDR prefixes with forwarded.ParseTrusted. Unset or blank trusts no peer.
|
||||
func (r *reader) trustedProxy(name string) forwarded.Trusted {
|
||||
v, _ := r.lookup(name)
|
||||
t, err := forwarded.ParseTrusted(v)
|
||||
if err != nil {
|
||||
r.fail(name, "%v", err)
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (r *reader) listenAddr(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user