Settings → Account: passkeys are offered at bare IP addresses, where browsers refuse them, and an IPv6 host is shown as \"[\ #429

Closed
opened 2026-10-01 10:46:38 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading accounts/passkeys.py and running its helpers against request hosts.

Checked again on 2026-10-01 against main at 0a5cad587: still present.

The Account page decides whether a passkey can be made here with passkeys.usable_here, and names the host it would be tied to with passkeys.bound_to. Both rest on _host, which cuts the Host header at the first colon, and neither knows that WebAuthn accepts only a domain as its relying-party id: an IP address is refused by the browser whether the page is served over HTTP or HTTPS. So the page offers "Add one" at hosts where the browser will refuse, and garbles IPv6 hosts in what it prints.

  • Where: accounts/passkeys.py:26, LOCAL_HOSTS; accounts/passkeys.py:29-40, _host; accounts/passkeys.py:43-56, usable_here and bound_to; templates/settings/account.html:105-130.
  • What happens:
    1. _host returns request.get_host().partition(":")[0], which for [::1]:8000 is [ and for [fd7a:115c::1]:8443 is [fd7a. LOCAL_HOSTS lists "[::1]" and "::1", neither of which _host can ever produce; and over HTTPS at an IPv6 address the page says "A passkey is tied to the address you made it at, which here is [fd7a."
    2. LOCAL_HOSTS also lists 127.0.0.1, and usable_here returns true for any request that is_secure(). An instance opened at http://127.0.0.1:8000, or over HTTPS at a bare IPv4 or IPv6 address, therefore shows "Add one"; the browser then refuses the ceremony, because the WebAuthn specification requires the caller's effective domain to be a valid domain, not an IP address. The browser suite checks passkeys at localhost (tests/e2e/test_passkeys.py:138) for the same reason.
  • Why it matters: the module exists so that neither failure is met only at the moment somebody cannot get in (its docstring, passkeys.py:17-18); at an address host it does the opposite, offering a button that fails and, over plain HTTP at [::1], blaming HTTP for a refusal that has a different cause.
  • Fix: split the host with django.http.request.split_domain_port, which handles bracketed IPv6, and strip the brackets. Treat any host that ipaddress.ip_address accepts as not usable for passkeys, with its own sentence ("Passkeys need the instance to be reached by a name, not a bare address"), before the secure-context test. Reduce LOCAL_HOSTS to localhost (subdomains of localhost are secure contexts too). bound_to then only ever prints a domain.

Done when

  • Tests in tests/test_passkeys.py request the Account page with HTTP_HOST set to 127.0.0.1:8000, [::1]:8000 and, marked secure, [fd7a:115c::1]:8443, and find no "Add one" and the address message each time; the existing localhost and testserver cases keep passing. (Set MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = False in those tests, since config/settings/test.py:35 turns it on.)
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading `accounts/passkeys.py` and running its helpers against request hosts. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The Account page decides whether a passkey can be made here with `passkeys.usable_here`, and names the host it would be tied to with `passkeys.bound_to`. Both rest on `_host`, which cuts the `Host` header at the first colon, and neither knows that WebAuthn accepts only a domain as its relying-party id: an IP address is refused by the browser whether the page is served over HTTP or HTTPS. So the page offers "Add one" at hosts where the browser will refuse, and garbles IPv6 hosts in what it prints. - **Where:** `accounts/passkeys.py:26`, `LOCAL_HOSTS`; `accounts/passkeys.py:29-40`, `_host`; `accounts/passkeys.py:43-56`, `usable_here` and `bound_to`; `templates/settings/account.html:105-130`. - **What happens:** 1. `_host` returns `request.get_host().partition(":")[0]`, which for `[::1]:8000` is `[` and for `[fd7a:115c::1]:8443` is `[fd7a`. `LOCAL_HOSTS` lists `"[::1]"` and `"::1"`, neither of which `_host` can ever produce; and over HTTPS at an IPv6 address the page says "A passkey is tied to the address you made it at, which here is [fd7a." 2. `LOCAL_HOSTS` also lists `127.0.0.1`, and `usable_here` returns true for any request that `is_secure()`. An instance opened at `http://127.0.0.1:8000`, or over HTTPS at a bare IPv4 or IPv6 address, therefore shows "Add one"; the browser then refuses the ceremony, because the WebAuthn specification requires the caller's effective domain to be a valid domain, not an IP address. The browser suite checks passkeys at `localhost` (`tests/e2e/test_passkeys.py:138`) for the same reason. - **Why it matters:** the module exists so that neither failure is met only at the moment somebody cannot get in (its docstring, `passkeys.py:17-18`); at an address host it does the opposite, offering a button that fails and, over plain HTTP at `[::1]`, blaming HTTP for a refusal that has a different cause. - **Fix:** split the host with `django.http.request.split_domain_port`, which handles bracketed IPv6, and strip the brackets. Treat any host that `ipaddress.ip_address` accepts as not usable for passkeys, with its own sentence ("Passkeys need the instance to be reached by a name, not a bare address"), before the secure-context test. Reduce `LOCAL_HOSTS` to `localhost` (subdomains of `localhost` are secure contexts too). `bound_to` then only ever prints a domain. ## Done when - Tests in `tests/test_passkeys.py` request the Account page with `HTTP_HOST` set to `127.0.0.1:8000`, `[::1]:8000` and, marked secure, `[fd7a:115c::1]:8443`, and find no "Add one" and the address message each time; the existing `localhost` and `testserver` cases keep passing. (Set `MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = False` in those tests, since `config/settings/test.py:35` turns it on.)
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:46:38 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#429
No description provided.