Fix is_locked bypass: 11 endpoints leak or allow manipulation of locked data#6514
Conversation
Add is_locked checks to: pending-sync (filter locked items), sync-batch (skip locked items), share (reject with 402), shared preview (skip locked), and accept (pre-validate before burning token, rollback on race). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add _validate_mcp_memory helper that checks existence and lock status before allowing mutations, matching the main memories router pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add get_memory + is_locked check before mutations, using ToolExecutionError with code -32002 matching the existing conversation lock pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add is_locked check to single move and bulk move conversation endpoints, returning 402 for locked conversations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests: share rejects locked, preview skips locked, accept pre-validates, pending-sync filters locked, sync-batch skips locked updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests: MCP REST/SSE memory delete/edit reject locked (402/-32002), folder move/bulk-move reject locked conversations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds Confidence Score: 4/5Safe to merge after adding @try_catch_decorator to undo_accept_task_share; all other lock guards are correct. One P1 defect: missing @try_catch_decorator on undo_accept_task_share causes a 500 (instead of 402) and permanently consumes the share token when Redis errors during rollback. All other changes are correct and well-tested. The fix is a one-line decorator addition. backend/database/redis_db.py — undo_accept_task_share needs @try_catch_decorator Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant accept_endpoint as accept_shared_action_items
participant Redis
participant Firestore
Client->>accept_endpoint: POST /v1/action-items/accept-share
accept_endpoint->>Redis: get_task_share(token)
Redis-->>accept_endpoint: uid, task_ids
loop Pre-validate (NEW)
accept_endpoint->>Firestore: get_action_item(sender_uid, task_id)
Firestore-->>accept_endpoint: item or None
end
alt No eligible items
accept_endpoint-->>Client: 402 All tasks locked
end
accept_endpoint->>Redis: try_accept_task_share(token, uid)
Redis-->>accept_endpoint: accepted or already_accepted
alt Already accepted
accept_endpoint-->>Client: 409 Conflict
end
loop Copy eligible tasks
accept_endpoint->>Firestore: get_action_item(sender_uid, task_id)
Firestore-->>accept_endpoint: item
accept_endpoint->>Firestore: create_action_item(uid, new_item)
end
alt Race: all items locked (NEW rollback)
accept_endpoint->>Redis: undo_accept_task_share(token, uid)
accept_endpoint-->>Client: 402 Tasks no longer available
end
accept_endpoint-->>Client: created, count
Reviews (1): Last reviewed commit: "Add tests for is_locked enforcement in M..." | Re-trigger Greptile |
| def undo_accept_task_share(token: str, uid: str): | ||
| """Rollback a task share acceptance (best-effort). Used when post-claim validation fails.""" | ||
| key = f'task_share:{token}:accepted' | ||
| r.srem(key, uid) |
There was a problem hiding this comment.
Missing
@try_catch_decorator breaks "best-effort" rollback
Every other Redis helper in this file is wrapped with @try_catch_decorator, which swallows exceptions and returns None. undo_accept_task_share lacks that wrapper, so if r.srem() raises (e.g., Redis is down), the exception propagates to the caller in accept_shared_action_items, the raise HTTPException(status_code=402, ...) line is never executed, and the user gets a 500 with their share token permanently consumed — they cannot re-accept.
| def undo_accept_task_share(token: str, uid: str): | |
| """Rollback a task share acceptance (best-effort). Used when post-claim validation fails.""" | |
| key = f'task_share:{token}:accepted' | |
| r.srem(key, uid) | |
| @try_catch_decorator | |
| def undo_accept_task_share(token: str, uid: str): | |
| """Rollback a task share acceptance (best-effort). Used when post-claim validation fails.""" | |
| key = f'task_share:{token}:accepted' | |
| r.srem(key, uid) |
| @@ -544,4 +567,9 @@ def accept_shared_action_items(request: AcceptSharedTasksRequest, uid: str = Dep | |||
| new_id = action_items_db.create_action_item(uid, new_item) | |||
| created_ids.append(new_id) | |||
There was a problem hiding this comment.
Double DB fetch per eligible task
Each task in eligible_ids is fetched from Firestore once in the pre-validation loop (line ~534) and a second time inside the copy loop here (line 552). The second fetch's result is what actually determines whether the item gets copied, so the first fetch's result is discarded. Passing the already-fetched objects through as (task_id, item) tuples halves the Firestore round-trips while preserving the race-condition re-check.
eligible = []
for task_id in task_ids:
item = action_items_db.get_action_item(sender_uid, task_id)
if item and not item.get('is_locked', False):
eligible.append((task_id, item))
if not eligible:
raise HTTPException(status_code=402, detail="All shared tasks are locked. A paid plan is required.")
# ... burn token ...
created_ids = []
for task_id, original in eligible:
if original.get('is_locked', False): # re-check in case of race
continue
...Tests the path where pre-validation passes, token is claimed, but items become locked before copy — verifies undo_accept_task_share is called. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Test results:
MCP endpoints (P8-P11) tested via unit tests only — MCP API key auth has no ADMIN_KEY bypass for live testing. by AI for @beastoin |
Closes #6511. Extends #6092/#6147 audit coverage.
Adds is_locked enforcement to 11 endpoints across 4 router files that either leaked locked data or allowed write operations on locked items:
_validate_mcp_memoryhelperundo_accept_task_sharefor accept token rollback27 new unit tests covering all gaps (locked rejected, unlocked passes, edge cases).
by AI for @beastoin