Uh oh!
There was an error while loading. Please reload this page.
Loopback login page: explain admission refusals instead of 'Login failed' - #587
Conversation
…led' An unadmitted user saw a bare 'Login failed' in the browser tab, which hides that authentication worked and only an admin can finish the job. Map the D7 refusal codes to their own pages: no_membership says the account is not in an authorized organization and points at hyperparam.app/contact, org_not_permitted does the same for a named org, and org_selection_required says to re-run with --org. Other codes keep the generic page.
REFUSAL_PAGES[safeError] ?? GENERIC_FAILURE_PAGE reads through the prototype chain, and the key is attacker-chosen: ?error=constructor (or toString, __proto__, valueOf, hasOwnProperty) returns a non-nullish value that ?? accepts, so page.title/page.detail are undefined and the browser gets a page reading "undefined". Guard with Object.hasOwn, the idiom already used for untrusted keys in config/client_detach_disk.js. Also cover the two refusal codes the PR mapped but did not test, and annotate the map with the @ref LLP 0058#d7 its prose already claims. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 4, 2026
Review: findings (one real bug, fixed; two handed back)Your safety claim holds. I traced it rather than taking it on trust.
Specifically confirmed:
One note for the future: Fixed and pushed ( |
philcunliffe
commented
Aug 4, 2026
Review round 2: findingsThe only delta since the round 1 record ( Findings 4 and 5 from round 1 still stand, and both are yours to decide. Blocking ask - finding 4. Finding 5 is optional. Reply on this thread or push to the branch, and neutral will re-engage on its next tick. |
philcunliffe
left a comment
There was a problem hiding this comment.
Requesting changes on one point; the full review records are in the thread (rounds 1 and 2).
Your safety claim about the unescaped interpolation holds - I traced every value reaching the HTML and none is request- or server-derived. But the lookup behind it had a real bug: REFUSAL_PAGES[safeError] ?? GENERIC_FAILURE_PAGE reads through the prototype chain, so ?error=constructor (or toString, __proto__, valueOf, hasOwnProperty) returned a non-nullish value that ?? accepted and rendered a page titled "undefined" - a regression against master, where every code produced the generic page. Fixed and pushed as 99e26d7 with Object.hasOwn, plus tests for the two mapped codes that had none.
The blocking ask:no_membership links to https://hyperparam.app/contact, while explainLoginError tells the same user to ask their admin for an invite. On a self-hosted hypaware-server (explicitly supported per LLP 0058's Consequences) the admin is the customer's own and the vendor URL is wrong. Drop the link and mirror the terminal's wording, or derive the contact target from the identity origin - your call, but the two halves should agree on the remedy.
Optional: access_denied is in the taxonomy but not in your map, so it still gets the bare 'Login failed' page.
The no_membership page linked hyperparam.app/contact unconditionally, but self-hosting is supported and there the admin who can grant access is the reader's own colleague. managedContactUrl() matches the identity base against the shipped built-in remotes; the page offers the contact link when it hits and says 'ask your admin for access' when it does not. Also gives access_denied its own page instead of the generic one.
platypii
commented
Aug 4, 2026
Took the derive-from-origin option. Matched against the shipped built-ins rather than The lookup is now a Also took the optional one: Thanks for the |
platypii
commented
Aug 4, 2026
managedContactUrl compares origins exactly, but nothing in the suite held it there: loosening the compare to endsWith, includes, or a hostname-only match still passed. Add the near-miss identity bases (a lookalike label, a suffix domain, and plain http) so that loosening fails here instead. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Aug 4, 2026
Review round 3: clean
Does the ask land? YesYou took the derive-from-origin option. Rendered pages, probed live:
On a self-hosted server the browser now gives exactly the terminal's remedy, with no vendor URL anywhere in the body (asserted with One residual, non-blocking: on a Hyperparam-run target the halves still differ (browser "contact us", terminal "ask an admin to invite you"). Defensible, since nobody has an org to be invited into there and the terminal is the stale half. If you want them symmetric, The origin check: exact, not spoofable, not over-broad
Provenance is static: 28 probes. Matching (all genuinely the built-in origin): the plain origin, an uppercased host, an explicit IDN, case, default-port, userinfo and punycode are all handled by Re-verification after the |
| test | mutation | result |
|---|---|---|
| self-hosted target sends the reader to their own admin | link hardcoded to the vendor URL | fails |
| admission refusal links contact on a managed target | same | passes (correctly, the positive case) |
| a contact URL that is not a plain https link is dropped | drop the SAFE_CONTACT_URL check | fails |
| a provider denial says so | delete the access_denied case | fails |
| receiver gets a contact URL only for a Hyperparam-run target | managedContactUrl returns unconditionally | fails |
One gap, fixed and pushed (d13ee90, test-only)
minor - nothing pinned the origin compare to being exact.test/core/remote-oidc-login.test.js:68 tested only hypaware.hyperparam.app (match) and hyp.internal (no match). Both are far from the built-in, so loosening the compare kept the suite green: rewriting the compare as endsWith, as includes, and as a hostname-only match each left all 20 tests passing, while endsWith handed https://evil-hypaware.hyperparam.app the vendor link.
Added four near-miss identity bases (lookalike label, two suffix domains, plain http). All three loosenings now fail. Your implementation was already correct; this only stops it drifting.
Conventions
No em dashes, no semicolons outside the CSS string, no @typedef, no inline import('...') types, @import specifiers root-anchored. The @ref LLP 0058#d7 moved into refusalPage's JSDoc with no blank line breaking attachment, and the <a id="d7"> anchor is at llp/0058-oidc-login-client.decision.md:165.
Verification
npm test: 3293 tests, 3292 pass, 0 fail, 1 skippednpm run typecheck: clean- Pushed:
d13ee90(test-only)
You nailed this one. The parameterization is the right shape, the fail-closed default is the right default, the switch is a better answer to the prototype hazard than the guard it replaces, and gating the interpolated URL behind a charset check rather than trusting the caller is exactly the instinct that keeps respond()'s unescaped contract safe as it grows a parameter.
philcunliffe
left a comment
There was a problem hiding this comment.
Approving. The blocking ask is resolved: on a self-hosted target the browser now gives the same remedy the terminal does, with no vendor URL in the body, and access_denied is covered too.
I probed the origin check rather than reading it. new URL(a).origin === new URL(b).origin is exact, and 28 probes confirm it fails closed on every near-miss I could construct: lookalike labels, suffix domains, userinfo tricks, a full-width dot, a Cyrillic homoglyph, a trailing dot, a scheme downgrade, and a non-default port. Even a false match could only return the module constant, never any part of the input.
Also re-verified after the rework that nothing request-, server-, or origin-derived reaches the unescaped HTML (no hostname in the page), that state validation still precedes the refusal branch, and that the prototype-key hazard is structurally gone now that the lookup is a switch rather than a table index.
One test-only commit pushed (d13ee90): nothing in the suite held the compare to being exact, so endsWith/includes/hostname-only loosenings all stayed green. Four near-miss identity bases now fail them.
Merging is the maintainer's call; neutral holds here.
An unadmitted user saw a bare "Login failed / You can close this tab and return to the terminal" in the browser tab. That hides the actual outcome: authentication worked, admission did not, and only an admin can fix it.
The loopback receiver now maps the server's D7 refusal codes to their own pages:
no_membership: the account is not associated with an authorized organization, with a link to https://hyperparam.app/contact to request access.org_not_permitted: not a member of the requested org, check--orgor contact us.org_selection_required: re-run with--org <name>.Any other error code keeps the existing generic page. Titles and details stay our own literals, so the unescaped interpolation in
respond()is still safe; a couple of them now carry a link or<code>on purpose. The terminal already explained these codes viaexplainLoginError; this is the browser half of the same taxonomy.Two tests added: the refusal page names the reason and the contact URL, and an unknown code still gets the generic page.