Skip to main content

ADR-0179: Loopback is the only exception to the declared-origin rule for inherited redirects

Date: 2026-08-09 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 5, Codex (review, PR #1121)

Context

scripts/reconcile-keycloak-client.py sets post.logout.redirect.uris = "+". Keycloak resolves "+" to the client's redirectUris at validation time, which is the right structural choice: it removes the second list that drifted apart from the first and caused the original pilot defect, where an origin was accepted at login and rejected at logout.

Inheriting a list means inheriting whatever is in it. The reconciliation is additive, so a pre-existing wildcard or stale attacker-controlled entry in redirectUris survives, and "+" then promotes that login-side entry to a valid post-logout redirect target (issue #1091, epic #1064 track T0).

The first guard refused only the open-redirect class — an unrestricted wildcard, or a wildcard in the authority. Measured against #1091's own stated negative control, it allowed https://evil.example.com/*, because a wildcard in the path is the ordinary form and that entry has no wildcard in its authority. The issue asks for both halves: "reject unrestricted wildcards and anything not matching the declared public origin(s)".

The recorded reason for building only the first half was real: a client legitimately carries a localhost entry beside the public one during development, and a rule that refuses every foreign origin would fail on an ordinary realm. A control that cries wolf is a control that gets switched off.

That reasoning was cited as ADR-0121, which is wrong — ADR-0121 is about semantic citation verification, and the principle it records (a false-alarming control gets disabled) is general, not a redirect-safety decision. Codex was right that an auth-sensitive exemption needs a record of its own. This is that record.

Decision

Every entry in redirectUris must match a declared public origin, with loopback as the single exception.

  1. An origin is scheme + host + port. Comparing the host alone accepted http://<declared-host>/* — and ftp://<declared-host>/* — under an HTTPS declaration, and "+" then promoted the downgraded scheme to a post-logout target. The default port is folded in, so https://h and https://h:443 are one origin.

  2. The host is urlparse(...).hostname, never a split of the raw authority. Splitting netloc on its last colon read https://localhost:443@evil.example.com/* as loopback, while the browser's host is evil.example.com — an open redirect introduced inside the fix for an open redirect.

  3. Userinfo is refused outright. @ anywhere in the authority makes the entry unsafe, before any host comparison runs, so no later rule has to reason about a spoofable authority.

  4. The exception is exactly localhost, 127.0.0.1, ::1 — by hostname, on any scheme and any port. Scheme and port are deliberately not constrained for loopback: a developer runs the frontend on an arbitrary port over plain HTTP, and pinning either would reintroduce the false alarm this exception exists to avoid. Nothing is exempted by pattern — the set is three literal hostnames, so it cannot widen by accident.

  5. The script refuses and reports; it never narrows silently. An unexpected redirect entry is an operator decision, and deleting one could break a legitimate client. Asserted, including that the client's list is not mutated while refusing.

Consequences

Positive

  • The entry #1091 names as its negative control is now refused, along with the userinfo and scheme-downgrade variants that the first fix admitted.
  • The exemption is a set of three literal hostnames rather than a pattern, so its blast radius is legible and cannot grow silently.
  • Every rule is a pure function over a parsed URL, and each is asserted in both directions — refuses the unsafe entry, does not refuse the legitimate one.

Negative

  • A deployment that legitimately serves several public origins must declare them all. The script takes one origin today, so a second public hostname fails until the caller passes it. Refusing is the intended behaviour; the operator adds the origin rather than the script guessing.
  • Loopback is trusted on any scheme and any port. An attacker who can make a browser resolve localhost to something else, or who controls a local listener, is inside a threat model this control does not address. Stated rather than implied.
  • A client carrying a deliberate non-loopback dev origin (a *.ngrok.io tunnel, a staging alias) now blocks reconciliation until it is declared or removed.

Neutral

  • No application code, schema, migration or flag. One script and its tests.

Alternatives Considered

Alternative 1: Keep only the wildcard rule

  • Refuse unrestricted wildcards; accept any concrete origin.
  • Why rejected: it is the state #1091 was filed against, and it allowed https://evil.example.com/*. A concrete foreign origin is the more dangerous case — with "+" it becomes a valid post-logout target, and unlike a wildcard nothing about it looks wrong at a glance.

Alternative 2: Refuse every origin that is not declared, loopback included

  • No exception at all.
  • Why rejected: it fails on an ordinary development realm, where a localhost entry sits beside the public one. The script would refuse to reconcile on most machines, and a control that blocks routine work gets removed or bypassed — the outcome this decision is trying to avoid.

Alternative 3: Silently drop the offending entries and continue

  • Narrow redirectUris to the declared origin, then apply "+".
  • Why rejected: it deletes an operator's configuration without asking, and it would hide the very condition the check exists to surface. A legitimate entry removed silently is a broken client with no diagnostic.

Decision context:

  • Latency: none — pure string and URL parsing in a script run by hand.
  • Dependency surface: none. urllib.parse only.
  • Debuggability: a refusal names the offending entry and states why "+" makes it dangerous, so an operator can act without reading the source.
  • Reversibility: delete one function and one call argument; minutes.
  • Blast radius: one script. It refuses more than before, so the failure mode is a blocked reconciliation, never a widened client.
  • Alternative considered: Alternative 2 (no exception) — rejected because a control that fails on an ordinary realm is one that gets switched off.