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.
-
An origin is scheme + host + port. Comparing the host alone accepted
http://<declared-host>/*— andftp://<declared-host>/*— under an HTTPS declaration, and"+"then promoted the downgraded scheme to a post-logout target. The default port is folded in, sohttps://handhttps://h:443are one origin. -
The host is
urlparse(...).hostname, never a split of the raw authority. Splittingnetlocon its last colon readhttps://localhost:443@evil.example.com/*as loopback, while the browser's host isevil.example.com— an open redirect introduced inside the fix for an open redirect. -
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. -
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. -
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
origintoday, 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
localhostto 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.iotunnel, 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
localhostentry 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
redirectUristo 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.parseonly. - 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.