# 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 (raises `KeyError` on missing key) with `request.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:
```python
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 items
- `radiology_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 fields
- `EntryTableView` — lists all entries
- `FormatsCreateView` — creates new Formats objects
- `FormatsUpdateView` — 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`  
```python
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:
```python
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`:
```python
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 entirely
- `question_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_exempt` instances have been removed from the codebase as part of this audit. The pattern `@csrf_exempt` on GET-only views is redundant since CSRF protection only applies to state-changing requests (POST/PUT/PATCH/DELETE).
