Skip to content

fix: enforce quiz ownership on submit and grade - #7

Merged
krishhimself merged 1 commit into
mainfrom
fix/quiz-ownership
Aug 22, 2026
Merged

fix: enforce quiz ownership on submit and grade#7
krishhimself merged 1 commit into
mainfrom
fix/quiz-ownership

Conversation

@krishhimself

Copy link
Copy Markdown
Owner

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_id could answer and grade somebody
else'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:

async def _load_owned_attempt(quiz_id: str, user_id: str) -> dict:
    attempt = await quiz_repository.get_attempt(quiz_id)
    if not attempt or attempt.get("user_id") != user_id:
        raise LookupError("quiz_not_found")
    return attempt

start_followup and grade_quiz both go through it, and user_id is a required
argument on both. 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.

Live verification

Two real accounts against Atlas:

A generates quiz      -> 200  quiz_id: d3bfe40f
B submits to A quiz   -> 404  "Quiz not found"    <- blocked
B grades A quiz       -> 404  "Quiz not found"
B submits fake id     -> 404  "Quiz not found"    <- identical
A submits own quiz    -> 200  follow-up issued

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_question and grade_answers are never reached on a rejected
request, 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: None and now 404s for every caller. That is correct rather than
unfortunate — 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

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
@krishhimself
krishhimself merged commit 358c696 into main Aug 22, 2026
2 checks passed
@krishhimself
krishhimself deleted the fix/quiz-ownership branch August 22, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant