Security audit: tenant isolation crossover report#217
Conversation
|
@claude code review |
|
Claude finished @RafaelPo's task in 1m 52s —— View job Code Review: Tenant Isolation Security Audit
Verification SummaryI cross-referenced every finding (HIGH through INFO) against the actual source code. All 14 findings are accurate — line numbers, code behavior, and severity assessments all check out. This is a thorough and well-structured audit. Review FeedbackFindings are soundHIGH-1 & HIGH-2 are the core issues and are correctly identified. The REST endpoints in MEDIUM-1 through MEDIUM-4 all verified. The LOW and INFO findings all verified and appropriately scoped. One additional observationThe audit notes that REST endpoints bypass the per-request Issues to address in this PR1. Local file paths in findings — All file references use absolute local paths ( 2. Minor formatting: The architecture diagram and summary table are well-structured. No issues there. Overall assessmentApprove with minor changes. The audit is technically accurate, well-prioritized, and actionable. The HIGH findings represent a real gap in the REST API layer that should be addressed. The recommended fixes are practical and appropriately scoped. Just clean up the local file paths before merging. |
e6b6db3 to
90ddd39
Compare
|
@claude code review |
|
Claude finished @RafaelPo's task in 1m 36s —— View job Code Review: Tenant Isolation Crossover
SummaryThis PR adds a Key Finding: Ownership check is incompleteThe Sentry review is correct. Compare the two implementations: MCP tool endpoints ( owner = await redis_store.get_task_owner(task_id)
# ...checks owner exists...
access_token = get_access_token()
user_id = access_token.client_id if access_token else None
if not user_id or user_id != owner: # ← compares caller vs owner
return [TextContent(..., text="Access denied: this task belongs to another user.")]REST endpoints ( owner = await redis_store.get_task_owner(task_id)
if not owner: # ← only checks existence, not identity
return JSONResponse({"error": "Task ownership could not be verified"}, ...)
logger.info("REST access granted for task %s (owner=%s)", task_id, owner)
return None # ← always grants access if any owner existsThe docstring at This is still useful as defense-in-depth (fail-closed for tasks without an owner record), but it does not close the cross-tenant gap from HIGH-1/HIGH-2. If an attacker leaks a poll token, this check doesn't stop them. What should happen nextThe fundamental constraint is that REST endpoints have no caller identity — they authenticate via poll token only, with no OAuth context. Possible approaches for a follow-up:
Option 3 is arguably what this PR implements, and it's a reasonable position — but the docstring should be honest about it rather than claiming equivalence with the MCP-layer check. Specific feedback1. Misleading docstring ( The docstring should not claim equivalence with
2. Owner logged at INFO level ( logger.info("REST access granted for task %s (owner=%s)", task_id, owner)Logging the owner ID at INFO level on every successful poll is noisy (widgets poll every few seconds) and leaks user identifiers into application logs. Consider 3. Tests are well-structured The new 4. Missing negative test: valid poll token but wrong owner There's no test that stores a different owner and verifies access is still granted (which it would be, since the check doesn't compare identities). This would document the current behavior explicitly. If the intent is to close the gap later, having this test as a VerdictApprove with minor changes. The code change is safe and adds a useful defense-in-depth layer. The two items to address before merge:
The broader cross-tenant gap (no caller identity on REST endpoints) is an architectural limitation that should be tracked separately. |
|
@claude code review |
|
Claude finished @RafaelPo's task in 1m 27s —— View job Code Review: Tenant Isolation Crossover
SummaryThis PR closes the HIGH-1/HIGH-2 gap from the tenant isolation audit. The two commits form a clean progression:
The REST security model is now: poll token as bearer credential + ownership cross-check as tamper detection. This is architecturally sound given that REST endpoints have no OAuth context. VerificationCompared the MCP-layer check (
The cross-check at The binding in Issues1. INFO-level log on every successful poll ( logger.info("REST access granted for task %s (owner=%s)", task_id, owner)Widgets poll every few seconds. This will flood application logs at INFO level and leak user identifiers. Should be 2. The poll token itself is encrypted via if user_id:
await client.setex(
name=build_key("poll_owner", task_id),
time=TOKEN_TTL,
value=user_id, # ← plaintext
)Meanwhile Test coverageTests are thorough and well-structured:
VerdictApprove with one minor fix. The ownership cross-check is well-designed and the docstring ( |
The REST endpoints /api/progress and /api/results/.../download previously authenticated solely via poll tokens with no user-level ownership verification. This adds a three-layer fail-closed check: 1. Task must have a recorded owner (task_owner) 2. Poll token must be bound to a user identity (poll_owner) 3. poll_owner must match task_owner (cross-check) Changes: - routes: add _validate_task_owner() called by api_progress/api_download - redis_store: store_poll_token accepts user_id, new get_poll_token_owner - tool_helpers: bind user identity when creating poll token - tests: deny-without-owner, deny-without-poll-owner, deny-on-mismatch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
375da25 to
fee28fc
Compare
| if settings.is_http: | ||
| access_token = get_access_token() | ||
| if not access_token or not access_token.client_id: | ||
| raise RuntimeError( | ||
| f"Cannot record task owner for {task_id}: no authenticated user" | ||
| ) |
There was a problem hiding this comment.
Bug: In no-auth HTTP mode, task submission will always fail with a RuntimeError because _submission_ui_json unconditionally tries to get an access token which doesn't exist.
Severity: HIGH
Suggested Fix
Modify _submission_ui_json to handle the no-auth HTTP case. Either bypass the ownership check entirely when no access token is present in no-auth mode, or assign a default system/synthetic user as the task owner for these submissions. This will prevent the RuntimeError and allow tasks to be submitted successfully.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: everyrow-mcp/src/everyrow_mcp/tool_helpers.py#L100-L105
Potential issue: In the `_submission_ui_json` function, when running in HTTP mode
(`settings.is_http` is true), the code attempts to retrieve an `access_token`. In the
supported no-auth HTTP mode, `get_access_token()` correctly returns `None`. This
triggers a conditional check that raises a `RuntimeError`, halting task submission. This
occurs because the implementation does not distinguish between authenticated and
unauthenticated HTTP modes, enforcing an ownership check that cannot be satisfied
without a user. As a result, any tool attempting to submit a task in no-auth mode will
fail, rendering this mode unusable.
Summary
everyrow-mcp/src/everyrow_mcp/)tool_helpers.py,redis_store.py,result_store.py,routes.py,tools.py,app.py,auth.py,uploads.py,config.py,http_config.py,middleware.pyFindings: 0 Critical, 2 High, 4 Medium, 5 Low, 3 Info
High: REST endpoints (
/api/progress/,/api/results/.../download) authenticate with poll tokens only — no user-level ownership check. Poll token leakage allows cross-tenant progress polling and full CSV data exfiltration.Medium: Poll tokens in query params (log leakage), Redis keys not tenant-scoped, result cache has no intrinsic ownership check, upload TOCTOU window.
Positive: Fail-closed ownership model, per-request API clients, Fernet-encrypted tokens at rest, atomic auth code replay prevention, SSRF protection, HMAC-signed uploads.
Test plan
🤖 Generated with Claude Code