Skip to content

fix: resolve every open bug-labelled issue (#257, #258, #259, #263, #267, #268) - #297

Merged
richardmhope merged 13 commits into
mainfrom
fix/bug-sweep
Aug 1, 2026
Merged

fix: resolve every open bug-labelled issue (#257, #258, #259, #263, #267, #268)#297
richardmhope merged 13 commits into
mainfrom
fix/bug-sweep

Conversation

@richardmhope

Copy link
Copy Markdown
Collaborator

Resolves every open issue labelled bug — one commit per issue, each self-contained.

Security

Closes #258 — reset-link poisoning. build_link fell back to request.base_url when PUBLIC_BASE_URL was blank, i.e. to the client-supplied Host. Since the reset request is unauthenticated, an attacker could make the real deployment mail a victim a genuine link — carrying a valid single-use reset token — pointing at the attacker. The request is no longer consulted at all; failure is closed at three levels (startup guard, admin-API guard, 503 at the endpoint) so the feature can never silently degrade to a request-derived link.

Closes #257 — OIDC unverified-email squatting. Every adapter computed email_verified; nothing read it. Collision handling stopped an unverified address taking an existing row but not pre-claiming a future one — locking the real person out and collecting the enrolments facilitators make by email. JIT creation now requires the assertion and audits the refusal; OIDC_ALLOW_UNVERIFIED_EMAIL is the documented escape hatch.

Closes #259 — secret exfiltration via sink re-pointing. Three admin-editable host fields decided where an env-only secret went, each paired with a "test" button. New sink_pinning refuses a runtime edit that moves the destination origin while the matching credential is set (422), following the llm_settings_service.validate_selection precedent. Clearing a sink and editing the path on the pinned host stay allowed; every destination move now emits a critical audit event.

Closes #267 — unguarded release publish. Branch protection does not cover tag pushes, so a v* tag on any commit published a signed, provenance-attested image. A verify job now requires the commit to be on main with a green ci.yml run for that exact SHA.

Correctness / performance

Closes #268 — half-backed-up state. The CronJob dumped Postgres only, so a restore yielded a database whose injects referenced attachment files that no longer existed. Database and the app-uploads PVC are now captured in one job under one timestamp, with a podAffinity rule for the RWO claim.

Closes #263 — N+1 in three list endpoints. Facilitator responses, inject comments, and the participant inject list each resolved per row. All three now cost a fixed number of queries; new regression tests assert the statement count does not move when the row count six-times, and all three fail against the previous code (39 statements vs 9).

Verification

  • uv run pytest — 692 passed, 2 skipped (the Playwright suite, which skips without a server on :8765; CI runs it).
  • ruff check, pyright app/ (0 errors), bandit (clean — one commit renames the sink_pinning kwargs that tripped B106's heuristic), uv lock --check, zizmor .github/workflows/, actionlint.
  • Compose/k8s defaults leave SMTP_HOST empty, so the new startup guard does not trip existing deployments.

🤖 Generated with Claude Code

richardmhope and others added 13 commits August 1, 2026 09:49
`build_link` fell back to `request.base_url` whenever `PUBLIC_BASE_URL` was
blank — i.e. to the client-supplied `Host` header. Because
`POST /api/auth/password-reset/request` is unauthenticated, an attacker could
request a reset for a victim with `Host: attacker.example` and the real
deployment would mail the victim a genuine link carrying a valid single-use
reset token pointed at the attacker. The raw token is the entire
authorization, so clicking it is account takeover. Invite links shared the
builder.

The request is now never consulted: `build_link(path, token)` roots every link
at the configured base and `link_base()` returns None when unset. Fail closed
at three levels — startup refuses `SMTP_HOST` without `PUBLIC_BASE_URL`, the
admin API refuses to enable email while it is blank, and the reset/invite
endpoints return 503 rather than mailing a request-derived link.

Closes #258

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every adapter computed `email_verified` — Entra even handles `xms_edov` and
demotes the mutable `preferred_username` fallback — but nothing read it.
`provision_oidc_user` JIT-created the local `User` with the asserted email
regardless. On an IdP where a user can set an address before verifying it, an
attacker signs in as `victim@corp.com` and takes the email column: the real
person is locked out of both registration and their own later SSO login, and
facilitators — who enrol from a picker showing only email and display name —
hand the attacker that team's injects and communications.

The collision checks above only stop an unverified address *taking* an existing
row; nothing stopped it pre-claiming a future one. JIT creation now requires an
IdP-asserted verified email and audits the refusal like the collision denial.
`OIDC_ALLOW_UNVERIFIED_EMAIL` (env-only, default false) restores the previous
behaviour for an IdP that genuinely never emits the claim.

Closes #257

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SIEM_HTTP_TOKEN`, `SMTP_PASSWORD`, and the proxy credentials are env-only
precisely so admins cannot read them — the effective-config view shows set/unset
booleans, never values. But three admin-editable *host* fields decide where each
one is sent, and each pairs with a "test" endpoint: PUT `http_endpoint`,
`smtp_host`, or `proxy_url` at a host you control, press test, and the app hands
over the secret. The boundary the docstrings assert was one settings-PUT from
being defeated.

`sink_pinning.check_pinned` now refuses a runtime edit that would move the
destination origin while the matching secret is set (422), following
`llm_settings_service.validate_selection`, which pins provider base URLs for the
same reason. Path edits on the pinned host and clearing the sink entirely stay
allowed — an operator must keep the off switch, and with no destination the
secret goes nowhere. Independently, any destination move now emits a `critical`
audit event naming the old and new origin, so a re-point is visible in the trail
even where nothing is pinned.

Closes #259

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same shape as #22/#210, in newer code paths — invisible on demo data, linear in
exercise activity:

- `GET /responses` resolved branch suggestions per response: a scenario-definition
  load, an inject fetch, and a scan of the *entire* pending-inject set for every
  row. `response_next_inject_suggestions_bulk` loads each once and resolves in
  memory; `select_next_injects` is the pure part both paths now share.
- `GET /inject-comments` re-fetched the inject, re-ran the caller's
  `ExerciseMember` lookup, and fetched the author — per comment. The router now
  resolves the inject map and the caller's group once, and `comment_payloads`
  batches authors into one `in_()`.
- `GET /injects` re-ran the same membership lookup for every inject in the list;
  it resolves the group once and filters with the new pure
  `access_control.inject_visible_to`.

Each endpoint gets a query-count regression test asserting the statement count
does not move when the row count six-times; all three fail against the previous
code (39 statements vs 9).

Closes #263

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`release.yml` triggered on any `v*` tag and went straight to building, pushing to
GHCR, SBOM-attesting, and cosign-signing. Branch protection covers pushes to
`main` but not tag pushes, so a tag on a branch commit, a commit that never went
through a PR, or one whose CI failed published a signed, provenance-attested
public image of unvalidated code — with the signing machinery from #73 actively
lending it credibility.

A `verify` job now gates `release` via `needs`: the tagged commit must be
contained in `origin/main`, and the most recent completed `ci.yml` run for that
exact SHA must have concluded `success`. It queries the CI workflow's own runs
rather than the commit's check-runs, which would also match this workflow's own
jobs. Both checks are conditioned on `SHOULD_PUSH`, so a `workflow_dispatch` dry
run stays a build-only smoke test while a dispatch that would publish is held to
the same bar as a tag.

Closes #267

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backup CronJob ran `pg_dump` only, while inject attachments live as files on
the `app-uploads` PVC and are referenced from the DB by `inject.attachment_path`.
Restoring after PVC loss (or moving to a new cluster) therefore produced a
consistent-looking database whose injects point at files that do not exist — the
record says the evidence is there and every download fails. Nothing in the
manifests or the docs said the "backup" covered half the durable state, so an
operator who set it up reasonably believed they were covered.

The job now archives `/uploads` to the same backup PVC under the same timestamp
as the dump, prunes both on the same retention, and mounts the claim read-only.
`app-uploads` is ReadWriteOnce, so a `podAffinity` rule co-schedules the job with
the app pod (droppable on ReadWriteMany). The docs point at
`python -m app.reconcile_attachments` as the post-restore integrity check.

Closes #268

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`secret_var="SIEM_HTTP_TOKEN"` reads as a hardcoded credential to bandit's
hardcoded_password_funcarg check (`^secret_` matches its word pattern), which
turned the blocking CI security scan red on three call sites. Renamed to
`credential_var`/`credential_present`; behaviour unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…267, #268)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…258)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Release verify job (#267): an annotated tag's GITHUB_SHA is the tag *object*,
  not the commit CI ran against, so the head_sha CI-run lookup found zero runs
  and refused a valid release. Peel to the commit first and use it for both the
  ancestry check and the API query. (docs/RELEASING.md shows a lightweight tag,
  which happened to work; `git tag -a` did not.)
- OIDC unverified-email denial (#257): the service emitted an auth.oidc_login
  deny and the router's OIDCProvisionError handler then emitted a second one for
  the same attempt — the collision path emits exactly once, and double events
  skew SIEM denial counting. Drop the service-level emit; the callback test now
  pins exactly one deny event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge kept #287's checkout bump on every pre-existing step but the new
verify job (#267) still carried the pre-bump SHA with a stale tag comment —
zizmor's online ref-version-mismatch audit (skipped in offline local runs)
fails CI on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@richardmhope
richardmhope merged commit f4e9dc3 into main Aug 1, 2026
7 checks passed
@richardmhope
richardmhope deleted the fix/bug-sweep branch August 1, 2026 00:48
richardmhope added a commit that referenced this pull request Aug 1, 2026
chore: untrack machine-local dotfiles swept into main by #297
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