Skip to content

security(config): require --url, closing header-derived host takeover - #764

Merged
lakhansamani merged 1 commit into
mainfrom
security/require-canonical-url
Aug 13, 2026
Merged

security(config): require --url, closing header-derived host takeover#764
lakhansamani merged 1 commit into
mainfrom
security/require-canonical-url

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

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 --url unset — the default — every self-referential URL this server emits is derived from request headers (X-Authorizer-URL, then X-Forwarded-Host, then Host): password-reset links, email-verification links, magic links, the JWT iss claim, and the OIDC discovery/JWKS URLs.

The attack needs no prior access and no mailbox compromise:

  1. Attacker sends forgot_password for the victim, carrying Host: evil.example.
  2. Victim receives a genuine reset email from the real service, pointing at evil.example.
  3. Victim clicks; the token lands on the attacker's server.
  4. Attacker redeems it — iss is validated against the same header-derived host, so replaying the spoofed Host satisfies the check.

Full account takeover (CWE-640).

Why require --url rather than patch around it

The 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 --url closes the class, and costs no supported capability:

  • Setting --url already collapses an instance to a single canonical host (GetHostFromRequest returns it and ignores every header, parsers/url.go:58). Multi-host operation only ever worked on the vulnerable path.
  • Verified org domains are email-domain→organization routing for home realm discovery, not HTTP virtual hosting. Unaffected.
  • --mcp-enabled already required --url; this follows the same precedent and sits next to it in runRoot.

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 --url is rejected, not just an empty one. SetTrustedURL discards anything sanitizeAuthorizerURL can't normalize and silently resumes header derivation — so --url=auth.example.com (no scheme) or --url=https://user:pw@host would have looked configured while staying fully vulnerable. Checking only for "" would have shipped a gate with a hole in it.

--url vs --allowed-origins

These 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:

--url is required (e.g. --url=https://auth.example.com)

  Why: without it the password-reset, email-verification and magic-link URLs, and the
  JWT `iss` claim, are derived from request headers — so an attacker can have a victim
  emailed a genuine reset link pointing at a domain the attacker controls.

  Note --url is NOT --allowed-origins; you need both:
    --url             this server's own address, e.g. https://auth.example.com
    --allowed-origins your apps this server may redirect to, e.g. https://app.example.com

One real interaction is documented too: when --allowed-origins is empty or *, IsValidRedirectURI restricts redirects to the server's own host — which is derived from --url. So --url also decides where the wildcard default may redirect.

Process changes

  • SECURITY.md — states the three outcomes every report reaches: confirmed (reproduced with a failing test that ships with the fix), not accepted (closed with a written reason, original preserved, reopen if we're wrong), or needs-info. "Cannot reproduce" and "we think you're wrong" are now distinguished explicitly.
  • AGENTS.md — the triage runbook, with the incident that motivated it: twelve advisories sat from April to August 2026, four were live, one was reported twice and flagged as an incomplete fix of a published CVE, and nineteen RCs shipped with it. Core rule: write a probe before forming an opinion, verify affected/patched ranges yourself (three of six published on 2026-08-13 had stale or missing ones), and fix live high-severity issues in a private fork, not a public PR.

Tests

cmd/authorizer_url_config_test.go:

  • Empty and whitespace-only --url refused
  • Seven unusable values refused, each asserted to be one SetTrustedURL would have discarded — the precondition is checked, so the test can't pass vacuously
  • Five usable forms accepted, including trailing slash and surrounding whitespace
  • --url beats X-Authorizer-URL + X-Forwarded-Host + Host set simultaneously
  • Header derivation still works when unset, so the fallback library embedders rely on doesn't rot

Verification

  • go build ./..., go vet ./..., make lint — 0 issues
  • make test — pass
  • make smoke — caught a real break. org_admin_smoke_test.go boots a second server without --url; it failed with the new gate, which is the gate working. Fixed, re-run green. I then checked every other startServer call site and all nine Authorizer services in e2e-playground/docker-compose.yml — all already pass --url.
  • make dev updated (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.

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
lakhansamani merged commit 5715865 into main Aug 13, 2026
6 checks passed
@lakhansamani
lakhansamani deleted the security/require-canonical-url branch August 13, 2026 13:39
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
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 lakhansamani mentioned this pull request Aug 14, 2026
9 tasks
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.
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.
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