Skip to content

fix: harden auth and MCP request handling - #1467

Merged
jfrench9 merged 2 commits into
mainfrom
bugfix/auth-surface-hardening
Sep 23, 2026
Merged

jfrench9 merged 2 commits into
mainfrom
bugfix/auth-surface-hardening

Conversation

@jfrench9

Copy link
Copy Markdown
Member

Summary

Hardening from the September app-tier review, covering auth and MCP request handling.

  • Token-redemption auth flows (password reset, email verification) follow the same session requirements as sign-in.
  • Credential-management routes (recovery-code regeneration, passkey re-auth and removal, password change) require an interactive session.
  • User-supplied fields are escaped in transactional email templates.
  • The circuit breaker keeps state only for circuits that have failed, and caller-supplied query timeouts are clamped.
  • The read-query guards apply on every MCP execution strategy.
  • Auth-cache encryption stays consistent across processes.

Compatibility

PUT /v1/user/password no longer accepts X-API-Key; it takes a sign-in session. No frontend, SDK facade or integration-template path uses a key there, so this is generated-tier surface and rides a client minor.

Tests

  • Each change has a regression test.
  • One workflow test now signs in instead of using an API key for password change.
  • just test-all passes.

🤖 Generated with Claude Code

- Token-redemption auth flows honor the same session requirements as sign-in
- Credential-management routes require an interactive session
- Escape user-supplied fields in transactional email templates
- Bound circuit-breaker state and clamp caller-supplied query timeouts
- Apply the read-query guards on every MCP execution strategy
- Keep auth-cache encryption consistent across processes
@github-actions github-actions Bot added the change:normal Normal change: automated review documented on the pull request before merge label Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Change classification: normal — platform or application code changed.

A normal change carries a documented automated review on this pull request before merge, in place of a second human approver. The change-classification job requests that review and passes once it has been posted. Recorded by the change-classification job.

@jfrench9

Copy link
Copy Markdown
Member Author

@claude please review this PR

@claude

claude Bot commented Sep 23, 2026

Copy link
Copy Markdown

Claude finished @jfrench9's task in 1m 55s —— View job


Reviewing PR #1467

  • Gather context (PR description, changed files list)
  • Read changed files in full
  • Review diff against origin/main
  • Post review feedback

Review

Solid, well-scoped hardening PR — each change is narrow, has a regression test, and the reasoning is documented in code comments. A few notes below; nothing blocking.

Auth session gating (password_reset.py, email_verification.py, mfa.py, passkeys.py, user/password.py)

  • may_issue_session_without_login (robosystems/routers/auth/utils.py:81-96) correctly denies auto-login when the user has a passkey enrolled or is subject to forced MFA enrollment, and require_jwt_user (robosystems/middleware/auth/dependencies.py:301-310) cleanly rejects API-key callers on credential-management routes. Good coverage in tests/routers/auth/test_credential_routes_require_session.py.

Circuit breaker (robosystems/middleware/robustness/circuit_breaker.py)

  • Switching self.circuits from defaultdict(CircuitState) to a plain dict that only stores failed circuits closes a real memory-growth vector (an attacker/caller supplying arbitrary operation names could previously grow the map unbounded just by calling check_circuit/record_success). The .get(...)/.setdefault(...)/.pop(...) swaps preserve existing behavior correctly, and get_circuit_status still returns a sane default for a never-seen circuit.
  • Minor behavior change worth flagging (not a bug, just a semantics shift): record_success now fully forgets a circuit that never failed, so get_circuit_status/get_all_circuit_status won't report a last_success_time for operations that always succeed. If any monitoring/dashboard relies on last_success_time being populated for healthy circuits, that signal is now gone (it never populates until the circuit's first failure+recovery). Looks intentional given the updated tests, just calling it out.

MCP request handling (handlers.py, streaming.py, client.py)

  • Good catch bringing assert_read_only_cypher + prepare_read_query (LIMIT injection) into execute_query_streaming, so the streaming strategy can no longer bypass the read-only/complexity guard that the direct tool path enforces. stream_cypher_query's anext(stream, None) wrapper to surface a pre-guard ValueError/GraphQueryComplexityError as a proper SSE error event (rather than an unhandled exception) is a nice touch.
  • Timeout clamp in call_tool (handlers.py:320-325) correctly rejects bool (since bool is an int subclass) and clamps into [10, 300], addressing the previous gap where a caller could pass 0 or a negative timeout.

Auth cache (robosystems/middleware/auth/cache.py)

  • The periodic re-derivation of the encryption key (KEY_GENERATION_RECHECK_SECONDS = 60) so multiple processes converge on a rotated key is a reasonable fix for the "one process rotates, others keep the stale key" class of bug. _derive_encryption_key swallows Redis errors internally and always returns a key, so a failed recheck won't get stuck (it isn't skipped for the next 60s window on error).
  • Now evicting cache entries that fail to decrypt during invalidate_user_data (previously just continued, silently leaving a possibly-stale entry keyed to the target user) is a good fail-closed tightening — well explained in the inline comments.

Email escaping (robosystems/operations/aws/ses.py)

  • send_org_invitation_email now escapes inviter_name/org_name — correct, since that's genuine cross-user injection (one user's chosen name is rendered in another user's inbox).
  • Note this doesn't close every gap: _verification_template, _password_reset_template, _welcome_template, and _capacity_warning_template (ses.py lines ~189, ~225, ~333, ~445) still interpolate user_name into HTML unescaped, unlike _passkey_enrolled_template/_email_changed_template which already escape it. Since user_name in those four is always the same user's own display name shown back to themselves, this is lower severity than the invitation case (no cross-user vector), but it's inconsistent with the PR's stated "user-supplied fields are escaped in transactional email templates," and a user who sets an HTML-y display name would still get raw markup in their own verification/reset/welcome emails. Worth a fast follow if not in scope here. Fix this →

Compatibility note: the PUT /v1/user/password breaking change (API key no longer accepted) is correctly called out in the PR description and covered by the updated test_api_workflows.py workflow test.

I did not run just test-all or the linter in this environment — no execution tooling was available to me here, so this review is based on static reading of the diff only.

@jfrench9
jfrench9 merged commit d4747ae into main Sep 23, 2026
8 checks passed
@jfrench9
jfrench9 deleted the bugfix/auth-surface-hardening branch September 23, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:normal Normal change: automated review documented on the pull request before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant