Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions internal/gateway/header_matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ func TestIncomingHeaderMatcherRefusesForwardingHeaders(t *testing.T) {
})
}

// X-Authorizer-Url is the same class, one key over. It decides the host of
// emailed password-reset / magic-link URLs and the `iss` claim of the tokens
// minted from them, so a caller who can set it can have a victim emailed a
// genuine reset link pointing at a host the caller controls. The gin path
// pins it to --url; the gRPC path did not, so the prefixed spelling
// reinstated host poisoning over REST.
t.Run("the authorizer-url header cannot be smuggled", func(t *testing.T) {
for _, smuggled := range []string{
"Grpc-Metadata-X-Authorizer-Url",
"grpc-metadata-x-authorizer-url",
"GRPC-METADATA-X-AUTHORIZER-URL",
"X-Authorizer-Url",
} {
md := annotate(t, map[string]string{smuggled: "https://evil.example.net"})
for _, key := range []string{"x-authorizer-url", "grpcgateway-x-authorizer-url"} {
for _, v := range md.Get(key) {
assert.NotEqual(t, "https://evil.example.net", v,
"%s smuggled an attacker-chosen host into %s", smuggled, key)
}
}
}
})

t.Run("the admin-secret header is still forwarded", func(t *testing.T) {
md := annotate(t, map[string]string{"x-authorizer-admin-secret": "s3cret"})
assert.Equal(t, []string{"s3cret"}, md.Get("x-authorizer-admin-secret"),
Expand Down
14 changes: 13 additions & 1 deletion internal/gateway/mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,21 @@ func incomingHeaderMatcherOption() runtime.ServeMuxOption {
// Belt and braces with transport.clientIPFromGRPC, which reads the
// LAST value rather than the first for the same reason. Either alone
// would close it; a header this load-bearing gets both.
//
// X-Authorizer-Url is here for the identical reason, one key over. It
// decides the host of emailed password-reset and magic-link URLs and the
// `iss` claim of the resulting tokens. The gin path pins it to --url
// inside parsers.GetHostFromRequest; the gRPC path did not consult --url
// at all, so the prefixed spelling reinstated host poisoning over REST.
// transport.hostURLFromGRPC now prefers --url — this is its second lock.
//
// This does NOT affect the gateway's own x-authorizer-url value: that is
// injected via runtime.WithMetadata (above), a separate option which
// operates on the already-parsed request and never passes through this
// matcher.
switch textproto.CanonicalMIMEHeaderKey(strings.TrimPrefix(
textproto.CanonicalMIMEHeaderKey(key), runtime.MetadataHeaderPrefix)) {
case "X-Forwarded-For", "X-Real-Ip", "X-Forwarded-Host":
case "X-Forwarded-For", "X-Real-Ip", "X-Forwarded-Host", "X-Authorizer-Url":
return "", false
}
return runtime.DefaultHeaderMatcher(key)
Expand Down
32 changes: 31 additions & 1 deletion internal/grpcsrv/transport/grpc_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"google.golang.org/grpc/peer"

"github.com/authorizerdev/authorizer/internal/constants"
"github.com/authorizerdev/authorizer/internal/parsers"
"github.com/authorizerdev/authorizer/internal/service"
"github.com/authorizerdev/authorizer/internal/utils"
)
Expand All @@ -37,7 +38,7 @@ func MetaFromGRPC(ctx context.Context) service.RequestMetadata {
protocol = constants.ProtocolREST
}
meta := service.RequestMetadata{
HostURL: firstHeader(md, "x-authorizer-url", "grpcgateway-x-authorizer-url"),
HostURL: hostURLFromGRPC(md),
IPAddress: clientIPFromGRPC(ctx, md),
UserAgent: firstHeader(md, "grpcgateway-user-agent", "user-agent"),
AuthorizationHeader: firstHeader(md, "authorization", "grpcgateway-authorization"),
Expand Down Expand Up @@ -101,6 +102,35 @@ const bufconnNetwork = "bufconn"
// left is the ordinary forwarded chain. Splitting it back apart hands
// utils.GetIP exactly the shape it would have seen on the gin path, so a
// REST call and the equivalent direct HTTP call resolve identically.
//
// hostURLFromGRPC resolves the authorizer host for a gRPC/REST call.
//
// The operator's --url wins over anything the caller sent, which is the whole
// point: this value becomes the host of the emailed password-reset and
// magic-link URLs and the `iss` claim of the tokens minted from them, so a
// caller who can choose it can have a victim emailed a genuine reset link
// pointing at a host the caller controls.
//
// parsers.GetHostFromRequest already enforced that for the gin path. This
// transport never called it — it read the metadata key directly — so the pin
// did not reach gRPC or the REST gateway in front of it, and the two surfaces
// disagreed with the gin one about the same deployment's own address.
//
// The metadata fallback below is unreachable in a shipped binary: it refuses to
// start without a usable --url (cmd.validateAuthorizerURL). It survives for
// library embedders and tests, which construct a Config directly. There it uses
// lastHeader, not firstHeader, for the same reason clientIPFromGRPC does: the
// gateway appends its own authoritative value AFTER anything a client smuggled
// in via the "Grpc-Metadata-X-Authorizer-Url" spelling, so the first value is
// the caller's and the last is ours. gateway.Handler also refuses that key at
// the boundary; this is the second lock.
func hostURLFromGRPC(md metadata.MD) string {
if trusted := parsers.TrustedURL(); trusted != "" {
return trusted
}
return lastHeader(md, "x-authorizer-url", "grpcgateway-x-authorizer-url")
}

func clientIPFromGRPC(ctx context.Context, md metadata.MD) string {
// LAST value, not first. A metadata key can hold several values, and
// grpc-gateway appends its own authoritative chain (client XFF + the real
Expand Down
106 changes: 106 additions & 0 deletions internal/grpcsrv/transport/host_url_pin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package transport

import (
"context"
"net/http"
"testing"

"google.golang.org/grpc/metadata"

"github.com/authorizerdev/authorizer/internal/parsers"
)

// TestHostURLPinnedToTrustedURL is the regression guard for the trusted-URL pin
// on the gRPC and REST-gateway transports.
//
// HostURL becomes the host of the emailed password-reset / magic-link URL and
// the `iss` claim of the tokens minted from them, so a caller who can choose it
// can have a victim emailed a genuine-looking reset link pointing at a host the
// caller controls. parsers.GetHostFromRequest pins that for the gin path; this
// transport read the metadata key directly and so never consulted --url.
//
// Not parallel, and restores the value in Cleanup: parsers.trustedURL is a
// set-once-at-startup package global.
func TestHostURLPinnedToTrustedURL(t *testing.T) {
const trusted = "https://auth.example.com"
const attacker = "https://evil.example.net"

parsers.SetTrustedURL(trusted)
t.Cleanup(func() { parsers.SetTrustedURL("") })

// Control: the gin path already ignores the caller's value. If this ever
// fails the pin itself regressed, not just this transport.
req, err := http.NewRequest(http.MethodGet, "http://listener.internal/", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Authorizer-URL", attacker)
if got := parsers.GetHostFromRequest(req); got != trusted {
t.Fatalf("gin control path regressed: GetHostFromRequest = %q, want %q", got, trusted)
}

t.Run("direct gRPC caller cannot choose the host", func(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(),
metadata.Pairs("x-authorizer-url", attacker))
if got := MetaFromGRPC(ctx).HostURL; got != trusted {
t.Errorf("HostURL = %q, want the trusted URL %q", got, trusted)
}
})

t.Run("gateway-smuggled value cannot win on ordering", func(t *testing.T) {
// A REST client sending Grpc-Metadata-X-Authorizer-Url lands a real
// x-authorizer-url entry BEFORE the gateway appends its own, so reading
// index 0 returned the caller's. gateway.Handler now also refuses the
// key outright; this asserts the transport holds even if it arrives.
md := metadata.MD{}
md.Append("x-authorizer-url", attacker)
md.Append("x-authorizer-url", trusted)
ctx := metadata.NewIncomingContext(context.Background(), md)
if got := MetaFromGRPC(ctx).HostURL; got != trusted {
t.Errorf("HostURL = %q, want the trusted URL %q", got, trusted)
}
})

t.Run("grpcgateway- prefixed spelling is also pinned", func(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(),
metadata.Pairs("grpcgateway-x-authorizer-url", attacker))
if got := MetaFromGRPC(ctx).HostURL; got != trusted {
t.Errorf("HostURL = %q, want the trusted URL %q", got, trusted)
}
})
}

// TestHostURLFallbackWithoutTrustedURL covers the embedder/test path. A shipped
// binary refuses to start without --url (cmd.validateAuthorizerURL), but a
// library embedder constructing a Config directly can reach this, and the
// gateway's own injected value must still arrive — otherwise every REST-minted
// token gets "http://bufconn" as its issuer and JWT validation rejects it.
func TestHostURLFallbackWithoutTrustedURL(t *testing.T) {
parsers.SetTrustedURL("")

t.Run("gateway's own value is honoured", func(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(),
metadata.Pairs("x-authorizer-url", "https://auth.example.com"))
if got := MetaFromGRPC(ctx).HostURL; got != "https://auth.example.com" {
t.Errorf("HostURL = %q, want the gateway's injected value", got)
}
})

t.Run("last value wins over a smuggled first", func(t *testing.T) {
md := metadata.MD{}
md.Append("x-authorizer-url", "https://evil.example.net")
md.Append("x-authorizer-url", "https://auth.example.com")
ctx := metadata.NewIncomingContext(context.Background(), md)
if got := MetaFromGRPC(ctx).HostURL; got != "https://auth.example.com" {
t.Errorf("HostURL = %q, want the LAST (gateway-appended) value", got)
}
})

t.Run("falls back to :authority when no value is present", func(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(),
metadata.Pairs(":authority", "listener.internal"))
if got := MetaFromGRPC(ctx).HostURL; got != "http://listener.internal" {
t.Errorf("HostURL = %q, want the :authority fallback", got)
}
})
}
13 changes: 13 additions & 0 deletions internal/parsers/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ func SetTrustedURL(u string) {
trustedURL = sanitizeAuthorizerURL(strings.TrimSpace(u))
}

// TrustedURL returns the operator-configured canonical base URL (--url), or ""
// when none is usable.
//
// Exported for transports that do not carry an *http.Request and so cannot go
// through GetHostFromRequest. The gRPC path is the reason this exists: it built
// its host from caller-supplied metadata and never consulted --url at all, so
// the host-pinning this package enforces for the gin path simply did not apply
// there. A transport that derives a host MUST prefer this over anything the
// caller sent.
func TrustedURL() string {
return trustedURL
}

// SetLogger records the logger used to report rejected X-Authorizer-URL
// header values. Called once from cmd/root at startup, alongside SetTrustedURL.
func SetLogger(log *zerolog.Logger) {
Expand Down
Loading