Skip to content

Security audit fixes (combines #19–#24) - #25

Merged
ndbroadbent merged 37 commits into
mainfrom
nathan/security-audit-release
Oct 10, 2026
Merged

ndbroadbent merged 37 commits into
mainfrom
nathan/security-audit-release

Conversation

@ndbroadbent

Copy link
Copy Markdown
Member

Combines the 2026-10-09 security audit fixes and their review rounds into one release. It supersedes #19, #20,
#21, #22, #23 and #24; each of those PRs describes its own change in detail, and every one went through a
multi-area review whose findings are fixed here.

What's in it

  • Security: authorize every API route, close MFA bypass, scope API tokens, harden proxy #19 Authorization:
    • every authenticated API route has a declared policy, and unmapped routes are denied (a test walks the router);
    • RBAC uses each caller's current roles, and API tokens are capped by their owner's role;
    • the MFA bypass for pending sessions is closed;
    • the proxy forwards only allowlisted headers and query parameters;
    • privileged run options are admin-only;
    • production safety checks, OAuth identity checks and Sentry scrubbing.
    • Review fixes:
      • non-admin self-service (own profile, sessions, activity and API tokens; team directory);
      • backup codes in the web MFA form;
      • CLI login also verifies the browser session;
      • env-change refusals name the key;
      • first MFA enrollment resets other sessions' MFA state.
  • CLI: focus the PIN field in the macOS security key dialog #20: the macOS PIN dialog takes keyboard focus.
  • Security: bind deploy approvals to the approved commit and artifact #21 Deploy approvals:
    • each approval is bound to the approved commit, its uploaded archive and pinned image repositories;
    • approved commands fail closed;
    • GitHub and CircleCI verification is tightened;
    • no tokens in job arguments.
    • Review fixes:
      • every service needs an image pattern;
      • settings values are shape-checked;
      • the CircleCI auto-approve job re-checks the approval;
      • the repository check fails closed.
  • Security: loopback CLI login bound to the approving browser #22 CLI login:
    • RFC 8252 loopback flow with PKCE, bound to the approving browser;
    • single-use, hashed login codes.
    • Review fixes:
      • Google code exchanged before binding;
      • failures and Cancel reach the CLI;
      • enrollment during login must happen in the bound browser;
      • version-skew messages;
      • updated docs.
  • Security: server-side WebAuthn challenges; verify gateway TLS in the CLI #23 WebAuthn and CLI TLS:
    • server-side, single-use, session-bound WebAuthn challenges;
    • sign counter enforced atomically;
    • the CLI verifies gateway TLS and refuses plain http (except loopback).
    • Review fixes:
      • CLI-login WebAuthn bound to the browser session;
      • proxy env ignored for gateway connections;
      • no token in URL errors;
      • registration tests.
  • Harden CI and release supply chain #24 Supply chain:
    • SHA-pinned actions and pinned, checksum-verified installers;
    • tag-only releases with SLSA attestations;
    • non-root, digest-pinned image;
    • daily security scan and Dependabot.
    • Review fixes:
      • deploy docs use scripts/deploy_all.sh, which runs migrations;
      • wget healthchecks;
      • paginated check wait;
      • prerelease tags don't move :latest.

Deploy notes

  • Migrations:
    • 20261009000000_cli_login_loopback: in-flight CLI logins are dropped.
    • 20261009120000_webauthn_challenges.
    • Deploy with scripts/deploy_all.sh (staging → eu → us), which migrates before promoting.
  • New CLI required: CLI login changed, so install the new rack-gateway CLI after the gateway is upgraded. CI uses API tokens and is unaffected.
  • Right after each rack is deployed, set service_image_patterns for the docspring app ({"*": "docker\\.io/docspringcom/app:{{GIT_COMMIT}}-amd64"}). Approval-bound CI builds are refused until it's set. Don't set it before deploying: the old gateway requires a git-sha once patterns exist, and CI sends none.
  • After the old version is drained: remove circleci_token/github_token from river_job.args and rotate both tokens.

Verification

Each original PR's checks passed on its own branch. On this combined branch, task ci (lint, duplication, file length, shellcheck, build, Go tests, govulncheck, web tests, mock-OAuth tests, CLI E2E and web E2E) passed locally. Results: Go 847 tests + 18 integration, web 109, mock-OAuth tests, CLI E2E green, web E2E 60/60.

…issions

The authenticated /api/v1 group only checked login and MFA. Admin
handlers (users, API tokens, settings, sessions, audit logs) never
checked roles, so any logged-in user or API token, including the
CircleCI token, could create admins, mint convox:*:* tokens, or turn
off deploy approvals and MFA. The checks were lost in the gin rewrite.

- New Authorize middleware runs after Authenticated. It enforces the
  access policy declared for every route in rbac's route table and
  denies routes without a policy. Admin routes require their gateway
  permission. Self-service routes (own MFA, /info, settings reads)
  are open to any logged-in human.
- API tokens are allowed only on the routes the CLI uses in CI: /info,
  /rack, creating a deploy approval request and reading one by ID.
  Tokens can no longer approve deploys or call any admin endpoint.
- rbac.Manager.Authorize(principal, permission) replaces Enforce,
  EnforceUser and EnforceForAPIToken. Humans are checked against the
  roles on their current DB record, so demotion and deletion apply on
  the next request instead of after a restart (Casbin user groupings
  were only ever added, never removed). API tokens need the
  permission themselves AND their owner's current role must allow
  it. Suspended or locked users and their tokens are denied.
- Handler and proxy checks (env reads/writes, secret masking, deploy
  approval view/create, Slack) now authorize the real caller. Before,
  an admin-owned token was treated as the admin.
- Audit logs get their own gateway:audit_log:read permission instead
  of borrowing deploy_approval_request:read. Listing approval
  requests and their audit logs requires approver permission,
  matching the handlers. Admin role gains security:*:*, which the
  rack TLS cert refresh route requires.
- Tokens of locked owners are rejected at authentication. Token
  creation honours expires_at, which the CLI already sends but the
  server ignored. Creating a token for an unknown user returns 404
  instead of dereferencing nil.
- Removed dead code: RequireRole, Authenticated's unused rbac
  parameter, and the header-based env permission check that nothing
  called (the proxy already strips Env headers).

routes/authorization_test.go exercises the real router as viewer and
deployer sessions, a viewer token, the production-style admin-owned
cicd token and an admin-owned wildcard token, and asserts every admin
endpoint refuses them.
A session that had only passed Google login could call the MFA
enrollment endpoints (MFANone), enroll its own TOTP and be marked
MFA-verified, then delete the user's real factors and mint tokens.
TOTP enrollment also replaced the user's backup codes and returned
fresh ones, so an attacker didn't even need to enroll.

- New RequireVerifiedMFASession middleware: a session whose user must
  complete an MFA challenge can only reach mfa/status, mfa/verify,
  the WebAuthn assertion endpoints and /info until it verifies. It
  may also complete the challenge inline with an MFA header (used by
  the SPA step-up dialog).
- The same rule applies on /rack-proxy, so a pending web session token
  can't be replayed as a Bearer credential.
- Adding a factor when already enrolled requires a recent step-up;
  first-time enrollment is unchanged.
- Enrollment no longer replaces an enrolled user's backup codes
  (regeneration stays a separate step-up action).
- MFA factor enrollment (incl. YubiOTP), deletion and backup-code
  regeneration now write DB audit events.
PUT /api/v1/apps/:app/env only validated the submitted key, then joined
values with newlines for the rack API. A value like
"x\nDATABASE_URL=postgres://evil" injected a second line that Convox
applied (last duplicate wins). That bypassed protected env vars and the
secrets:set permission, and the audit log only recorded the harmless key.

MergeEnv now requires keys to match [A-Za-z_][A-Za-z0-9_]* and rejects
CR, LF and NUL in values. The handler returns 400 with an explanation.
The proxy copied every client header to the rack except a five-entry
denylist. The rack trusts several of those headers because the gateway
authenticates as admin with the rack password:
- Image/Volumes/Privileged on a process run let ops or an approved CI
  token start an arbitrary image with host paths mounted
- X-Convox-Actor forged the rack's own audit trail
- X-Convox-TID switched tenant namespace resolution

- Client headers now pass through an explicit allowlist derived from
  the Convox SDK (sdk.Client.Headers, stdsdk.Client.Request) and the
  header tags on structs.LogsOptions, ProcessExecOptions,
  ProcessRunOptions and ObjectOptions. Credentials, cookies, actor and
  tenant overrides, X-Forwarded-*, hop-by-hop headers,
  Accept-Encoding, env headers and gateway-internal headers are
  dropped, on both HTTP and WebSocket requests.
- Run options that escape the release image or scheduling
  constraints (Image, Volumes, Privileged, Node-Labels, Node-Affinity,
  Run-Tolerations, System-Critical, Run-Annotations, Run-Labels) need
  the new convox:process:run_privileged permission, which only the
  admin wildcard covers. API tokens can never use them. Requests
  without that permission get 403 and never reach the rack.
- The gateway sets X-Convox-Actor to the user's email, or
  token:<name> for API tokens.
- New StripInternalHeaders middleware runs first and removes
  client-supplied X-Audit-Resource, X-Release-Created,
  X-Original-Path, X-User-*, X-API-Token-*, X-Auth-Source,
  X-RBAC-Decision and X-Rack-* headers, which the audit logger and
  request logger read.
- buildTargetURL escapes the authorized path, so %3F/%23 can't
  truncate the route the rack sees. Paths with control characters,
  '?', '#' or dot segments are rejected before route matching.
- The WebSocket dial follows redirects only on the rack's own host,
  so the rack credential is never sent anywhere else.
…oduction

Sentry was initialised with SendDefaultPII=true, so every 5xx event
carried the Authorization header (session or API token plus inline MFA
code) and session cookies. PII collection is now off, and a BeforeSend
hook strips everything except a small header allowlist, plus cookies,
query strings, request bodies and env.

The gateway now refuses to start when its database is marked
production while any test-only switch is set: DEV_MODE, E2E_TEST_MODE
(skips WebAuthn assertion checks), AWS_ENDPOINT_URL_S3 or
POSTMARK_API_BASE. GOOGLE_OAUTH_BASE_URL must be https there.
Development and E2E databases are unaffected.
…okens

- Pin every third-party action to a full commit SHA (newest release
  within the current major), with the version in a comment.
- Install Task, the Convox CLI and TruffleHog from pinned releases with
  SHA-256 verification instead of piping install scripts (including
  golangci-lint's install.sh from master) into sh. The golangci-lint
  action already installs the pinned version and verifies the config
  schema, so the separate install and config-verify steps are gone.
  govulncheck is pinned to v1.8.0.
- ci.yml and e2e.yml run with contents: read. The docs workflow gives
  pages/id-token write only to the deploy job, pins Bun and installs
  with --frozen-lockfile.
- New daily Security Scan workflow: govulncheck, bun audit (high+) for
  web, and a Trivy scan of the published image, so new advisories
  surface without a push (CI was red for a month unnoticed).
- Dependabot for gomod, bun (web, docs, mock-oauth), GitHub Actions and
  Dockerfiles, weekly with grouped minor/patch updates.
…curl

- Pin oven/bun, golang and alpine base images by digest (runtime moves
  from alpine:latest to alpine:3.24.2) in all Dockerfiles, including
  mock-oauth's floating oven/bun:1-alpine.
- The production image runs as UID/GID 10001. Binaries, web assets and
  start-gateway.sh stay root-owned and read-only; the gateway only
  writes temp files under /tmp.
- curl is no longer installed in the gateway images. The compose
  healthcheck uses busybox wget, which is already in alpine.
- Fix the commit hash in the gateway binary: the build stage read an
  unset COMMIT_HASH arg, so every image reported CommitHash=unknown. It
  now uses the required COMMIT_SHA build arg that CI already passes.
- ID tokens must have email_verified=true.
- When GOOGLE_ALLOWED_DOMAIN is set, the hosted-domain (hd) claim must
  match it as well as the email domain (case-insensitive). Before, a
  bare suffix compare on the email was the only check.
- The ID token verifier accepts RS256 only. Google never uses HS256,
  and allowing it only matters if the issuer can be swapped.
- GOOGLE_ALLOWED_DOMAIN is required outside DEV_MODE. An empty value
  used to let any Google account sign in.
- The mock OAuth server now emits hd like Google does for Workspace
  accounts.
- Listing deploy approval requests and reading a request's audit trail
  were mapped to deploy_approval_request:approve, which carries
  MFAAlways, so every page load demanded a fresh MFA code. They now
  use the read-level :list permission (admin-only); the handlers
  still require approver permission. A test asserts no GET route can
  require MFAAlways.
- A 403 from the Authorize middleware names the missing permission,
  e.g. "insufficient permissions: requires convox:env:read".
- E2E login helper: when login lands on the MFA challenge, complete it
  with a TOTP code like a user would. Enrolling TOTP directly in the
  DB for an already logged-in user now also marks that session
  MFA-verified, matching a real UI enrollment (step-up stays unset).
- cli-login-webui clears TOTP replay state before generating its
  approve code, so another test on the same shard can't have used
  the time step already.
- CLI E2E expectations updated for the new permission error message.
When the CLI runs in the background (e.g. from an agent), osascript isn't
the frontmost app, so the dialog appeared without keyboard focus and the
PIN field had to be clicked before typing. The script now runs
`activate` first, which brings the dialog to the front with the field
focused. No extra macOS permissions are needed.
An approval used to authorize "a build" for the token and app, so a
compromised CI job could ship any tarball or image under it: git-sha was
optional (DocSpring CI never sends one), the build's archive wasn't
compared with the upload, and image patterns were optional and unanchored.

Token builds under an approval now:
- must use the archive uploaded under that same approval, chosen
  deterministically (no "most recent approved" lookup);
- take the commit from the approval record, never from the client. A
  git-sha, if sent, must equal it, and approvals need a full 40-hex SHA;
- may only reference pre-built images tagged <sha> or <sha>-<suffix>.
  Services built from source are rejected;
- require service_image_patterns, so the image repository is pinned
  too. Patterns are anchored and the substituted commit is regex-escaped.

Also:
- approved_deploy_commands fails closed: an empty list allows no
  commands under an approval. The CLI E2E seed stored it as
  {"commands": [...]}, which was silently read as empty and so allowed
  anything; it now uses the plain array production uses.
- GitHub verification: "branch" mode requires the commit to be on the
  feature branch (compare status behind/identical, not any 200),
  "latest" mode compares full SHAs, and URL segments are path-escaped.
- CircleCI auto-approve checks the workflow's pipeline revision and
  repository against the approval (and rejects fork pipelines) before
  approving the hold job. workflow_id must be a UUID.
- CircleCI and GitHub API tokens are no longer stored in River job
  args; workers read them from config. The Jobs API redacts token-,
  secret-, password- and key-like arg keys.
The redirect check only compared hosts, so a wss -> ws redirect on the
rack host would resend the rack Basic credential in cleartext. The
redirect must now keep both the host and the scheme of the configured
rack URL.
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.
WebAuthn (server):
- Challenges used to round-trip through the client as session_data and
  were trusted on the way back. One captured assertion could be replayed
  as a step-up proof indefinitely, and a zeroed Expires skipped
  go-webauthn's expiry check.
- Challenges now live in a webauthn_challenges table, bound to the user
  and the session that started the ceremony. They are single-use
  (DELETE ... RETURNING), expire after a few minutes, and the client only
  gets an opaque ID back in the same session_data field. This covers
  login MFA, step-up, inline WebAuthn on the proxy, and enrollment.
- The credential sign counter is stored and enforced (clone detection).

CLI:
- Every proxied Convox command went through stdsdk's http.Client and
  websocket dialer, which set InsecureSkipVerify. A man-in-the-middle
  with any certificate got the session token and MFA proof from the
  Basic auth header. Both now verify certificates against the system
  roots, and plain http:// is refused except for loopback gateways.
- The rack-proxy URL's credentials are URL-encoded, so inline WebAuthn
  data can't break parsing (the parse error used to echo the token).
- Inline step-up WebAuthn derives the RP ID from the configured gateway
  host and rejects a different RP ID sent by the server.
- The Release workflow runs only for v* tags. workflow_dispatch is gone:
  it built whatever branch it was dispatched from, then pushed :latest
  and overwrote the newest release's assets. v* tags are admin-only via
  a repository ruleset.
- Per-job least-privilege permissions; the tag regex is anchored; tag
  values reach shell steps through env: instead of ${{ }} interpolation.
- Images are pushed as :vX.Y.Z, :<short-sha> and :latest, with SLSA
  build provenance attestations for the image and the CLI binaries
  (verify with `gh attestation verify`). setup-go caching is off in
  release jobs so a poisoned main-branch cache can't reach a release.
- convox.yml deploys an immutable tag instead of :latest. It's pinned
  to :e327add (v0.1.1, same digest as the current :latest), and
  scripts/bump-version.sh now sets it to :vX.Y.Z on each bump.
- The CLI reports its real version: ldflags set main.version /
  main.buildTime, not the non-existent main.Version / main.BuildTime.
- Go tooling installs are pinned rather than @latest.
- Docs (CLAUDE.md, README, deployment docs) describe the new process.
TruffleHog runs its auto-updater by default. In CI it tried to replace the
checksum-verified binary installed root-owned in /usr/local/bin and failed
("cannot move binary back"), failing the secret scan. A pinned binary
must not update itself anyway, so pass --no-update.
…role

Review fixes for PR #19:

- Refuse query parameters the Convox SDK never sends. The rack reads options
  from the query string as well as headers and the body, so `?command=`,
  `?env=` and `?manifest=` could override the exec command, release env and
  build manifest the gateway had checked. The web UI's scale request now
  sends `count` in the form body, like the SDK.
- deploy_with_approval no longer lets a token exceed its owner's current
  role.
- Env change refusals name the keys and the reason ("ADMIN_PASSWORD is a
  protected env var for docspring. Unprotect it in rack-gateway settings to
  change it.") instead of "You don't have permission to create releases."
  Removing a secret now needs secret:set, like changing one.
- Token names may not contain control characters (they become the
  X-Convox-Actor header).
- The production guard also refuses AWS_ENDPOINT_URL and the STS/KMS
  endpoint overrides.

Adds tests for token callers, the websocket header path and the query
allowlist.
Sessions created while a user had no factor are marked MFA-verified at
login. Once the user enrolls their first factor, those sessions must prove
it too, so first enrollment clears their MFA state; the session that
enrolled is re-verified. YubiKey enrollment now re-verifies its session
like TOTP and WebAuthn do.

Also adds tests that ID tokens are only accepted when signed with RS256
and that a locked owner's API tokens are rejected.
Review fixes for PR #19 (A03-F01, A03-F03):

- The MFA challenge page and step-up dialog have a "Use a backup code"
  mode. Codes are accepted in any case, with spaces or dashes. Now that a
  pending session can't enroll a new factor, this is the recovery path for
  a user who has lost their authenticator.
- Approving a CLI login with MFA also verifies the browser's web session
  for the same user, so the web UI doesn't ask for MFA again afterwards.
- /api/v1/info reports mfa_pending, and the web UI sends a pending session
  to the challenge page instead of letting every request fail.
- When several requests need step-up at once, the dialog now settles all
  of them (retry after verifying, reject on cancel) instead of leaving all
  but the last one hanging.
Review fixes for PR #19 (A01-F01, A01-F02, A06-F01, A06-F04, A06-F05).
Locking admin routes down removed access the viewer, ops and deployer roles
need. This restores it with ownership rules instead of admin-only routes:

- Every user can view their own profile, sessions and audit trail and sign
  out their own sessions (route-table self rule on /users/:email: exact
  email match, people only, never API tokens). Other users' records still
  need gateway:user:read / user:update / audit_log:read.
- The team directory (GET /users) needs the new gateway:user:list, which
  every role has. Lock and MFA details are only returned with user:read.
- API tokens: gateway:api_token:read/create/update/delete cover the
  caller's own tokens; the new gateway:api_token:manage (admin) covers
  everyone's. Deployers can issue CI tokens for themselves. A token's
  permissions must be within its owner's current role at create and
  update time, as well as at request time.
- /api/v1/info returns the caller's permissions so the web UI shows only
  what they can use; navigation and pages follow them.
- Fixes the role editor, which called an unregistered route.
- CLI: deploy-approval approve and test-auth handle API tokens clearly.
- A test walks the real router: every /api/v1 route is public by design or
  has a policy, and every policy has a route.
- Docs updated for the new permissions, token rules, self-service, the
  production safety check and the proxy header and query allowlists.
…alls

.claude/ holds local agent worktrees (several GB), which made the CLI E2E
deploy tarball exceed the gateway's 500MB manifest-check limit and would
bloat any Docker build context. CLI commands in the E2E harness now get
</dev/null so a command that reads stdin (env set) can't hang the run.
- The OTP, MFA and backup code inputs take focusOnMount instead of an
  autoFocus prop: they focus their first field from an effect and never
  used the native attribute.
- Small cleanups DeepSource flagged: no redundant undefined arguments, the
  per-user audit log call uses the generated client, and the scale
  mutation is no longer async without an await.
- Naming yourself as a token's owner is matched case-insensitively.
- The redirect-after-login E2E spec enforces MFA for its user, so it no
  longer depends on global settings left by earlier tests in the worker.
Findings from the PR #21 review:

- Approval-bound builds need an image pattern for every service (its own
  or "*"). Per-service patterns used to leave unlisted services' image
  repository unpinned.
- The settings API rejects wrongly shaped values: approved_deploy_commands,
  protected_env_vars and secret_env_vars must be JSON arrays of strings and
  service_image_patterns an object of compilable patterns. A wrong shape
  used to read back as an empty list, which now allows no commands.
- The CircleCI auto-approve job re-checks the deploy approval on every
  attempt and is cancelled once it is rejected, expired or deployed, so a
  retry can't approve the hold afterwards.
- The pipeline repository check fails closed when the repository can't
  be determined.
- Docs and settings help text describe the new rules; an upgrade note
  covers the rollout order (set service_image_patterns right after
  deploying), approved command shapes and token rotation.
- Tests: pattern coverage, prefix git-sha, DocSpring's real manifest
  shape, listed and unlisted approved commands, approval re-check,
  unknown repository, empty revision, job-arg wiring and redaction.
- The E2E deploy approval seed builds its commit SHA instead of a 40-hex
  literal, and the CLI E2E image pattern pins the repository.
# 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.
Findings from the PR #23 review:

- CLI connections to the gateway ignore HTTP(S)_PROXY, so websocket
  commands no longer break behind a proxy, and stay on HTTP/1.1.
- Plain http to a non-loopback gateway is refused where the gateway URL is
  loaded, covering MFA calls and all native CLI requests, not only the
  Convox SDK.
- The proxied gateway URL is parsed properly (query, fragment and user
  info are rejected) and errors never include the session token.
- The WebAuthn sign counter is compared and stored in one guarded UPDATE;
  a counter that doesn't advance is treated as a cloned key.
- Tests for WebAuthn registration (session binding, expiry, replay, user
  verification) and for the exact Authorization header the CLI sends.
- Docs: PIN/biometric requirement, 5-minute single-use challenges, one
  key touch per command, CLI TLS. Removes the outdated WEBAUTHN.md.
Findings from the PR #24 review:

- Release docs and the bump script point at scripts/deploy_all.sh, which
  runs database migrations before promoting; production never migrates on
  startup, so plain `convox deploy` skipped them. The public upgrade guide
  no longer claims migrations run automatically.
- Docker docs use wget healthchecks; the image no longer ships curl.
- The release workflow's check wait paginates check runs, so the daily
  security scan can't push the required checks off the first page.
- Prerelease tags don't move :latest.
- The CLI-login MFA submit consumes the WebAuthn challenge with the bound
  browser's own web session, and challenges started by a session can no
  longer be redeemed by a caller that names no session.
- `rack-gateway login` refuses plain http to a non-loopback gateway before
  sending anything to it.
- One isLoopbackHost helper (both branches declared it).
@coderabbitai

coderabbitai Bot commented Oct 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

This review includes 300 billable files and costs up to $75.00.

View limit details

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: b6ab51d6-6915-4637-8060-8b6d936568fc

📥 Commits

Reviewing files that changed from the base of the PR and between 3871209 and e1190dd.


⛔ Files ignored due to path filters (3)
  • internal/gateway/openapi/generated/swagger.json is excluded by !**/generated/**
  • web/src/api/types.generated.ts is excluded by !**/*.generated.*
  • web/src/lib/generated/mfa-requirements.ts is excluded by !**/generated/**

📒 Files selected for processing (300)
  • .dockerignore
  • .github/actions/install-convox/action.yml
  • .github/actions/install-task/action.yml
  • .github/dependabot.yml
  • .github/wait-for-checks.js
  • .github/workflows/ci.yml
  • .github/workflows/docs.yml
  • .github/workflows/e2e.yml
  • .github/workflows/release.yml
  • .github/workflows/security-scan.yml
  • CLAUDE.md
  • Dockerfile
  • Dockerfile.gateway-dev
  • Dockerfile.mock-convox
  • README.md
  • cmd/mock-convox/handlers_apps.go
  • convox.yml
  • docker-compose.yml
  • docs/legacy/DEPLOY.md
  • docs/legacy/WEBAUTHN.md
  • docs/src/content/docs/configuration/environment-variables.mdx
  • docs/src/content/docs/deployment/docker.mdx
  • docs/src/content/docs/deployment/production-checklist.mdx
  • docs/src/content/docs/development/api-reference.mdx
  • docs/src/content/docs/getting-started/architecture.mdx
  • docs/src/content/docs/integrations/deploy-approvals/circleci.mdx
  • docs/src/content/docs/integrations/deploy-approvals/index.mdx
  • docs/src/content/docs/integrations/deploy-approvals/workflow.mdx
  • docs/src/content/docs/operations/troubleshooting.mdx
  • docs/src/content/docs/operations/upgrades.mdx
  • docs/src/content/docs/security/authentication/api-tokens.mdx
  • docs/src/content/docs/security/authentication/index.mdx
  • docs/src/content/docs/security/authentication/oauth-flow.mdx
  • docs/src/content/docs/security/authentication/sessions.mdx
  • docs/src/content/docs/security/hardening.mdx
  • docs/src/content/docs/security/rbac/permissions.mdx
  • docs/src/content/docs/security/rbac/roles.mdx
  • docs/src/content/docs/user-guide/cli/authentication.mdx
  • docs/src/content/docs/user-guide/cli/index.mdx
  • docs/src/content/docs/user-guide/cli/mfa-verification.mdx
  • docs/src/content/docs/user-guide/mfa/webauthn.mdx
  • docs/src/content/docs/user-guide/web-ui/api-tokens.mdx
  • docs/src/content/docs/user-guide/web-ui/audit-logs.mdx
  • docs/src/content/docs/user-guide/web-ui/index.mdx
  • docs/src/content/docs/user-guide/web-ui/user-management.mdx
  • go.mod
  • internal/cli/CLAUDE.md
  • internal/cli/auth.go
  • internal/cli/cli_login.go
  • internal/cli/common.go
  • internal/cli/deploy_approvals_approve.go
  • internal/cli/deploy_approvals_approve_test.go
  • internal/cli/gateway_test_auth.go
  • internal/cli/login_loopback.go
  • internal/cli/login_loopback_test.go
  • internal/cli/mfa_helpers.go
  • internal/cli/mfa_rpid_test.go
  • internal/cli/mfa_verify.go
  • internal/cli/pinentry/pinentry.go
  • internal/cli/pinentry/pinentry_test.go
  • internal/cli/rack.go
  • internal/cli/sdk_tls.go
  • internal/cli/sdk_tls_test.go
  • internal/cli/types.go
  • internal/cli/utils.go
  • internal/gateway/CLAUDE.md
  • internal/gateway/app/app.go
  • internal/gateway/app/production_guard.go
  • internal/gateway/app/production_guard_test.go
  • internal/gateway/app/sentry.go
  • internal/gateway/app/sentry_test.go
  • internal/gateway/app/setup.go
  • internal/gateway/audit/logger.go
  • internal/gateway/audit/query_redaction_test.go
  • internal/gateway/audit/redaction.go
  • internal/gateway/auth/mfa/backup_codes.go
  • internal/gateway/auth/mfa/backup_codes_test.go
  • internal/gateway/auth/mfa/enrollment.go
  • internal/gateway/auth/mfa/totp.go
  • internal/gateway/auth/mfa/totp_test.go
  • internal/gateway/auth/mfa/webauthn.go
  • internal/gateway/auth/mfa/webauthn_assertion.go
  • internal/gateway/auth/mfa/webauthn_challenge_test.go
  • internal/gateway/auth/mfa/webauthn_registration_test.go
  • internal/gateway/auth/mfa/webauthn_test.go
  • internal/gateway/auth/mfa/webauthn_user.go
  • internal/gateway/auth/mfa/yubiotp.go
  • internal/gateway/auth/oauth.go
  • internal/gateway/auth/oauth_claims_test.go
  • internal/gateway/auth/oauth_signing_test.go
  • internal/gateway/auth/principal.go
  • internal/gateway/auth/service.go
  • internal/gateway/auth/service_test.go
  • internal/gateway/circleci/client.go
  • internal/gateway/circleci/pipeline_verification.go
  • internal/gateway/circleci/pipeline_verification_test.go
  • internal/gateway/config/config.go
  • internal/gateway/config/config_test.go
  • internal/gateway/db/cli_login.go
  • internal/gateway/db/commit_sha.go
  • internal/gateway/db/database.go
  • internal/gateway/db/database_test.go
  • internal/gateway/db/deploy_approval_requests_mutations.go
  • internal/gateway/db/deploy_approval_requests_queries.go
  • internal/gateway/db/mfa.go
  • internal/gateway/db/mfa_scan.go
  • internal/gateway/db/migrations/20261009000000_cli_login_loopback.sql
  • internal/gateway/db/migrations/20261009120000_webauthn_challenges.sql
  • internal/gateway/db/sessions_mutations.go
  • internal/gateway/db/sessions_test.go
  • internal/gateway/db/types.go
  • internal/gateway/db/webauthn_challenges.go
  • internal/gateway/envutil/envutil.go
  • internal/gateway/envutil/envutil_test.go
  • internal/gateway/github/client.go
  • internal/gateway/github/client_test.go
  • internal/gateway/github/verify_commit_test.go
  • internal/gateway/handlers/admin_audit.go
  • internal/gateway/handlers/admin_audit_user.go
  • internal/gateway/handlers/admin_jobs.go
  • internal/gateway/handlers/admin_tokens.go
  • internal/gateway/handlers/admin_tokens_helpers.go
  • internal/gateway/handlers/admin_tokens_scope.go
  • internal/gateway/handlers/admin_users_list.go
  • internal/gateway/handlers/api_env_test.go
  • internal/gateway/handlers/api_handler_env.go
  • internal/gateway/handlers/api_handler_info.go
  • internal/gateway/handlers/auth_cli.go
  • internal/gateway/handlers/auth_cli_browser_session.go
  • internal/gateway/handlers/auth_cli_complete.go
  • internal/gateway/handlers/auth_cli_loopback.go
  • internal/gateway/handlers/auth_cli_loopback_test.go
  • internal/gateway/handlers/auth_helpers.go
  • internal/gateway/handlers/auth_mfa_audit.go
  • internal/gateway/handlers/auth_mfa_enrollment.go
  • internal/gateway/handlers/auth_mfa_management.go
  • internal/gateway/handlers/auth_mfa_verification.go
  • internal/gateway/handlers/deploy_approval_admin.go
  • internal/gateway/handlers/deploy_approval_circleci_test.go
  • internal/gateway/handlers/deploy_approval_create.go
  • internal/gateway/handlers/deploy_approval_helpers.go
  • internal/gateway/handlers/deploy_approval_notification.go
  • internal/gateway/handlers/deploy_approval_requests_mfa_test.go
  • internal/gateway/handlers/deploy_approval_requests_test.go
  • internal/gateway/handlers/deploy_approval_validation.go
  • internal/gateway/handlers/dto.go
  • internal/gateway/handlers/integrations_slack_helpers.go
  • internal/gateway/handlers/integrations_slack_test.go
  • internal/gateway/handlers/job_args_redaction.go
  • internal/gateway/handlers/job_args_redaction_test.go
  • internal/gateway/handlers/settings.go
  • internal/gateway/handlers/settings_helpers.go
  • internal/gateway/handlers/testing_helpers_test.go
  • internal/gateway/jobs/circleci/approve.go
  • internal/gateway/jobs/circleci/approve_test.go
  • internal/gateway/jobs/client.go
  • internal/gateway/jobs/email/cli_login.go
  • internal/gateway/jobs/github/pr_comment.go
  • internal/gateway/jobs/github/pr_comment_test.go
  • internal/gateway/middleware/auth.go
  • internal/gateway/middleware/auth_test.go
  • internal/gateway/middleware/authorize.go
  • internal/gateway/middleware/debug_logging.go
  • internal/gateway/middleware/internal_headers.go
  • internal/gateway/middleware/internal_headers_test.go
  • internal/gateway/middleware/mfa.go
  • internal/gateway/middleware/mfa_pending.go
  • internal/gateway/middleware/mfa_stepup_webauthn_test.go
  • internal/gateway/proxy/approval_resolvers.go
  • internal/gateway/proxy/deny_test.go
  • internal/gateway/proxy/deploy_approval_binding_test.go
  • internal/gateway/proxy/deploy_approval_tracking_test.go
  • internal/gateway/proxy/deploy_approval_validation.go
  • internal/gateway/proxy/env.go
  • internal/gateway/proxy/env_denials.go
  • internal/gateway/proxy/env_filters.go
  • internal/gateway/proxy/env_test.go
  • internal/gateway/proxy/forward.go
  • internal/gateway/proxy/forward_headers.go
  • internal/gateway/proxy/forward_headers_test.go
  • internal/gateway/proxy/forward_helpers.go
  • internal/gateway/proxy/forward_query.go
  • internal/gateway/proxy/forward_query_test.go
  • internal/gateway/proxy/handler.go
  • internal/gateway/proxy/handler_test.go
  • internal/gateway/proxy/manifest_policy.go
  • internal/gateway/proxy/manifest_validation.go
  • internal/gateway/proxy/matrix_test.go
  • internal/gateway/proxy/mfa_verification.go
  • internal/gateway/proxy/token_caller_test.go
  • internal/gateway/proxy/token_permissions.go
  • internal/gateway/proxy/websocket.go
  • internal/gateway/rbac/action_string.go
  • internal/gateway/rbac/constants.go
  • internal/gateway/rbac/gateway_routes.go
  • internal/gateway/rbac/http_routes.go
  • internal/gateway/rbac/http_routes_test.go
  • internal/gateway/rbac/interface.go
  • internal/gateway/rbac/principal.go
  • internal/gateway/rbac/rbac.go
  • internal/gateway/rbac/rbac_test.go
  • internal/gateway/rbac/resource_string.go
  • internal/gateway/rbac/roles_config.go
  • internal/gateway/routes/authorization_test.go
  • internal/gateway/routes/main_test.go
  • internal/gateway/routes/mfa_pending_test.go
  • internal/gateway/routes/mfa_recovery_test.go
  • internal/gateway/routes/route_coverage_test.go
  • internal/gateway/routes/route_registration.go
  • internal/gateway/routes/routes.go
  • internal/gateway/routes/self_service_test.go
  • internal/gateway/routes/self_service_tokens_test.go
  • internal/gateway/routes/setup_helpers.go
  • internal/gateway/security/notifier.go
  • internal/gateway/settings/service_app.go
  • internal/gateway/settings/validate.go
  • internal/gateway/settings/validate_test.go
  • internal/gateway/testutil/webauthntest/registration.go
  • internal/gateway/testutil/webauthntest/webauthn.go
  • internal/gateway/token/service.go
  • internal/gateway/token/service_test.go
  • internal/integration/integration_test.go
  • mock-oauth/Dockerfile
  • mock-oauth/src/signing.ts
  • scripts/build-and-push.sh
  • scripts/bump-version.sh
  • scripts/install.sh
  • scripts/lib/cli-e2e/cli_helpers.sh
  • scripts/lib/cli-e2e/db.sh
  • scripts/lib/cli-e2e/stages.sh
  • scripts/lib/cli-e2e/suite.sh
  • taskfiles/Taskfile.go.yml
  • web/e2e/account-security.spec.ts
  • web/e2e/cli-login-webui.spec.ts
  • web/e2e/cli-loopback.ts
  • web/e2e/db.ts
  • web/e2e/helpers.ts
  • web/e2e/mfa-backup-codes.spec.ts
  • web/e2e/redirect-after-login.spec.ts
  • web/e2e/self-service.spec.ts
  • web/src/api/generated.ts
  • web/src/api/openapi.json
  • web/src/api/schemas/getAuthCliCallbackParams.ts
  • web/src/api/schemas/getAuthCliReturnParams.ts
  • web/src/api/schemas/getUsersEmailAuditLogsParams.ts
  • web/src/api/schemas/handlersCLILoginCancelRequest.ts
  • web/src/api/schemas/handlersCLILoginCompleteRequest.ts
  • web/src/api/schemas/handlersCLILoginRedirectResponse.ts
  • web/src/api/schemas/handlersCLILoginStartRequest.ts
  • web/src/api/schemas/handlersCLILoginStartResponse.ts
  • web/src/api/schemas/handlersCreateAPITokenRequest.ts
  • web/src/api/schemas/handlersUserInfo.ts
  • web/src/api/schemas/handlersVerifyWebAuthnAssertionRequest.ts
  • web/src/api/schemas/handlersWebAuthnAssertionStartResponse.ts
  • web/src/api/schemas/index.ts
  • web/src/app.tsx
  • web/src/components/account-security/enrollment-dialog.tsx
  • web/src/components/layout.tsx
  • web/src/components/mfa-input.tsx
  • web/src/components/mfa-verification-form.test.tsx
  • web/src/components/mfa-verification-form.tsx
  • web/src/components/mfa-verification-form/backup-code-form.tsx
  • web/src/components/mfa-verification-form/backup-code.test.ts
  • web/src/components/mfa-verification-form/backup-code.ts
  • web/src/components/mfa-verification-form/description.ts
  • web/src/components/mfa-verification-form/form-sections.tsx
  • web/src/components/mfa-verification-form/types.ts
  • web/src/components/navigation-items.test.ts
  • web/src/components/navigation-items.ts
  • web/src/components/otp-input.tsx
  • web/src/contexts/step-up-context.tsx
  • web/src/contexts/step-up-queue.test.ts
  • web/src/contexts/step-up-queue.ts
  • web/src/hooks/use-can.ts
  • web/src/lib/api.ts
  • web/src/lib/auth.ts
  • web/src/lib/get-current-user.test.ts
  • web/src/lib/mfa-challenge-redirect.test.ts
  • web/src/lib/mfa-challenge-redirect.ts
  • web/src/lib/permissions.test.ts
  • web/src/lib/permissions.ts
  • web/src/pages/account-security/use-account-security-mutations.ts
  • web/src/pages/app-services-page.test.tsx
  • web/src/pages/app-services-page.tsx
  • web/src/pages/app-settings-page.tsx
  • web/src/pages/app-settings/service-image-patterns-card.tsx
  • web/src/pages/audit-page.test.tsx
  • web/src/pages/audit-page.tsx
  • web/src/pages/cli-auth-success-page.tsx
  • web/src/pages/mfa-challenge-page.test.tsx
  • web/src/pages/mfa-challenge-page.tsx
  • web/src/pages/tokens-page.test.tsx
  • web/src/pages/tokens-page/index.tsx
  • web/src/pages/user-page.tsx
  • web/src/pages/user/header-section.tsx
  • web/src/pages/user/use-user-audit-logs.ts
  • web/src/pages/user/use-user-editing.ts
  • web/src/pages/users-page.test.tsx
  • web/src/pages/users-page.tsx
  • web/src/pages/users/users-table-row.tsx


  • 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 3871209...e1190dd 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:22p.m. Review ↗
Go Oct 9, 2026 11:22p.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.

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