Skip to content

fix(security): idempotent /api/configure-pat (defence-in-depth follow-up to #42)#51

Merged
dgokeeffe merged 1 commit into
mainfrom
fix/configure-pat-idempotent
May 18, 2026
Merged

fix(security): idempotent /api/configure-pat (defence-in-depth follow-up to #42)#51
dgokeeffe merged 1 commit into
mainfrom
fix/configure-pat-idempotent

Conversation

@dgokeeffe
Copy link
Copy Markdown
Collaborator

Summary

Defence-in-depth follow-up to the configure-pat hotfix in #42. Refuses re-configuration of the PAT once the rotator is alive with a valid token, returning 409 Conflict.

Why this exists

PR #42 closed the impersonation vector — a non-owner can no longer call `/api/configure-pat` and persistently impersonate the owner.

But the endpoint is still non-idempotent for the legitimate owner. Once a PAT is configured, anything that can drive the owner's authenticated browser session can swap PATs out from under the rotator:

  • An XSS vector in the CoDA frontend (CSP is strict but `'unsafe-inline'` for scripts widens it)
  • A malicious tab the owner has in another browser window
  • Browser malware or a compromised extension
  • An accidental double-submit during a network blip

The owner-gate from #42 cannot distinguish "the owner sent this" from "something running in the owner's browser sent this." This PR adds a state-based check that closes the swap vector regardless of who appears to be calling.

The guard

if pat_rotator.token and not pat_rotator.is_token_expired:
    logger.warning(...)
    return jsonify({"error": "PAT already configured. Restart the app to reconfigure."}), 409
Scenario Behaviour
Fresh app, no PAT yet `token` is None → passes → normal bootstrap → 200
PAT set but expired (rotator timed out while idle) `is_token_expired = True` → passes → owner can re-bootstrap
PAT actively configured + rotator alive 409 Conflict, log entry written
Frontend during normal use Never calls configure-pat post-success (verified by grep) — no regression

Why 409, not 403

403 says "you're not allowed." 409 says "the resource is in a state that conflicts with this request." The owner DOES have permission — the issue is that bootstrap is single-shot. 409 is the semantically correct response.

Tests

  • `test_configure_pat_rejected_when_already_configured`: rotator alive → even owner gets 409
  • `test_configure_pat_allowed_when_token_expired`: rotator timed out → owner can still bootstrap
  • All 6 `TestConfigurePatAuth` cases pass; 233/233 unit tests pass

What this PR is NOT

  • Not a primary security control — fix(security): owner-gate /api/configure-pat (hotfix) #42 remains the primary. This is defence-in-depth.
  • Not a behavioural change for the frontend — `createPane()` only calls configure-pat in the "PAT not valid" branch, which by definition means `is_token_expired` is true, which the new guard explicitly permits.

Test plan

  • 233/233 unit tests pass
  • Behavioural matrix above verified via unit tests
  • Sathish post-hoc review

This pull request and its description were written by Isaac.

PR #42 closed the impersonation vector by gating /api/configure-pat
on the owner. That's the primary control: only the owner can submit
a PAT.

But the endpoint is still non-idempotent. Once a PAT is active, the
owner (or anything driving the owner's browser — XSS, malicious tab,
session-hijacking malware, or just an accidental double-submit) can
POST a different PAT and the rotator will swap to it, revoking the
previously-active token as a side effect.

This adds an idempotency guard immediately after the owner check:

  if pat_rotator.token and not pat_rotator.is_token_expired:
      return 409 "PAT already configured. Restart the app to reconfigure."

Bootstrap is single-shot by design — the rotator handles all subsequent
credential refreshes automatically. There's no legitimate reason for
the frontend to call configure-pat twice in one app lifetime (confirmed
by grep — the only caller is the PAT-bootstrap path inside the
"PAT not valid" branch of createPane()).

The expired-token escape hatch is preserved: if the rotator times out
because all sessions were reaped (long idle), is_token_expired returns
True and the owner can re-bootstrap without an app restart.

409 Conflict (not 403 Forbidden) is the right status here: the issue
isn't authorization (the owner DOES have permission) — it's that the
operation conflicts with current state.

Tests:
- test_configure_pat_rejected_when_already_configured: rotator alive →
  even owner gets 409. Mocks pat_rotator.token + is_token_expired as
  PropertyMocks on the class (instance properties don't accept patching
  through mock.patch.object directly).
- test_configure_pat_allowed_when_token_expired: rotator timed out →
  owner can still bootstrap (gets 400 "Token required" with empty body,
  proving they passed both gates).
- All 6 TestConfigurePatAuth cases pass; 233/233 total.

Co-authored-by: Isaac
@dgokeeffe dgokeeffe merged commit ae6d7ad into main May 18, 2026
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