Co-authored-by: Copilot <copilot@github.com>
10 KiB
Security Audit Report
Date: 2026-04-30
Scope: All views.py files and generic/decorators.py across the entire project.
Issues Fixed
1. Missing @login_required on exam_collection_exams_partial (generic/views.py)
Severity: High
File: generic/views.py
The exam_collection_exams_partial view was publicly accessible with no authentication. It exposed exam names, candidate counts, and author information from any collection to anonymous users.
Fix: Added @login_required decorator.
2. Unauthenticated get_*_id popup-helper views (multiple files)
Severity: High
Files: generic/views.py, anatomy/views.py, rapids/views.py, longs/views.py, atlas/views.py
All of the following views were decorated with @csrf_exempt but had no authentication. They allowed unauthenticated users to enumerate internal database records (Examinations, BodyParts, Structures, Abnormalities, Regions) by name via GET requests.
Additionally, all of these views contained a bug: request.accepts("application/json")() calls the boolean result of accepts() as a function, which raises TypeError: 'bool' object is not callable. This made them effectively non-functional (always returning HttpResponse("/")).
| File | View |
|---|---|
generic/views.py |
get_examination_id |
anatomy/views.py |
get_body_part_id, get_examination_id, get_structure_id |
rapids/views.py |
get_abnormality_id, get_examination_id, get_region_id |
longs/views.py |
get_examination_id |
atlas/views.py |
get_examination_id |
Fix:
- Removed
@csrf_exempt(GET-only views do not need CSRF exemption). - Added
@login_required. - Fixed
request.accepts("application/json")()→request.accepts("application/json"). - Replaced
request.GET["key"]dict access (raisesKeyErroron missing key) withrequest.GET.get("key", ""). - Added docstrings documenting scope and functionality.
3. Unauthenticated answer_suggestion_submit with CSRF exemption (rad/views.py)
Severity: High
File: rad/views.py (line ~781)
This view accepted anonymous POST requests with no CSRF protection to create new Answer records (marked proposed=True) on any existing question. This allowed anyone on the internet to submit arbitrary proposed answers to anatomy or rapids questions, polluting the moderation queue.
Fix:
- Removed
@csrf_exempt. - Added
@login_required. - Added
if request.method != "POST": return ...guard. - Fixed a minor typo in the response message (
"submited"→"submitted"). - Added docstring documenting scope and functionality.
4. Debug print() in check_user_in_group decorator (generic/decorators.py)
Severity: Medium
File: generic/decorators.py
The check_user_in_group decorator printed request.user to stdout on every invocation, leaking usernames/email addresses to application logs.
Fix: Removed the print(request.user) statement.
5. Debug print() statements leaking sensitive data (multiple files)
Severity: Medium
Files: generic/views.py, atlas/views.py, longs/views.py, sbas/views.py, physics/views.py, shorts/views.py, rcr/views.py, oef/views.py
Numerous active (non-commented) print() calls throughout view code leaked sensitive data to stdout/production logs, including:
- User objects and usernames
- CID numbers and passcodes
- Email address lists being processed
- Exam and queryset data
Fix: All active print() statements replaced with logger.debug() (using the project-standard loguru logger) or removed where they provided no diagnostic value. Commented-out print statements were left in place.
6. @csrf_exempt on collection_case_displaysetup (atlas/views.py)
Severity: Low
File: atlas/views.py
The view had @csrf_exempt applied alongside a comment saying "Only if you have CSRF issues; otherwise, keep CSRF protection". The view already has proper authentication via @user_is_collection_author_or_atlas_editor, so the CSRF exemption was unnecessary and weakened security.
Fix: Removed @csrf_exempt.
7. Missing @login_required on toggle_share_with_supervisor (generic/views.py)
Severity: Medium
File: generic/views.py
This HTMX view had no authentication check. An anonymous user with a known CidUserExam PK could toggle the share_with_supervisor flag (although the request.user != cid_user_exam.user_user check would prevent the toggle from succeeding, it still leaks whether the record exists).
Fix: Added @login_required and updated docstring.
8. Missing @login_required on active_exams in sbas/views.py
Severity: Medium
File: sbas/views.py
The active_exams view had no authentication. It returned a list of all active SBA exams to anonymous users.
Fix: Added @login_required. Updated docstring.
9. Unreachable dead code in rcr/views.py
Severity: Low
File: rcr/views.py
In the get_object method of an UpdateView subclass, two lines appeared after a return obj statement:
return obj
print("test") # unreachable
return super().get_object() # unreachable
Fix: Removed the unreachable print("test") and return super().get_object() lines.
Issues Requiring Clarification (Not Fixed)
A. @csrf_exempt on exam_submit (rad/views.py)
File: rad/views.py
This view accepts POST submissions of exam answers without CSRF protection and without @login_required. It appears to be a legacy mobile/external client endpoint that dispatches to post_exam_answers() on individual exam view classes, which may perform their own CID/passcode authentication.
Risk: Without authentication at this layer, the endpoint could be abused to submit bogus exam answers for real users if a valid CID/passcode combination is known.
Recommendation: Clarify whether this endpoint is still in active use by a mobile client. If so, implement token-based authentication (e.g. Django REST Framework token auth) or require CID/passcode in the request body and validate before dispatching. If not in use, remove it.
B. Unauthenticated views in rcr/views.py
File: rcr/views.py
The following views have no authentication decorators:
radiology_gap— renders a full RCR gap analysis page with all curriculum itemsradiology_results— renders all RCR assessment results
Risk: If these pages are intended to be internal-only (for assessors), they should require authentication. If they are intentionally public (e.g., public-facing curriculum overview), this is acceptable.
Recommendation: Confirm intended visibility. If internal, add @login_required (or a more specific decorator).
C. Unauthenticated class-based views in oef/views.py
File: oef/views.py
The following CBVs lack LoginRequiredMixin:
EntryUpdateView— allows updating OEF entry fieldsEntryTableView— lists all entriesFormatsCreateView— creates new Formats objectsFormatsUpdateView— updates Formats objects
The update views in particular are a concern if this is not an intentionally public tool.
Recommendation: Add LoginRequiredMixin (and appropriate permission checks) if this app is not intended to be publicly editable.
D. Commented-out @login_required on exam_toggle_flag in physics/views.py
File: physics/views.py
The exam_toggle_flag view has #@login_required commented out. The view falls back to exam.check_user_can_take(cid, passcode, request.user) for access control, suggesting this is intentionally accessible to CID users (non-Django-auth users). However, the intent should be confirmed.
Recommendation: If the CID/passcode check is the intended authentication mechanism, document this clearly. If the view should also support Django-authenticated users, restore the decorator appropriately.
E. exam_submit potential AttributeError (rad/views.py)
File: rad/views.py
exam_type, exam_id = request.POST.get("eid").split("/")
If eid is missing from the POST body, request.POST.get("eid") returns None, and .split("/") raises AttributeError. This would return a 500 response rather than a clean error.
Recommendation: Guard with:
eid = request.POST.get("eid", "")
if "/" not in eid:
return JsonResponse({"success": False, "error": "Invalid eid"})
exam_type, exam_id = eid.split("/", 1)
F. Open redirect risk in RedirectMixin and UpdateUserProfileView (generic/views.py, rad/views.py)
File: generic/views.py, rad/views.py
Both RedirectMixin.get_success_url and UpdateUserProfileView.get_success_url use self.request.GET["redirect"] / self.request.GET.get("redirect") as the redirect target without validation. A malicious actor could craft a URL like ?redirect=https://evil.com to redirect authenticated users to a phishing site after a successful form submission.
Recommendation: Validate the redirect URL with Django's url_has_allowed_host_and_scheme:
from django.utils.http import url_has_allowed_host_and_scheme
redirect_url = self.request.GET.get("redirect", "")
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts={self.request.get_host()}):
return redirect_url
return super().get_success_url()
G. Missing login check on sbas/views.py #@login_required commented-out views
File: sbas/views.py
Two views have @login_required commented out:
question_view(around line 222) — currently commented out entirelyquestion_detail(around line 226) — currently commented out entirely
These are fully commented-out functions; no action needed unless they are re-enabled.
Notes on Existing @csrf_exempt Usage
The following @csrf_exempt usages remain after fixes. All are on GET-only admin popup helpers that are now also @login_required. CSRF protection is not relevant for idempotent GET requests, but the decorators are harmless:
All
@csrf_exemptinstances have been removed from the codebase as part of this audit. The pattern@csrf_exempton GET-only views is redundant since CSRF protection only applies to state-changing requests (POST/PUT/PATCH/DELETE).