security(config): require --url, closing header-derived host takeover - #764
Merged
Conversation
Without --url the password-reset, email-verification and magic-link URLs, the JWT `iss` claim and the OIDC discovery/JWKS URLs were all derived from request headers. An unauthenticated attacker sends a forgot-password request carrying their own Host; the victim receives a genuine reset link pointing at the attacker's domain; clicking it hands over the token, and because `iss` is validated against the same header-derived host, the attacker redeems it by replaying the spoofed Host. Account takeover with no prior access and no mailbox compromise (CWE-640). Startup now refuses an empty --url, and also an unusable one: SetTrustedURL discards anything it cannot normalize and silently resumes header derivation, so "--url=auth.example.com" would have looked configured while staying vulnerable. This costs no supported capability. Setting --url already collapsed an instance to one canonical host, so multi-host only ever worked on the vulnerable path; verified org domains are email-domain routing for home realm discovery, not HTTP virtual hosting. Doing it now matters: pre-GA it is a release note, post-GA it needs a major. --url and --allowed-origins are routinely confused, so both are now documented against each other and the startup error spells out the difference: --url is this server's own address, --allowed-origins are the apps it may redirect to. Neither substitutes for the other. Also records the advisory-triage runbook in AGENTS.md and the three possible report outcomes in SECURITY.md. Twelve advisories sat in triage from April to August 2026; four were live vulnerabilities, and SECURITY.md had promised a 7-day triage decision throughout. Closes GHSA-m82j-rq33-qjx2.
lakhansamani
added a commit
that referenced
this pull request
Aug 14, 2026
The 2.4.0 entry still claimed a model without `type agent` keeps pre-2.4.0 behaviour byte-for-byte. e70c0b7 changed that: the check now denies, with --fga-allow-unconstrained-agents as the migration opt-out. The flag shipped with no changelog entry at all. Both states still meter as outcome="not_enforced", so a reader hitting that metric was being told their agents run unconstrained when in fact their delegated calls are being denied — opposite diagnosis, opposite remedy. Refs #764
This was referenced Aug 14, 2026
Merged
lakhansamani
added a commit
that referenced
this pull request
Aug 14, 2026
BREAKING CHANGE: --mcp-authorizer-url is gone; pass --url instead. The flag shipped in 2.3.0, so this is breaking for stdio MCP users. It lands in 2.4.0 rather than 2.5.0 because 2.4.0 already forces every deployment to add --url (#764) — the invocation is being edited anyway, so this rides along instead of costing a second migration. --url now does the job here: runMCP pins the trusted URL, which GetHostFromRequest consults before any header, and mcp.Options takes its value. Verified end to end with a live server via `node mcp-agent.mjs --verify` — all six assertions pass with the flag absent. Also refuses --mcp-bearer without --url at startup. That combination previously failed later as a bare `Unauthenticated` on every tool call, which reads as a bad token rather than missing config.
lakhansamani
added a commit
that referenced
this pull request
Aug 14, 2026
#764 made --url required — the server now refuses to start without it. That shipped with no changelog entry, and the existing Security bullet still described --url as a new optional flag that "sets" the trusted source, implying deployments could leave it unset. It is the most disruptive change in 2.4.0: every 2.3.x deployment that never set --url fails to boot on upgrade. Recorded as BREAKING under Changed, alongside the --encryption-key entry it parallels. Refs #764
lakhansamani
added a commit
that referenced
this pull request
Aug 14, 2026
The flag description told operators they could leave --url empty and keep "legacy header-based derivation". #764 removed that option — the server refuses to start without it — so `--help` documented a configuration the binary rejects. Also names the distinction operators actually get wrong: --url is this server's own address, --allowed-origins is the apps it may redirect to.
lakhansamani
added a commit
that referenced
this pull request
Aug 14, 2026
* deprecate(mcp): --mcp-authorizer-url in favour of --url The two feed different mechanisms. --url sets the trusted URL, which GetHostFromRequest returns before it looks at any header; --mcp-authorizer-url only stamps `x-authorizer-url` metadata. Now that --url is required (#764) and inherited by the subcommand, it always wins — so --mcp-authorizer-url is inert wherever --url is set. That is the reason to deprecate rather than leave it: passing both is not an error and warns about nothing, so a divergent value looks configured and silently does nothing. examples/with-agent-permissions already passes both, so the flag is dead there today. Not removed. It still reaches the header path when `authorizer mcp` runs without --url, and breaking a 2.3.x stdio setup in a minor release to delete a flag whose whole subcommand goes in 2.5.0 buys nothing. * fix(mcp): honour --url in the stdio subcommand `authorizer mcp` inherits the root flag set, so --url was always accepted there — but parsers.SetTrustedURL was only ever called from runRoot. The flag therefore did nothing in this path: issuer validation stayed on header derivation, and --mcp-authorizer-url was the only mechanism that worked. Caught by deleting --mcp-authorizer-url from the example on the theory that it was already redundant. It was not; the stdio probe failed with `rpc error: code = Unauthenticated`. runMCP now pins the trusted URL as the server does, and the same probe passes with --url alone. This is what makes the deprecation in the previous commit true rather than assumed. * feat(mcp)!: remove --mcp-authorizer-url in 2.4.0 BREAKING CHANGE: --mcp-authorizer-url is gone; pass --url instead. The flag shipped in 2.3.0, so this is breaking for stdio MCP users. It lands in 2.4.0 rather than 2.5.0 because 2.4.0 already forces every deployment to add --url (#764) — the invocation is being edited anyway, so this rides along instead of costing a second migration. --url now does the job here: runMCP pins the trusted URL, which GetHostFromRequest consults before any header, and mcp.Options takes its value. Verified end to end with a live server via `node mcp-agent.mjs --verify` — all six assertions pass with the flag absent. Also refuses --mcp-bearer without --url at startup. That combination previously failed later as a bare `Unauthenticated` on every tool call, which reads as a bad token rather than missing config. * deprecate(mcp): make --mcp-authorizer-url inert, not removed Reverts the deletion in 4a3b1af. The flag stays parsed so a 2.3.x invocation keeps starting instead of dying on `unknown flag`, but nothing reads it — --url supplies the value now that runMCP pins the trusted URL. Verified inert end to end: with --url correct and --mcp-authorizer-url=https://WRONG.example, all six assertions of `node mcp-agent.mjs --verify` still pass against a live server. Before the runMCP fix that wrong value would have broken issuer validation. Drops the flag from internal/e2e/smoke_test.go, which now passes --url instead — the same migration the deprecation notice prescribes.
lakhansamani
added a commit
to authorizerdev/examples
that referenced
this pull request
Aug 14, 2026
* fix: document the flags the server actually requires
The setup line repeated in 11 READMEs was v1-era and missing FOUR flags
the 2.4.0 server refuses to start without. As written it fails on the
first one before ever reaching the others:
$ ./authorizer --database-type sqlite --database-url authorizer.db \
--admin-secret secret
Error: --encryption-key is required: ...
Replaced with a command verified to boot against a build of authorizer
main: adds --url (authorizerdev/authorizer#764), --encryption-key,
--jwt-type/--jwt-secret and --client-id/--client-secret.
Also adds --url to the two org-SSO compose files and the k8s-tokenreview
manifest; all three map 8080:8080, so the container's own address and the
address the demo dials agree.
* fix(agent-permissions): drop redundant --mcp-authorizer-url
--url alone now suffices: authorizerdev/authorizer#768 makes
`authorizer mcp` honour it. Verified with `node mcp-agent.mjs --verify`
against a live server — all six assertions pass.
The example passed --url and --mcp-authorizer-url with the same value.
Removing the latter WITHOUT the server fix failed with
`rpc error: code = Unauthenticated`, which is how the underlying bug
was found.
lakhansamani
added a commit
to authorizerdev/authorizer-js
that referenced
this pull request
Aug 14, 2026
The server exits at boot without --url as of authorizerdev/authorizer#764, so both suites would fail at container startup once the image pin moves to 2.4.0 stable. The pin is 2.4.0-rc.13 today, so this is a no-op now. Uses the in-container address rather than the mapped host port, which is only knowable after start. The difference affects generated email links and the JWT iss claim; neither suite asserts on either.
lakhansamani
added a commit
to authorizerdev/authorizer-py
that referenced
this pull request
Aug 14, 2026
The server exits at boot without --url as of authorizerdev/authorizer#764. CI pins 2.4.0-rc.16, which predates the requirement, so this is a no-op today and stops the job breaking when the pin moves to stable. Port mapping is fixed 8080:8080, so the container's own address and the address the tests dial are the same.
lakhansamani
added a commit
to authorizerdev/docs
that referenced
this pull request
Aug 14, 2026
The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written.
lakhansamani
added a commit
to authorizerdev/authorizer.dev
that referenced
this pull request
Aug 14, 2026
The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written.
lakhansamani
added a commit
to lakhansamani/authorizer-railway-migration-test
that referenced
this pull request
Aug 14, 2026
The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written.
This was referenced Aug 14, 2026
lakhansamani
added a commit
that referenced
this pull request
Aug 14, 2026
* docs: add --url to every runnable command --url is required since #764 — the server exits at boot without it — but these were never swept: the repo's own README (4 blocks, including the Docker quick start and the volume-persistence example), MIGRATION.md (3) and the perf harness (2). Every one of them, copy-pasted, fails with: --url is required (e.g. --url=https://auth.example.com) The perf container publishes 8090:8080, so its --url names 8090 — the address a client actually reaches it on. * docs: add --url to remaining runnable commands Second pass after the README fix. MIGRATION.md had two more blocks (the build/authorizer quickstart and the go run one-liner), and the .env.sample flag-mapping reference omitted --url entirely despite marking --client-id as required.
lakhansamani
added a commit
to authorizerdev/authorizer-go
that referenced
this pull request
Aug 14, 2026
The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written.
lakhansamani
added a commit
to authorizerdev/examples
that referenced
this pull request
Aug 14, 2026
The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written.
lakhansamani
added a commit
to authorizerdev/docs
that referenced
this pull request
Aug 14, 2026
* fix(docs): add mandatory flags missing from runnable examples Three blocks could not boot as written: - sso-guide: no --jwt-*, --client-id/secret or --admin-secret. Fails on `missing jwt type` after --encryption-key passes. - rate-limiting compose: no --jwt-* or --admin-secret. - mcp: no --jwt-*. `authorizer mcp` validates the bearer itself, so it needs the same JWT settings as the server that minted it — without them every tool call returns Unauthenticated. Verified the corrected sso-guide flag set boots against a build of main. Also documents persisting SQLite across restarts with a named volume; the Docker quick start writes the database inside the container, so every restart began from an empty one. * fix: add --url, required since authorizer 2.4.0 The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written.
lakhansamani
added a commit
to authorizerdev/docs
that referenced
this pull request
Aug 15, 2026
* fix(docs): add mandatory flags missing from runnable examples Three blocks could not boot as written: - sso-guide: no --jwt-*, --client-id/secret or --admin-secret. Fails on `missing jwt type` after --encryption-key passes. - rate-limiting compose: no --jwt-* or --admin-secret. - mcp: no --jwt-*. `authorizer mcp` validates the bearer itself, so it needs the same JWT settings as the server that minted it — without them every tool call returns Unauthenticated. Verified the corrected sso-guide flag set boots against a build of main. Also documents persisting SQLite across restarts with a named volume; the Docker quick start writes the database inside the container, so every restart began from an empty one. * fix: add --url, required since authorizer 2.4.0 The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written. * docs(mcp): document delegated tokens, correct verified claims Adds the RFC 8693 section: /mcp accepts delegated tokens, the resource must be <url>/mcp rather than the bare URL, answers are the agent's authority and not the user's, and the 5-minute TTL has no refresh. Corrections from checking the page against main: - protected-resource metadata also carries jwks_uri and resource_documentation - --url is required to start the server at all, not only with --mcp-enabled; MCP additionally requires a usable http(s) origin - a CIMD client_id must be an https URL WITH a path; a bare origin falls through to a registry lookup - FGA auto-reuse covers MariaDB; CockroachDB, YugabyteDB, libSQL, PlanetScale and SQL Server need an explicit --fga-store - the FailedPrecondition message is "fine-grained authorization is not enabled" * docs(mcp): record real-client verification of delegated tokens Verified on Claude Code 2.1.233: connects, calls check_permissions, and gets the agent's intersected answer. A token bound to the bare <url> instead of <url>/mcp fails with invalid_token.
lakhansamani
added a commit
to authorizerdev/authorizer.dev
that referenced
this pull request
Aug 19, 2026
The server exits at boot without --url (authorizerdev/authorizer#764). These commands could not start as written.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes
GHSA-m82j-rq33-qjx2— the last of the four live vulnerabilities found while triaging the advisory backlog. Also does the process work (items 3 and 4 of the triage plan).The vulnerability
With
--urlunset — the default — every self-referential URL this server emits is derived from request headers (X-Authorizer-URL, thenX-Forwarded-Host, thenHost): password-reset links, email-verification links, magic links, the JWTissclaim, and the OIDC discovery/JWKS URLs.The attack needs no prior access and no mailbox compromise:
forgot_passwordfor the victim, carryingHost: evil.example.evil.example.issis validated against the same header-derived host, so replaying the spoofedHostsatisfies the check.Full account takeover (CWE-640).
Why require
--urlrather than patch around itThe obvious lighter fix — validate the header-derived host against the origin allowlist — helps only deployments that configured an explicit list and does nothing on the default
*, which is the configuration the attack targets. It would leave the vulnerable setup vulnerable.Requiring
--urlcloses the class, and costs no supported capability:--urlalready collapses an instance to a single canonical host (GetHostFromRequestreturns it and ignores every header,parsers/url.go:58). Multi-host operation only ever worked on the vulnerable path.--mcp-enabledalready required--url; this follows the same precedent and sits next to it inrunRoot.Timing is the argument. Pre-GA this is a release note. Post-GA it is a breaking change that needs a major. rc.19 is the last cheap moment.
The subtle half
An unusable
--urlis rejected, not just an empty one.SetTrustedURLdiscards anythingsanitizeAuthorizerURLcan't normalize and silently resumes header derivation — so--url=auth.example.com(no scheme) or--url=https://user:pw@hostwould have looked configured while staying fully vulnerable. Checking only for""would have shipped a gate with a hole in it.--urlvs--allowed-originsThese get conflated constantly, and now that one is mandatory the confusion surfaces at boot. Both config fields now document each other, and the startup error says it outright:
One real interaction is documented too: when
--allowed-originsis empty or*,IsValidRedirectURIrestricts redirects to the server's own host — which is derived from--url. So--urlalso decides where the wildcard default may redirect.Process changes
Tests
cmd/authorizer_url_config_test.go:--urlrefusedSetTrustedURLwould have discarded — the precondition is checked, so the test can't pass vacuously--urlbeatsX-Authorizer-URL+X-Forwarded-Host+Hostset simultaneouslyVerification
go build ./...,go vet ./...,make lint— 0 issuesmake test— passmake smoke— caught a real break.org_admin_smoke_test.goboots a second server without--url; it failed with the new gate, which is the gate working. Fixed, re-run green. I then checked every otherstartServercall site and all nine Authorizer services ine2e-playground/docker-compose.yml— all already pass--url.make devupdated (it didn't pass--url)Upgrade note for rc.20
Every deployment must now set
--url. Existing installs that omitted it will refuse to start with the message above. This is deliberate: the configurations that break are exactly the ones vulnerable to account takeover.