fix: enforce quiz ownership on submit and grade - #7
Merged
Conversation
Authentication established who the caller was but never checked that the quiz was theirs. Any logged-in user who knew or guessed a quiz_id could answer and grade somebody else's attempt, which made the attribution the token establishes worthless - a comprehension score is a claim about a specific person, and this let one person's answers land on another person's record. Ownership is now a precondition of loading the attempt rather than a check each caller performs. _load_owned_attempt refuses anything that is missing or belongs to someone else, and start_followup and grade_quiz both go through it. user_id is a required argument on both, not an optional one. When the signatures changed, five existing tests failed with TypeError rather than quietly passing, which is the point: an endpoint that forgets to pass the caller cannot compile by accident into a bypass. Three decisions worth recording. A quiz belonging to somebody else answers 404, not 403. A 403 would confirm the quiz exists and turn the endpoint into an oracle for discovering valid ids. Tests assert the missing-quiz and foreign-quiz responses are byte-identical, the same reasoning already applied to login not revealing whether an account exists. Refusal happens before any model call. Two tests assert generate_followup_question and grade_answers are never reached on a rejected request, so an attacker cannot burn the daily Gemini quota on quizzes they have no access to. Attempts with no owner are unreachable by anyone. Any attempt created before authentication existed has user_id None and now 404s for every caller. That is correct rather than unfortunate: an unattributed attempt proves nothing about anyone. Verified live with two registered accounts against Atlas: A generates a quiz, B is refused 404 on both submit and followup, B gets the same 404 for an id that does not exist at all, and A still submits their own quiz successfully. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PET9qKZXhgjEbZK7MReYQj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
PR #6 established who the caller is. It never checked that the quiz was theirs.
Any logged-in user who knew or guessed a
quiz_idcould answer and grade somebodyelse's attempt. That makes the attribution the token establishes worthless — a
comprehension score is a claim about a specific person, and this let one person's
answers land on another person's record.
The fix
Ownership is a precondition of loading the attempt, not a check each caller performs:
start_followupandgrade_quizboth go through it, anduser_idis a requiredargument on both. When the signatures changed, five existing tests failed with
TypeErrorrather than quietly passing — which is the point. An endpoint that forgetsto pass the caller cannot compile by accident into a bypass.
Live verification
Two real accounts against Atlas:
Three decisions worth reviewing
404, not 403. A 403 would confirm the quiz exists, turning the endpoint into an
oracle for discovering valid ids. Tests assert the missing-quiz and foreign-quiz
responses are byte-identical — the same reasoning already applied to login not
revealing whether an account exists.
Refusal happens before any model call. Two tests assert
generate_followup_questionandgrade_answersare never reached on a rejectedrequest, so an attacker cannot burn the daily Gemini quota (20/day/model) on quizzes
they have no access to.
Unowned attempts are unreachable by everyone. Any attempt created before auth
existed has
user_id: Noneand now 404s for every caller. That is correct rather thanunfortunate — an unattributed attempt proves nothing about anyone — but it does mean
such rows are dead. There are none in the current database.
Verification
102 tests pass (92 backend, 10 frontend). 9 new tests covering ownership at both the
service and HTTP boundaries.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PET9qKZXhgjEbZK7MReYQj