mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
Caller.IP is the last thing forcing a Bridge install: cloud's request-needing sites cover body, arbitrary headers, URL and IP, and only IP was still missing. It is ambient, not declared — what the CONNECTION says, not something the caller states about itself — so it sits beside identity rather than in the op's input shape. Empty when no connection is behind the context, which is the same honest answer every other Caller field gives and never a panic. The trust question decides the rest. By default IP is the socket peer, which cannot be spoofed and behind a load balancer IS the load balancer. zip does not silently believe X-Forwarded-For: anyone can send one, and an IP that quietly reports the proxy is worse than none because someone will rate-limit or audit on it. A deployment behind a proxy opts in with Config.TrustProxy, and the opt-in is ALLOWLIST-GATED — TrustedProxies names the peers whose forwarded header is believed, and anywhere else it falls back to the peer. Setting TrustProxy with no allowlist trusts nothing, which is the safe reading of an incomplete config. The knobs are wired through to the router, because a trust setting that does not reach c.IP() is a promise the code does not keep. Measured: the typed path is 31 allocs/1388 B against 40/3063 on the previous tag. The improvement is stable across runs but I have not confirmed its cause, so it is reported rather than claimed — what is verified is no regression. One allocation I did add, a per-request closure for the header reader, is now built only for ops that declare a header. Co-authored-by: Hanzo Dev <[email protected]>
76 lines
2.5 KiB
Go
76 lines
2.5 KiB
Go
package zip
|
|
|
|
import (
|
|
"context"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
type ipIn struct{}
|
|
type ipOut struct {
|
|
IP string `json:"ip"`
|
|
}
|
|
|
|
func ipApp(t *testing.T, cfg Config) *App {
|
|
t.Helper()
|
|
cfg.DisableStartupMessage = true
|
|
a := New(cfg)
|
|
Get(a, "/whoami", func(ctx context.Context, _ *ipIn) (*ipOut, error) {
|
|
return &ipOut{IP: CallerOf(ctx).IP}, nil
|
|
}, WithOperationID("whoami"))
|
|
return a
|
|
}
|
|
|
|
func whoami(t *testing.T, a *App, xff string) string {
|
|
t.Helper()
|
|
req := httptest.NewRequest("GET", "/whoami", nil)
|
|
if xff != "" {
|
|
req.Header.Set("X-Forwarded-For", xff)
|
|
}
|
|
resp, err := a.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("GET: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
return readAll(t, resp.Body)
|
|
}
|
|
|
|
// By default an untrusted X-Forwarded-For is IGNORED. Anyone can send one, so
|
|
// believing it would make an audited or rate-limited value attacker-controlled.
|
|
func TestCallerIP_ForwardedHeaderIsNotBelievedByDefault(t *testing.T) {
|
|
a := ipApp(t, Config{AppName: "svc"})
|
|
got := whoami(t, a, "203.0.113.9")
|
|
if got == `{"ip":"203.0.113.9"}` {
|
|
t.Error("an unauthenticated X-Forwarded-For was believed — that value is attacker-controlled")
|
|
}
|
|
if got == `{"ip":""}` {
|
|
t.Error("no IP at all; the socket peer should still be reported")
|
|
}
|
|
}
|
|
|
|
// Opting in is allowlist-gated: the forwarded value is honoured only when the
|
|
// PEER is a named proxy.
|
|
func TestCallerIP_TrustedProxyIsHonouredOnlyForNamedPeers(t *testing.T) {
|
|
// fiber's test transport dials from 0.0.0.0; trust it explicitly.
|
|
trusting := ipApp(t, Config{AppName: "svc", TrustProxy: true,
|
|
TrustedProxies: []string{"0.0.0.0/0"}, ProxyHeader: "X-Forwarded-For"})
|
|
if got := whoami(t, trusting, "203.0.113.9"); got != `{"ip":"203.0.113.9"}` {
|
|
t.Errorf("a trusted proxy's forwarded address was not honoured: %s", got)
|
|
}
|
|
|
|
// The same app with an allowlist that does NOT include the peer falls back.
|
|
notTrusting := ipApp(t, Config{AppName: "svc", TrustProxy: true,
|
|
TrustedProxies: []string{"198.51.100.7"}, ProxyHeader: "X-Forwarded-For"})
|
|
if got := whoami(t, notTrusting, "203.0.113.9"); got == `{"ip":"203.0.113.9"}` {
|
|
t.Error("a forwarded address was believed from a peer that is not in the allowlist")
|
|
}
|
|
}
|
|
|
|
// No connection behind the context means nothing is claimed — the same honest
|
|
// answer every other Caller field gives, and never a panic.
|
|
func TestCallerIP_EmptyWithNoConnection(t *testing.T) {
|
|
if got := CallerOf(context.Background()).IP; got != "" {
|
|
t.Errorf("CallerOf(background).IP = %q, want empty", got)
|
|
}
|
|
}
|