pdf-quiz-generator/docs/quiz-revamp/privacy-review.md
Daniel 1ce3eec7cb feat: authorize tutor context and protect uploaded media
Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews.
2026-09-07 15:04:00 +02:00

3.7 KiB
Raw Blame History

Review — follow-up validation (static, read-only)

  • Finding 1 — RESOLVED. owns_source (backend/app/utils/upload_access.py:115127) no longer treats every file as moderator-owned. The moderator grant is now user.is_moderator and not cards and path.startswith('questions/') (line 121), so a live card reference (cards non-empty from card_deck_ids) forces the card ACL path: card_access (lines 130134) requires user_id == user.id / is_shared == 1 / admin — a non-owner moderator fails. Reads (can_read_upload, lines 137152) and attachments (validate_image_attachments, lines 183188) both fall through to references() only when the questions/ and images/doc_ grants do not apply, so moderator attachment of a card-only file returns 403. Admin still succeeds via owns_source:116. An independent question reference grants moderator access because require_question_access (backend/app/utils/quiz_access.py:6990) returns for moderators, and validate_image_attachments:186187 uses bool(ids) for moderators. Covered by test_card_only_moderator_denial_admin_success_and_question_grant (backend/tests/test_related_privacy.py).

  • Finding 2 — RESOLVED. stored_upload_path (upload_access.py:6085) canonicalizes case (_authority lowercases host), default port 80/443, query/fragment (dropped via urlsplit), percent (unquote on path/host), backslash (replace('\\','/')), and .. (posixpath.normpath), and rejects anything that does not normalize back under /uploads/ — so hostile out-of-root paths return None. Both classification (references/card_deck_ids/document_for_file compare canonicalized stored values to the route path) and permission checks use this. Anonymous access hits get_current_user("", db) → 401 (uploads.py:3741); owner success and peer 404 covered by test_legacy_aliases_are_canonical_for_classification_and_permissions. New-write strictness is unchanged: validate_image_attachments:168175 rejects query/fragment, % in netloc, backslash, and bad ports on absolute URLs, and local_upload_path:2431 rejects \ % ? # :, control chars, and ../empty parts. The image chooser dedupes via stored_upload_path(row.path) or row.path in a set (questions.py:445447).

  • Orphan/LMS handling — RESOLVED. protected (uploads.py:3536) defaults non-LMS-prefix paths to private, and document_for_file/questions/cards force protection even inside a legacy prefix. Unreferenced course_files|course_thumbnails|scorm|certificates files stay anonymous (test test_orphaned_files...). Hostile paths can't escape: upload_file:3447 rejects symlinks and resolves is_relative_to(root); stored_upload_path normalizes .. before the /uploads/ prefix check.

  • Note (not a blocker): owns_source:123126 grants moderators the images/doc_N/ branch without checking cards, unlike the card-aware questions/ clause. Currently unreachable as a leak because no code path writes Flashcard.image_path (generation at tasks/quiz_tasks.py:655660 sets none; CardEdit has only front/back), and moderators already hold document access. One-line hardening when cards get images: if match and not cards:.

  • Residual risks (unverified by me): I ran no tests/commands/live DB — static only. Parent re-verified 37 backend tests, 69 frontend tests/build, Nginx 206/HEAD/416 with no-store/Vary/SVG sandbox, and pgvector eligibility-before-LIMIT; those remain parent-attested, not re-run here. Full-table scans in references/card_deck_ids/document_for_file per request are an acknowledged perf ceiling (upload_access.py:88). The pedshub_media cookie is not HttpOnly (deliberate, frontend/src/utils/token.js). No blockers remain for the two P1 findings.