Nobody has started this yet — be first.
Business impact
This is a full cross-tenant data breach. Habit names, timezones, streaks, and full check-in history are private per-user data -- the project's own README.md calls out "per-user data isolation" as a headline feature, and app/dependencies.py::get_current_habit has a comment explicitly describing the "never reveal a habit exists to someone who doesn't own it" contract. Right now that contract is broken for every route that loads a habit by id: GET, PATCH, DELETE /habits/{id}, and every /checkins route under it. Any logged-in user can enumerate sequential habit ids and read, rename, delete, or check in/out on habits belonging to other people -- this is a textbook Insecure Direct Object Reference (IDOR, OWASP API1:2023 -- "Broken Object Level Authorization"), the single most common API security vulnerability class. In a real deployment this is an incident, not a bug ticket: it needs a security disclosure, not just a changelog line.
Problem
get_habit_for_user's docstring still describes the intended contract ("load a habit only if it belongs to user"), but the query itself no longer filters on user_id at all -- it loads any habit by id, regardless of who owns it. app/dependencies.py::get_current_habit, which every habit/check-in route depends on, trusts this function completely.
Current behavior
GET /habits/{id} (and PATCH, DELETE, and every /checkins route under it) returns 200 with another user's private habit data when called with a different user's valid token and that user's habit id, instead of a 404.
Expected behavior
get_habit_for_user returns a habit only when Habit.id == habit_id AND Habit.user_id == user.id. Any other case (wrong id, or right id but wrong owner) must return None, which get_current_habit already correctly turns into a 404 (never a 403 -- a 403 would confirm the id exists, a 404 doesn't).
Steps to reproduce
cd fastapi/habit_tracker source .venv/bin/activate python - <<'PY' from fastapi.testclient import TestClient from app.main import app c = TestClient(app)
c.post("/auth/register", json={"email": "owner@example.com", "password": "supersecret1"}) owner_token = c.post("/auth/login", data={"username": "owner@example.com", "password": "supersecret1"}).json()["access_token"] habit = c.post("/habits", json={"name": "Private Journal", "timezone": "UTC"}, headers={"Authorization": f"Bearer {owner_token}"}).json()
c.post("/auth/register", json={"email": "", "password": "supersecret1"}) intruder_token = c.post("/auth/login", data={"username": "", "password": "supersecret1"}).json()["access_token"]
Why this matters
This is a great illustration of why "return None, caller 404s" access-control patterns are worth being paranoid about: the shape of the code (query → None-check → 404) still looks completely correct at every call site (get_current_habit, every route using it). The bug is entirely inside the one function that's supposed to enforce the boundary, and every caller inherits it silently. Grepping for 403 or "unauthorized" anywhere in this codebase won't find this bug -- you have to actually check which predicate the query filters on.
Suggested approach
Compare this function to crud.list_habits a few lines above it, which correctly scopes by user_id. The fix is about restoring exactly one missing predicate -- resist the urge to add extra defenses (e.g. re-checking ownership again in every route) instead of fixing the one choke point this function exists to be.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket06_cross_user_idor.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
$git checkout -b fix/cross-user-idorFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/cross-user-idorSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inresp = c.get(f"/habits/{habit['id']}", headers={"Authorization": f"Bearer {intruder_token}"}) print(resp.status_code, resp.json()) # expect 404 -- observe 200 with the owner's private habit PY
docker compose up --buildfastapi/habit_trackerRun the project's own suite with pytest -v (or pytest tests/ -q for a quick pass/fail count) from fastapi/habit_tracker -- a fresh clone should show a handful of failures until TICKET-03 and TICKET-06 are fixed.
Work the tickets in practice_tickets/ (TICKET-01 through TICKET-07); each names one pytest file under practice_tickets/tests/:
pytest practice_tickets/tests/test_ticket01_stats_window.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 7, clean pass/fail summary
./practice_tickets/run_tickets.sh 03 07 # just a subset
practice_tickets/tests/ is intentionally outside pyproject.toml's testpaths = ["tests"], so a plain pytest run from the repo root never picks these up -- they're learning exercises, not part of the project's CI-gating regression suite.
Level 1
Fix a bug
Read existing behaviour, correct it.