@@ -240,6 +240,131 @@ def factory(name):
240240 assert r .json ()["score" ] == 2
241241
242242
243+ # ── Cross-user node ownership (IDOR regression, issue #157) ─────────────────
244+
245+
246+ class TestQuizNodeOwnership :
247+ """User A must not be able to generate or submit a quiz against a
248+ concept node owned by user B. Both paths must 404 (not 200) so an
249+ attacker can't read a victim's concept content nor corrupt the
250+ victim's mastery fields.
251+
252+ The graph_nodes table is owner-scoped: a SELECT scoped to a user_id
253+ that doesn't own the node returns no rows. The factory below models
254+ real DB behaviour — an *unscoped* read (the bug) still leaks B's node,
255+ so these tests fail on unscoped code and pass only once the route
256+ filters every node read by the caller's user_id.
257+ """
258+
259+ NODE_OWNER = "user_beatriz" # victim B owns the node
260+ ATTACKER = "user_andres" # attacker A
261+
262+ def _node_row (self ) -> dict :
263+ return {
264+ "id" : "node_b" ,
265+ "user_id" : self .NODE_OWNER ,
266+ "course_id" : "course1" ,
267+ "concept_name" : "Beatriz Secret Concept" ,
268+ "mastery_score" : 0.5 ,
269+ "times_studied" : 3 ,
270+ "mastery_events" : [],
271+ }
272+
273+ def _ownership_aware_graph_select (self , columns = "*" , filters = None , ** _ ):
274+ """Faithfully model DB row-filtering for the victim's node.
275+
276+ The row comes back when the query matches by id and EITHER no
277+ user_id scope is applied (the *buggy* unscoped read, which leaks
278+ B's node to anyone) OR the scope names the owner. A scope naming
279+ a non-owner (the attacker) returns nothing — so unscoped code
280+ leaks/writes (test fails) and only owner-scoped reads 404 for A.
281+ """
282+ filters = filters or {}
283+ if filters .get ("id" ) != "eq.node_b" :
284+ return []
285+ user_filter = filters .get ("user_id" )
286+ if user_filter is None or user_filter == f"eq.{ self .NODE_OWNER } " :
287+ return [self ._node_row ()]
288+ return []
289+
290+ def test_generate_against_foreign_node_returns_404 (self ):
291+ """A POSTs /generate with B's concept_node_id but A's user_id.
292+ The owner-scoped node read misses → 404, before any quiz_attempts
293+ row is created and before the agent ever runs."""
294+ def factory (name ):
295+ mock = MagicMock ()
296+ if name == "graph_nodes" :
297+ mock .select .side_effect = self ._ownership_aware_graph_select
298+ else :
299+ mock .select .return_value = []
300+ mock .insert .return_value = []
301+ return mock
302+
303+ agent_run = AsyncMock ()
304+ with (
305+ patch ("routes.quiz.table" , side_effect = factory ),
306+ patch ("routes.quiz.quiz_agent.run" , new = agent_run ),
307+ ):
308+ r = client .post ("/api/quiz/generate" , json = {
309+ "user_id" : self .ATTACKER ,
310+ "concept_node_id" : "node_b" ,
311+ "num_questions" : 1 ,
312+ "difficulty" : "easy" ,
313+ "use_shared_context" : False ,
314+ })
315+
316+ assert r .status_code == 404
317+ # The agent must never run for a foreign node — no content leak.
318+ agent_run .assert_not_called ()
319+
320+ def test_submit_against_foreign_node_returns_404_and_writes_nothing (self ):
321+ """A owns the quiz_attempts row (so require_self passes), but the
322+ attempt's concept_node_id points at B's node. The owner-scoped
323+ node read (using the attempt's user_id == A) misses → 404, and no
324+ graph_nodes.update fires, so B's mastery stays intact."""
325+ update_calls : list = []
326+
327+ def factory (name ):
328+ mock = MagicMock ()
329+ if name == "quiz_attempts" :
330+ mock .select .return_value = [{
331+ "id" : "quiz_a" ,
332+ "user_id" : self .ATTACKER , # attempt belongs to A
333+ "concept_node_id" : "node_b" , # but targets B's node
334+ "difficulty" : "medium" ,
335+ "questions_json" : SAMPLE_QUESTIONS ,
336+ }]
337+ mock .update .return_value = []
338+ elif name == "graph_nodes" :
339+ mock .select .side_effect = self ._ownership_aware_graph_select
340+ mock .update .side_effect = lambda * a , ** k : update_calls .append ((a , k )) or []
341+ else :
342+ mock .select .return_value = []
343+ mock .update .return_value = []
344+ return mock
345+
346+ with (
347+ patch ("routes.quiz.table" , side_effect = factory ),
348+ patch ("routes.quiz.update_streak" ),
349+ patch ("routes.quiz.get_quiz_context" , return_value = {}),
350+ patch ("routes.quiz.call_gemini_json" , return_value = {}),
351+ ):
352+ r = client .post ("/api/quiz/submit" , json = {
353+ "quiz_id" : "quiz_a" ,
354+ "answers" : [
355+ {"question_id" : 1 , "selected_label" : "A" },
356+ {"question_id" : 2 , "selected_label" : "D" },
357+ ],
358+ })
359+
360+ assert r .status_code == 404
361+ # No mastery write to the victim's node (or any node) occurred.
362+ assert update_calls == [], (
363+ "submit_quiz wrote to graph_nodes for a foreign concept node — "
364+ "IDOR regression (issue #157)."
365+ )
366+
367+
243368# ── POST /api/quiz/generate ──────────────────────────────────────────────────
244369
245370
0 commit comments