Skip to content

Commit 0cb7589

Browse files
fix(security): scope assignment update/delete/sync write filters by user_id (defense-in-depth, #123)
Follow-up to #123/#224. update_assignment, delete_assignment, and sync_to_google do a user_id-scoped SELECT and 404 a non-owned id before writing, so they are not exploitable today — but their write/delete filters were id-only, relying solely on that guard. Scope the writes by user_id too, so a future change to the read-guard can't silently reopen the IDOR (matches export_to_google). Tests assert the update/delete/sync write filters now carry user_id.
1 parent 84aee68 commit 0cb7589

2 files changed

Lines changed: 75 additions & 3 deletions

File tree

backend/routes/calendar.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,11 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
210210
if "course_id" in patch and patch["course_id"] == "":
211211
patch["course_id"] = None
212212

213-
table("assignments").update(patch, filters={"id": f"eq.{assignment_id}"})
213+
# Scope the write by user_id too (defense in depth): the scoped SELECT above
214+
# already 404s a non-owned id, but don't rely on that guard alone (#123).
215+
table("assignments").update(
216+
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
217+
)
214218
return {"updated": True}
215219

216220

@@ -222,7 +226,10 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
222226
)
223227
if not existing:
224228
raise HTTPException(status_code=404, detail="Assignment not found")
225-
table("assignments").delete(filters={"id": f"eq.{assignment_id}"})
229+
# Scope the delete by user_id too (defense in depth), not just the guard above.
230+
table("assignments").delete(
231+
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
232+
)
226233
return {"deleted": True}
227234

228235

@@ -356,9 +363,10 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
356363
"end": {"date": a["due_date"]},
357364
}
358365
created = service.events().insert(calendarId="primary", body=event).execute()
366+
# Scope the write-back by user_id too (defense in depth), matching export.
359367
table("assignments").update(
360368
{"google_event_id": created["id"]},
361-
filters={"id": f"eq.{a['id']}"},
369+
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
362370
)
363371
synced += 1
364372

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Defense-in-depth follow-up to #123: the assignment mutation endpoints
3+
(update / delete / sync) do a user_id-scoped SELECT and 404 a non-owned id
4+
before writing, so they're not exploitable today — but their write filters
5+
were id-only, relying solely on that guard. These tests assert the write/delete
6+
now also scope by user_id, so a future change to the read-guard can't silently
7+
reopen the IDOR.
8+
"""
9+
from unittest.mock import MagicMock, patch
10+
11+
from fastapi.testclient import TestClient
12+
13+
from main import app
14+
15+
client = TestClient(app)
16+
17+
OWNER = "user_andres"
18+
AID = "assignment_1"
19+
20+
21+
class TestSiblingWriteScoping:
22+
def test_update_scopes_write_by_user_id(self):
23+
with patch("routes.calendar.table") as t:
24+
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
25+
r = client.patch(
26+
f"/api/calendar/assignments/{AID}",
27+
json={"user_id": OWNER, "title": "New title"},
28+
)
29+
assert r.status_code == 200
30+
# The UPDATE filter must include user_id, not just id.
31+
update_filters = t.return_value.update.call_args.kwargs["filters"]
32+
assert update_filters.get("user_id") == f"eq.{OWNER}"
33+
assert update_filters.get("id") == f"eq.{AID}"
34+
35+
def test_delete_scopes_delete_by_user_id(self):
36+
with patch("routes.calendar.table") as t:
37+
t.return_value.select.return_value = [{"id": AID}]
38+
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
39+
assert r.status_code == 200
40+
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
41+
assert delete_filters.get("user_id") == f"eq.{OWNER}"
42+
assert delete_filters.get("id") == f"eq.{AID}"
43+
44+
def test_sync_scopes_writeback_by_user_id(self):
45+
unsynced = [{
46+
"id": AID, "title": "HW", "due_date": "2026-03-01",
47+
"notes": None, "google_event_id": None, "courses": {},
48+
}]
49+
50+
with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
51+
patch("routes.calendar.build") as build, \
52+
patch("routes.calendar.decrypt_if_present", return_value=""), \
53+
patch("routes.calendar.table") as t:
54+
service = MagicMock()
55+
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
56+
build.return_value = service
57+
# select returns the unsynced row on the first call, [] thereafter.
58+
t.return_value.select.side_effect = [unsynced, []]
59+
r = client.post("/api/calendar/sync", json={"user_id": OWNER})
60+
61+
assert r.status_code == 200
62+
update_filters = t.return_value.update.call_args.kwargs["filters"]
63+
assert update_filters.get("user_id") == f"eq.{OWNER}"
64+
assert update_filters.get("id") == f"eq.{AID}"

0 commit comments

Comments
 (0)