oficina / docs Public

Restore detected Keymail OAuth without widening CSP open

On branch fix/keymail-oauth

Paul requested detected Keymail addresses use OAuth; email was unconditionally forced by both the form and gateBegin. This fix follows existing spec section 8.1.

Classification and rate limits now run independently of admission. admissionMailer suppresses delivery for refused addresses or failed grant lookups; callback and per-request authorization remain required. This avoids exposing membership through OAuth versus email selection. Suppressed email attempts leave only the upstream expiring token, never delivered.

OAuth starts end with a 200 HTML continuation using Home's existing meta-refresh plus link pattern. The pending cookie survives, CSP stays unchanged, and no-store/no-referrer apply. Destination validation checks the plugin-generated HTTPS OAuth URL and client/callback/PKCE bindings. Explicit email fallback remains. Existing method-aware local-link freshness is unchanged; no fabricated Keymail authentication time.

Actual library integration tests use subprocess-isolated DNS/HTTP fixtures: allowed/refused OAuth, state/address/PKCE errors, replay, and non-Keymail mail delivery. Focused race tests passed. Chromium/WebKit each passed JavaScript on/off and automatic/fallback navigation (8 checks). Three compiled mutations failed for their intended reasons: forced email, delivery bypass, direct OAuth redirect chain. Full local gate and exact remote CI are in progress.

Fable is currently quota-limited until its reported 2026-09-09 09:50 Europe/Dublin reset. No review verdict, merge, or deployment is claimed. Prepare a whole-branch review after reset, then deploy only the existing Docs preview. No human email was sent by tests. Current live preview remains c703a41.

Candidate 3fffd1be535ec87ab6e92da753046a4f1e4c754c is pushed. Full local .amadan/ci.d/10-gate passed, focused race passed, exact remote CI passed. All 8 browser transition cases passed after final markup. Three compiled mutations failed on their intended assertions. Worktree clean.

Evidence: /home/paulca/.cache/oficina-keymail-oauth/evidence.json. Immutable full-branch Fable packet is prepared at /home/paulca/.cache/oficina-keymail-oauth/review/prompt.txt but NOT invoked before the provider's 09:50 Dublin reset. No merge/deployment. Next action is review, then preview-only release and actual version verification.

Read-only DNS checks found rslw.com has Google MX, no _keymail TXT and no root A record; its current library discovery falls back to email. Receiving forwarded mail in Keymail does not establish delegation. No DNS changes made.

A separate Home agent confirmed the same direct OAuth303/CSP issue on Home main and is preparing an isolated fix reusing the existing continuation template. Home checker work remains separate and unchanged.

Independent Claude Fable 5.1 review completed for exact 3fffd1be535ec87ab6e92da753046a4f1e4c754c. Root observed exit0, iserror=false, endturn and actual model usage. Full verdict follows; no deployment is claimed by the review.

Verdict: APPROVE. No blocking findings. Four follow-ups worth tracking, none of which weakens admission, session minting, or the CSP posture.

Findings, severity-ranked

  1. Medium. Global sign-in lockout is now reachable anonymously. rastrillo's clientIP is the raw RemoteAddr, and its own comment says that behind the platform edge the 20-per-15-minute budget collapses into one global budget. Before this branch, a stranger's address was refused ahead of Begin and burned nothing. Now every POST consumes the budget, so 20 junk submissions lock every member out of sign-in for 15 minutes, repeatably. This widens an upstream weakness rather than creating it (an attacker already could do this with one guessable member address). Fix belongs upstream: trusted-proxy client IP in rastrillo/auth. Existing sessions are unaffected.
  1. Medium. Home-mode local linking loops for a Keymail provider that omits auth_time. The guard in freshLocalAuthentication is correct and must stay method-aware. But the consequence for such a provider is: /account/home → Begin → /signin/local → OAuth → callback → Complete lands on / with no explanation, and the next attempt repeats. A clearer fallback is warranted as a follow-up: when Complete (or the /account/home handler) sees a keymail session with zero AuthTime, redirect to /signin/local?force=1 instead of /. That offers email without treating session mint time as provider authentication. Not blocking: it affects only Home mode with a no-gesture provider, and the fixture provider reports auth_time.
  1. Low. Protocol-relative Location passthrough is latent. beginSignin treats any Location beginning with / as internal and forwards it as a real 303, so //host/... would be sent to the browser. Unreachable with the pinned plugin (AuthorizeURL always emits https://). Tighten the check to exclude a // prefix.
  1. Low. Refused-address auth_links rows accumulate until a sweep nothing calls. Confirmed: sendLink runs PutLink before Send, Sweep exists in v0.23 and Docs never invokes it. Growth is bounded by the same rate limiter as finding 1, and each row is small. Not required for this release. A boot-time or periodic Auth.Sweep is the right follow-up; a pre-storage delivery hook is not needed and would require an upstream API change.
  1. Low. No visible email-fallback affordance. force=1 is reachable only through rastrillo's failed-callback redirect or by typing the query. A Keymail user whose provider is down has no in-page way to choose email. Copy review is waived; this is an affordance, not wording.
  1. Info. Blind SSRF surface is now anonymous. Classification performs a DNS TXT lookup and HTTPS GETs to a host the submitter's domain delegates to, with Go's default redirect-following and a 5 s timeout. Upstream documents Begin as "a blind-SSRF surface by design". This is the accepted cost of the OAuth the user asked for; previously this app opted out with unconditional force=1.

Verified correct

  • Membership disclosure. Email branch stays byte-identical (existing test). OAuth branch returns a 200 continuation for allowed and refused alike, differing only in fresh state/challenge. The synchronous-SMTP timing difference for admitted addresses pre-dates this branch.
  • Unadmitted Keymail users. rastrillo's admit() checks Authorize before sessions.SignIn, so a refused verified identity gets a 403 and no cookie. The integration test's cookie-name check is valid: the session cookie is __Host-rastrillo_session.
  • Continuation document. Only Set-Cookie is copied from the captured 303; CSP is set by the outer middleware on the real writer and is untouched. Cache-Control: no-store and Referrer-Policy: no-referrer are set. Go 1.26.5 (matching go.mod) escapes <meta content="0;url=…"> through the URL filter and normalizer plus attribute escaping. The rendered fixture confirms &amp; encoding and an intact authorize URL.
  • Destination validation. Refuses non-https, userinfo, fragment, wrong path, duplicate or missing keys, mismatched client_id/redirect_uri, non-identify scope, non-S256. Test covers each. Nothing submitted by the client reaches the destination; return_to never enters this path.
  • Callback. Pending cookie is cleared on first read; replay never reaches the token endpoint (test asserts exchange count). Wrong address, wrong state and wrong PKCE all refuse without a session.
  • Local-link boundary. No AuthTime fallback to session.At for keymail; explicit force=1 still renders the hidden field and takes the magic-link path; the local sign-in form no longer forces email.
  • Test isolation. Subprocess test rewrites net.DefaultResolver and http.DefaultTransport only in the child; the DNS fixture answers NXDOMAIN, so no packet leaves the box.

Evidence assessment

| Source | Result | |---|---| | ci.json | success at 3fffd1b, gate step passed | | gate.log, race.log | internal/docs ok under both | | mutations.json + logs | All three fail for the intended diagnostic | | oauth-browser/results.json | 8 of 8 navigations reached the fixture Keymail origin with the pending cookie and no referer |

Limits: the browser result fields are literals written after the assertions pass, and "cspUnchanged" means the response carried the unchanged policy and no CSP console error fired. No live provider or human login was exercised. Live detection for rslw.com is unproven and currently falls back to email. The deployed preview remains c703; nothing here changes that.

I could not write the review to the plan file: the Write tool is disabled in this session. This message is the full record.

Deployed a8f5c0e1d67ad042e89793aa941135eacd98aa23 to https://overnight-docs.bds.oncarlos.com, account bds, channel canary/overnight-release, environment overnight-release. Both runtime /api/version body and edge version now match. The initial observation had old runtime/new edge metadata; a scoped restart request followed by wake verified the new binary. Production Docs still reports87662f5.

Merged tree is identical to Fable-reviewed/tested3fffd1b; exact candidate CI passed, ARM64 archive build passed. Main CI is separately running. Eight live unsigned Chromium/WebKit checks passed (JS on/off,320/1280): form does not force mail, explicit force=1 fallback works, CSP preserved, no overflow. Local synthetic real classifier/OAuth/state/PKCE/admission and continuation browser checks remain the evidence for authentication; no human email or live provider sign-in was performed.

Try https://overnight-docs.bds.oncarlos.com/signin/local for the new local flow. Home entry's separate release is in progress. Evidence: /home/paulca/.cache/oficina-keymail-oauth/release/verified.json.

Fable follow-ups tracked on Docs main tasks73–76: trusted-proxy rate limits, visible/no-auth-time email fallback, protocol-relative redirect refusal, expired auth-link/session sweep. They are not claimed fixed by this release.