Skip to content

fix(api): close admin auth bypass, scope runner tokens, gate repo access - #60

Merged
mariuspruvot merged 1 commit into
mainfrom
fix/security-hardening
Jul 30, 2026
Merged

fix(api): close admin auth bypass, scope runner tokens, gate repo access#60
mariuspruvot merged 1 commit into
mainfrom
fix/security-hardening

Conversation

@mariuspruvot

Copy link
Copy Markdown
Owner

Security and correctness fixes from the backend audit. Each item was verified against the code before changing it.

Security

Admin panel was unauthenticated by default. ENVIRONMENT defaults to "development", and the login accepted any password in that mode — so a self-hoster who never set the variable exposed GitHub users, installations and BYOK ciphertext at /admin. The bypass is removed, comparison is constant-time, and the panel refuses to mount when ADMIN_PASSWORD is unset. Writing a test for it caught a second bug in my own fix: compare_digest("", "") is True, so an unset password would have authenticated an empty field.

Runner containers got an over-privileged GitHub token. mint_installation_token sent no repositories/permissions, so the token carried every permission the App holds on every repo — injected as a plain env var into a container running claude --dangerously-skip-permissions over attacker-controllable PR content with open egress. It is now narrowed to the repo under review with contents/metadata/pull_requests: read. The API still mints its own unscoped token when it needs to write.

No per-repo authorization. create_container_session checked installation membership only, never repo_full_name — any org member could start a session on any repo the installation covered and stream its diff back, bypassing GitHub's per-repo permissions. verify_repo_access now asks GitHub with the caller's own token.

Credential columns were editable in SQLAdmin. column_details_exclude_list only hides fields on the detail page; the edit form rendered the Fernet ciphertext in a writable input. Added form_excluded_columns on both views and can_edit = False on BYOK.

Correctness

  • Dashboard was broken for non-admin members: both read-only installation routes required org-admin, so members saw an installation in the list then got 403 opening it. Now member-level; admin stays on BYOK and settings.
  • Base64 PEM support: every deployment guide tells operators to base64 the App private key (.env cannot hold multi-line values) but no decoding existed — following the docs broke all GitHub App auth. Raw PEM keeps working.
  • Six pre-pivot tables dropped: alembic upgrade head built a 12-table schema while create_all built 6, so tests never ran against the deployed schema, and the next --autogenerate would have emitted these drops as a surprise destructive diff.

Cleanup

  • CurrentUser = Annotated[GitHubUser, Depends(get_current_user)] replaces user=Depends(...) on every route: 19 noqa: B008 gone and user has a real type instead of implicit Any. Same for the webhook body.
  • RUF100 enabled so dead noqa cannot accumulate. Note for future audits: ruff check --select RUF100 on the command line replaces the rule selection, which makes every noqa look dead — that trap nearly cost us the B008 suppressions.
  • Four test files moved off unittest.mock onto a hand-written GitHubDouble (httpx.MockTransport, routed by URL) that records requests, so tests can assert on what was actually sent.

Verification

  • 308 tests pass (was 282), coverage 77% → 79%
  • ruff check + ruff format --check clean on src/tests/alembic, mypy clean
  • New migration applied against a real Postgres: schema verified to contain exactly the 6 live tables

Security fixes from the backend audit:

- admin: ENVIRONMENT defaults to "development", where the login accepted ANY
  password — a deploy that forgets the variable exposed user rows and BYOK
  ciphertext at /admin. The bypass is gone, comparison is constant-time, and
  the panel refuses to mount without ADMIN_PASSWORD. BYOK/user credential
  columns are now excluded from the edit forms, not just the detail view.
- containers: the runner received an installation token scoped to every repo
  and every App permission, injected as an env var into a container running
  Claude Code over attacker-controllable PR content. It is now narrowed to the
  repo under review with read-only scopes; the API keeps minting its own
  unscoped token for posting comments.
- containers: session creation only checked installation membership, so any
  org member could start a session on any repo the installation covered and
  stream its diff back. verify_repo_access now asks GitHub with the caller's
  own token.
- installations: the two read-only routes required org-admin, so ordinary
  members saw an installation in the list and got 403 opening it. They now use
  member-level access; admin stays on BYOK and settings.
- config: GITHUB_APP_PRIVATE_KEY accepts base64-encoded PEM, which every
  deployment guide already told operators to use — .env cannot hold multi-line
  values. Raw PEM keeps working.
- alembic: drop the six pre-pivot comprehension tables. Migrations built a
  12-table schema while tests built 6, and the next autogenerate would have
  emitted these drops itself.
- routers: replace `user=Depends(get_current_user)` with a `CurrentUser`
  Annotated alias — removes 19 noqa: B008 and gives `user` a real type. RUF100
  is enabled so dead noqa cannot accumulate again.
- tests: hand-written GitHubDouble (httpx.MockTransport, routed by URL)
  replaces unittest.mock patching in four files; new tests cover admin login,
  PEM normalization, repo access and token scoping.

282 -> 308 tests, coverage 77% -> 79%, ruff and mypy clean.
@helprs-prod

helprs-prod Bot commented Jul 30, 2026

Copy link
Copy Markdown

helPRs session created for this PR.

Skill: challenge-me | Open session

@mariuspruvot
mariuspruvot merged commit 380bc65 into main Jul 30, 2026
10 checks passed
mariuspruvot added a commit that referenced this pull request Aug 1, 2026
FERNET_KEY encrypts the GitHub and Claude credentials in the database, and
replacing it made every one of them unreadable — so in practice it could
not be replaced, which is a bad property for a key whose exposure is the
whole reason it exists.

The crypto helpers now take an ordered keyset and go through MultiFernet:
encrypt with the first key, decrypt with whichever one matches. A new
FERNET_KEY_FALLBACKS setting holds retired keys, so a rotation is a deploy
rather than an outage.

Fallbacks are validated at boot alongside the primary key. A malformed
retired key must not surface at first decrypt — the row that would break is
exactly the credential the rotation is trying not to lose.

`helprs.scripts.rotate_credentials` finishes the job by re-encrypting stored
rows under the primary key, using MultiFernet.rotate (no plaintext needed).
Without it the fallback list grows forever and a "retired" key stays as
sensitive as the live one. It rewrites in a single transaction, is safe to
re-run, and a row no configured key can read is reported and left intact
rather than overwritten.

The keyset is typed `list[str]` rather than `Sequence[str]` deliberately:
`str` satisfies `Sequence[str]`, so a caller still passing a single key
would have type-checked cleanly. mypy caught all six call sites this way.

`settings.fernet_keys` is a plain property, not a computed_field — the
latter would put every key back into model_dump() output and undo the
SecretStr work from #65.

Docs: a rotation runbook in self-hosting.md, the new setting in
.env.example, and the stale "in development mode, any password is accepted"
line next to ADMIN_PASSWORD removed — that bypass was fixed in #60.
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