mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:
users := app.Group("/v1/users")
users.Post("/", api.Create)
mint := users.Group("/mint").With(middleware.Mint(gate))
mint.Post("/deposit", api.Deposit)
A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.
That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.
Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.
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)
|
|
a.Get("/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)
|
|
}
|
|
}
|