Skip to content

Security: loopback CLI login bound to the approving browser - #22

Closed
ndbroadbent wants to merge 3 commits into
nathan/security-authzfrom
nathan/security-cli-loopback
Closed

ndbroadbent wants to merge 3 commits into
nathan/security-authzfrom
nathan/security-cli-loopback

Conversation

@ndbroadbent

Copy link
Copy Markdown
Member

This PR is stacked on #19. It fixes RG-10 (AUTH-2, CLI-2, CLI-3, MFA-9) from the 2026-10-09 audit.

Problem

CLI login was effectively a device flow with no user code, so whoever held the gateway-issued state got the 90-day session:

  • /auth/cli/complete never checked the code_verifier.
  • States never expired.
  • state and the OAuth code were logged in plaintext.
  • Nothing tied the approving browser to the polling CLI.

Anyone who could read the gateway logs, or get you to open their login link, could take over the session.

Fix (RFC 8252 loopback)

  1. The CLI generates the PKCE verifier and its own state, then listens on 127.0.0.1:<random port>.
  2. /auth/cli/start receives only the S256 challenge, the state and the loopback redirect URI. The redirect URI is strictly validated: http, loopback host, /callback.
  3. The browser that completes Google OAuth is bound with an HttpOnly cookie. The MFA form, MFA submit and return steps all require it.
  4. After MFA, the browser is redirected to the CLI's loopback listener with a single-use login code. The code is stored hashed, expires after 10 minutes, and is consumed atomically.
  5. The CLI redeems the code and its verifier at /auth/cli/complete. The polling path and the state-as-credential path are removed.

Also in this PR:

  • The MFA page shows the initiating IP and device.
  • A completed CLI login sends a notification.
  • state, code and login_code are redacted from request and audit logs.
  • The CLI only opens https auth URLs. On Windows it uses rundll32 instead of cmd /c start, which split URLs on &.

Testing

  • Gateway: tests for challenge mismatch, single-use and expired login codes, redirect_uri validation, and redaction.
  • CLI: tests for the loopback listener (binds to 127.0.0.1, ignores a wrong state, times out).
  • Web: MFA-challenge page tests, and E2E updated for the loopback flow.
  • E2E harness fixes:
    • The simulated browser stays on the gateway hostname, because the binding cookie is host-scoped.
    • Commands now get /dev/null as stdin. env set reads a non-terminal stdin until EOF, so an inherited pipe hung the suite.
  • Local results: task go:test, task web:test and lint all pass, web E2E is 56/56, and CLI E2E is green.

Deploy note

The login protocol changes, so after deploying a gateway with this change you need a new CLI build to log in to it. CircleCI uses API tokens rather than login, so it's unaffected.

The CLI login used to be a device flow without a user code: whoever
held the gateway-issued `state` got the 90-day session.
/auth/cli/complete never checked the code_verifier, states never
expired, `state` and the OAuth code were written to the request log,
and nothing tied the browser that approved the login to the CLI that
polled. A log reader or anyone who got a user to open their login link
could take over the session.

Now:
- The CLI generates the PKCE verifier and its own state, listens on
  127.0.0.1 with a random port, and sends only the S256 challenge,
  state and loopback redirect URI to /auth/cli/start.
- The browser that completes Google OAuth is bound to the login with
  an HttpOnly cookie. The MFA form, MFA submit and return steps all
  require it, so a third party holding the state can't use it or burn
  the user's MFA attempts.
- After MFA, the browser is redirected to the CLI's loopback listener
  with a single-use login code (stored hashed, 10-minute TTL, consumed
  atomically). /auth/cli/complete needs the code AND the verifier. The
  polling endpoint and the old state-as-credential path are gone.
- The MFA page shows the initiating IP and device name. A completed
  CLI login sends a notification.
- state, code and login_code query params are redacted from request
  and audit logs.
- The CLI only opens https auth URLs, and on Windows uses rundll32
  rather than `cmd /c start` (which split URLs on '&').

E2E harness: the simulated browser stays on the gateway's hostname
(the binding cookie is host-scoped, as in a real browser), and
commands run with stdin from /dev/null. `env set` reads extra input
from a non-terminal stdin until EOF, so an inherited open pipe hung
the suite.
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 953f1311-edf7-441b-9a8e-2910ac51e260

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@deepsource-io

deepsource-io Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in d3db28f...084c03e on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
JavaScript Oct 9, 2026 11:09p.m. Review ↗
Go Oct 9, 2026 11:09p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

# Conflicts:
#	scripts/lib/cli-e2e/cli_helpers.sh
#	web/e2e/cli-login-webui.spec.ts
Findings from the PR #22 review:

- A first factor enrolled during an enrollment-required CLI login only
  completes the login if the browser that approved it verified its own
  session after the enrollment.
- The Google code is exchanged in the callback and the browser is bound
  only after it succeeds, so a junk-code callback can't claim the login,
  and the Google code is no longer stored.
- Failures after binding, provider errors and exchange failures go back to
  the CLI's loopback listener, so the CLI exits instead of waiting out its
  timeout. "Cancel Login" now cancels on the server (POST /auth/cli/cancel)
  and tells the CLI.
- The CLI always prints the login URL, accepts any https identity provider,
  and reports version skew clearly in both directions.
- Login codes are issued once; the CLI-login email dedup uses the client IP
  instead of the client-supplied device name.
- Docs describe the loopback flow, the same-machine requirement (ssh -L for
  remote hosts) and the CLI upgrade needed after a gateway upgrade.
@ndbroadbent

Copy link
Copy Markdown
Member Author

Merged to main as part of #25 (combined security audit release).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant