Skip to content

security(transport): pin the authorizer host to --url on gRPC and REST - #814

Merged
lakhansamani merged 1 commit into
mainfrom
security/grpc-trusted-url-pin
Oct 14, 2026
Merged

lakhansamani merged 1 commit into
mainfrom
security/grpc-trusted-url-pin

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Makes the operator-configured --url authoritative on the gRPC and grpc-gateway REST transports, as it already is on the gin/HTTP path.

Background

parsers.GetHostFromRequest resolves this server's own base URL and returns --url in preference to every request header once it is set. That pinning is what makes the host trustworthy, and --url is mandatory — the binary refuses to start without a usable one (cmd.validateAuthorizerURL).

The gRPC transport did not go through that function. MetaFromGRPC built RequestMetadata.HostURL directly from caller-supplied metadata and never consulted --url, so the pin did not reach gRPC or the REST mux in front of it, and the three surfaces disagreed about the same deployment's own address.

That value is load-bearing rather than cosmetic: internal/service/forgot_password.go:74 and magic_link_login.go:157 take meta.HostURL as the host of the emailed password-reset / magic-link URL, store it on the verification request, and it becomes the iss claim of the tokens minted on redemption.

Changes

Two independent locks, matching what x-forwarded-for already carries for the same class of bug — its comment reads "Either alone would close it; a header this load-bearing gets both."

internal/grpcsrv/transport/grpc_metadata.go — new hostURLFromGRPC prefers parsers.TrustedURL() over any metadata value.

The metadata fallback below it is unreachable in a shipped binary and survives for library embedders and tests, which construct a Config directly. There it now reads the last value rather than the first, for the same reason clientIPFromGRPC does: the gateway appends its own authoritative value after anything a client supplied, so the first entry is the caller's and the last is ours.

internal/gateway/mount.go — the incoming-header matcher now refuses X-Authorizer-Url, alongside the three forwarding headers already there. DefaultHeaderMatcher forwards a Grpc-Metadata--prefixed header with the prefix stripped, so without this a REST caller could reach the metadata key the transport reads. The existing switch canonicalises and strips the prefix, so one entry covers every spelling.

This does not affect the gateway's own x-authorizer-url value, which is injected via runtime.WithMetadata — a separate option that operates on the already-parsed request and never passes through the matcher. There is a test asserting that, because it is the thing a future "cleanup" would break (mount.go's own comment explains why: without the gateway's value, REST-minted tokens get http://bufconn as their issuer and JWT validation rejects every one).

internal/parsers/url.go — TrustedURL() exported, so a transport with no *http.Request can reach the pin.

Compatibility

No change on the REST path. The gateway already injects x-authorizer-url = parsers.GetHostFromRequest(r), which returns the trusted URL first. The value MetaFromGRPC computes after this change is byte-identical to what it computed before.

Direct gRPC changes behaviour, deliberately. A caller-supplied x-authorizer-url metadata value used to win; now --url does. The only deployment that notices is one doing per-request host resolution over gRPC — and that deployment is already inconsistent, since the gin path has ignored the header ever since --url became authoritative there. This makes the transports agree. X-Authorizer-URL is not documented in README.md, MIGRATION.md or docs/ as a deployment mechanism, so a deliberate dependency is unlikely; worth a changelog note under Changed regardless.

Checked the two tests that touch this header and neither is affected: internal/parsers/url_test.go:176 deliberately preserves legacy header priority when no --url is configured (untouched — this does not change the gin path), and internal/integration_tests/scim_deprovision_test.go builds a raw gin.New() router that never reaches the gateway mux.

Testing

internal/grpcsrv/transport/host_url_pin_test.go (new) and an added case in internal/gateway/header_matcher_test.go, 7 cases total: the direct-gRPC path, value-ordering, the grpcgateway- prefixed spelling, every blocked spelling at the gateway boundary, and three fallback behaviours including the :authority default.

Both locks are mutation-verified independently — each was disabled on its own, in a form that still compiled, and its regression test confirmed to fail before being restored. (Disabling lock 1 naively only produces an unused-import build error, which demonstrates nothing; the check was redone so the test itself was what failed.)

Full bar, all green locally:

go build ./...     OK
go vet ./...       OK
make test          0 failures
make smoke         ok  internal/e2e  23.658s   (GraphQL + REST + gRPC + MCP)

Smoke was run because this touches transport wiring, per AGENTS.md — it boots the real binary and exercises every public surface, which is exactly where a wrong fix here would show up.

The gin path resolves this server's own base URL through
parsers.GetHostFromRequest, which returns the operator-configured
--url and ignores every request header once it is set. The gRPC
transport built the same value straight from caller-supplied
metadata and never consulted --url at all, so the two surfaces
disagreed about the deployment's own address and the pin did not
reach gRPC or the grpc-gateway REST mux in front of it.

That value is load-bearing: it becomes the host of the emailed
password-reset and magic-link URLs (internal/service/forgot_password.go,
magic_link_login.go) and the `iss` claim of the tokens minted from
them, so a caller able to choose it can have a victim emailed a
genuine reset link pointing at a host the caller controls.

Two locks, matching the treatment x-forwarded-for already carries
for the same class of bug:

- transport.hostURLFromGRPC prefers parsers.TrustedURL() over any
  metadata. The metadata fallback is unreachable in a shipped
  binary (cmd.validateAuthorizerURL refuses to start without a
  usable --url) and survives for library embedders and tests, where
  it now reads the LAST value rather than the first, since the
  gateway appends its own authoritative value after anything a
  client smuggled in.
- gateway's incoming-header matcher refuses X-Authorizer-Url, which
  closes the Grpc-Metadata- spelling DefaultHeaderMatcher forwards
  prefix-stripped. The gateway's own value is injected via
  runtime.WithMetadata and does not pass through the matcher, so it
  is unaffected.

- parsers.TrustedURL() exported so a transport with no *http.Request
  can reach the pin.

Both locks are mutation-verified: disabling either one alone makes
its regression test fail.
@lakhansamani
lakhansamani merged commit b5012cb into main Oct 14, 2026
4 checks passed
lakhansamani added a commit that referenced this pull request Oct 14, 2026
Sixteen PRs merged since the 2.4.1 tag; [Unreleased] documented three
of them and none of the five advisory fixes. A security release whose
notes omit its security fixes gives nobody a reason to upgrade, which
is the operator-facing half of the failure SECURITY.md triage exists
to prevent.

Renames [Unreleased] to 2.4.2 and documents all of it:

- Security: the six advisories closed (#814, #799, #798, #796 x2,
  #815) and the three reachable dependency advisories (#797, #807,
  #811), plus the Go 1.27 builder (#800).
- Fixed: the audit-filter parity bugs (#802) and the Cassandra index
  probe that never waited (#810).
- Changed: direct-gRPC x-authorizer-url now yields to --url (#814),
  and the dropped Cassandra audit_log(created_at) index (#812).
- Added: the audit plumbing (#806), stated as writing no new records
  and changing no behaviour, plus two deployment entries.
- Upgrade notes: eight items, including the three that were missing
  entirely — that ANY two audit filters now scan on Cassandra (not
  only the timestamp range, which the previous note implied), the
  longer first start on ScyllaDB now that probes genuinely block, and
  the manual DROP INDEX for the removed index.

Section order leads with Security, matching 2.4.1 — the file orders by
what matters for the release at hand rather than a fixed sequence.

Deliberately omitted: #791 (scorecard action bump) and #809 (the
previous changelog edit). Neither is user-facing; Keep a Changelog
asks for notable changes, not a commit log.

The ScyllaDB view-build note is scoped to ScyllaDB: global secondary
indexes are materialized views there, while Apache Cassandra builds
local 2i with no view-build step and no nodetool viewbuildstatus.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant