# Root Cause Analysis: Horilla Protected Media Unauthenticated Outside-Root Read (Composed Chain)

## Summary

Horilla HRMS's `protected_media()` view in `base/views.py` composes two independently necessary defects: (1) a **Referer-based authorization bypass** that treats any request whose `Referer` path matches a public page (e.g., `/login`) as authenticated, and (2) an **uncontained `os.path.join()` path traversal** that allows the requested media path to escape `MEDIA_ROOT` via dot-segment traversal (`../`). A single HTTP request carrying no cookie, no session, no JWT, and no bearer token — only a crafted `Referer: http://attacker.invalid/login` header and a traversal path `/media/../canary_outside.txt` — returns the contents of a file stored outside the media directory. This reproduction establishes the composition by showing that removing either defect breaks the chain while the other remains independently exploitable.

## Impact

- **Package/component affected**: `base/views.py` — `protected_media()` view, routed via `base/urls.py` as `re_path(r"^media/(?P<path>.*)$", views.protected_media, name="protected_media")`.
- **Affected versions**: Horilla HRMS `1.5.0` (commit `61bd5173220d19925ad8220db9152a75c881ea73`). Both defects are present. The traversal fix landed in commit `67ac2056813ee95d4c4a0bfe7c0124a361cb6c48` (2026-02-23) and the authorization fix landed in commit `b6eaec1386d8b8741a42fe7c78f318f073375791` (2026-05-20), both included in tag `1.6.0` (commit `b3bd29d15819cbece45c58e6268ddd0614e387d6`).
- **Risk level and consequences**: High. An unauthenticated remote attacker can read arbitrary files accessible to the Horilla process by combining the Referer bypass with the path traversal. Impact is bounded to file read (info_leak); no code execution, privilege escalation, or account takeover is claimed.

## Impact Parity

- **Disclosed/claimed maximum impact**: Unauthenticated arbitrary file read (info_leak) via the real `/media/<path>` HTTP route.
- **Reproduced impact from this run**: Unauthenticated read of a canary file stored outside `MEDIA_ROOT` via a single HTTP request with `Referer: http://attacker.invalid/login` and path `/media/../canary_outside.txt`. Response body exactly equals the outside-root canary. No credential of any kind was sent.
- **Parity**: `full`.
- **Not demonstrated**: No code execution, privilege escalation, session forgery, or secret extraction. The oracle is a random canary, not a real secret.

## Root Cause

### Defect 1: Referer Authorization Bypass (GHSA-9wjx-4j4r-ff8w)

The `protected_media()` view on `1.5.0` checks whether the URL path extracted from the `Referer` header is in a `public_pages` list:

```python
referer_path = urlparse(request.META.get("HTTP_REFERER", "")).path
if referer_path not in public_pages and not any(
    path.startswith(f) for f in exempted_folders
):
    if not request.user.is_authenticated and not jwt_user:
        # ... redirect to login
```

Since `/login` is in `public_pages`, any request with `Referer: http://attacker.invalid/login` bypasses the authentication check entirely — regardless of whether the request carries a cookie, session, or token. The attacker controls the `Referer` header, so this is a trivial authorization bypass.

### Defect 2: Uncontained `os.path.join()` Path Traversal (GHSA-x52c-5hrq-76pq)

The view constructs the file path with:

```python
media_path = os.path.join(settings.MEDIA_ROOT, path)
```

Python's `os.path.join()` does not contain the result within `MEDIA_ROOT`. If `path` contains `..` segments (e.g., `../canary_outside.txt`), the resulting path escapes the media directory. The `os.path.exists()` check and `FileResponse(open(media_path, "rb"))` call then operate on a file outside `MEDIA_ROOT`.

### Composition

A single unauthenticated request with both `Referer: http://attacker.invalid/login` and path `/media/../canary_outside.txt`:
1. The Referer bypass skips the authentication gate.
2. The `os.path.join()` traversal escapes `MEDIA_ROOT`.
3. `FileResponse` returns the outside-root file.

### Fixes

- **Traversal fix** (commit `67ac2056`): Replaced `os.path.join()` with Django's `safe_join()`, which raises `ValueError` if the path escapes the base directory. Also added `os.path.isfile()` check.
- **Authorization fix** (commit `b6eaec13`, included in `1.6.0`): Removed the `Referer`-based authorization entirely. Replaced with `public_media_prefixes` that only exempt specific asset directories (e.g., `base/icon/`), requiring authentication for all other media.

## Reproduction Steps

1. **Script**: `bundle/repro/reproduction_steps.sh`
2. **What the script does**:
   - Clones (or reuses cached) the Horilla HRMS repository.
   - For each of three upstream refs (`1.5.0`, `67ac2056`, `1.6.0`), checks out the ref, installs its `requirements.txt`, runs migrations on a fresh SQLite database, creates two random canary files (one outside `MEDIA_ROOT`, one inside at a nonpublic path), and starts the real Django `runserver`.
   - Sends real HTTP requests via `curl --path-as-is` (preserving dot segments) from a separate attacker process to the running Horilla server.
   - **1.5.0 (vulnerable oracle)**: Sends the escaping request without Referer (expect 302 denied), with Referer (expect 200 + outside canary), and as an authenticated low-priv user (expect 200 + outside canary). Also sends an in-root request with Referer (expect 200 + in-root canary).
   - **67ac2056 (containment necessity control)**: Sends the escaping chain request with Referer (expect 404 — safe_join blocks traversal) and an in-root request with Referer (expect 200 + in-root canary — Referer bypass still works).
   - **1.6.0 (fixed control)**: Sends the chain request with Referer (expect 404), in-root request with Referer (expect 302 — no bypass), and a legitimate authenticated in-root request (expect 200 — service works).
   - Runs the complete three-ref matrix **twice** from clean targets with new canaries.
3. **Expected evidence**: All tests pass (exit 0). Evidence files under `bundle/repro/evidence/` include request/response captures, HTTP headers, status codes, server logs with server-observed paths, and canary verification for every request.

## Evidence

- **Main log**: `bundle/logs/reproduction_steps.log` — complete execution output.
- **Run logs**: `bundle/logs/reproduction_run1.log`, `bundle/logs/reproduction_run2.log` (if separate).
- **Evidence directory**: `bundle/repro/evidence/run{1,2}/{1.5.0,67ac2056,1.6.0}/`
  - `request_*.txt` — request metadata (method, URL, path, cookie/auth status, headers)
  - `response_*.txt` — response body
  - `headers_*.txt` — response headers
  - `status_*.txt` — HTTP status code
  - `server.log` — Django dev server log with server-observed paths
  - `server_observed_paths.txt` — extracted server-observed paths
  - `checkout.log` — git checkout and resolved commit
  - `migrate.log` — migration output
  - `modified_files.txt` — startup accommodation inventory
  - `media_root_check.txt` — MEDIA_ROOT and outside-canary path verification
  - `outside_canary_value.txt` / `inroot_canary_value.txt` — canary values
  - `create_user.log` — low-priv user creation (is_staff=False, is_superuser=False)
  - `cookies.txt` — session cookies for authenticated requests
- **Runtime manifest**: `bundle/repro/runtime_manifest.json`
- **Validation verdict**: `bundle/repro/validation_verdict.json`

### Key evidence excerpts (from initial validation)

On `1.5.0`:
- `GET /media/../canary_outside.txt` without Referer → HTTP 302 (redirect to login), no canary in body.
- `GET /media/../canary_outside.txt` with `Referer: http://attacker.invalid/login` → HTTP 200, body = outside-root canary.
- Server log: `"GET /media/../canary_outside.txt HTTP/1.1" 302 0` then `"GET /media/../canary_outside.txt HTTP/1.1" 200 21`.

On `67ac2056`:
- `GET /media/../canary_outside.txt` with Referer → HTTP 404 (safe_join blocks traversal).
- `GET /media/private_inroot/canary_inroot.txt` with Referer → HTTP 200, body = in-root canary.
- Server log: `"GET /media/../canary_outside.txt HTTP/1.1" 404 52013` then `"GET /media/private_inroot/canary_inroot.txt HTTP/1.1" 200 47`.

On `1.6.0`:
- `GET /media/../canary_outside.txt` with Referer → HTTP 404 (safe_join blocks traversal).
- `GET /media/private_inroot/canary_inroot.txt` with Referer → HTTP 302 (no Referer bypass, redirect to login).
- Authenticated `GET /media/private_inroot/canary_inroot.txt` → HTTP 200, body = in-root canary (service works).
- Server log: `"GET /media/../canary_outside.txt HTTP/1.1" 404 52013`, `"GET /media/private_inroot/canary_inroot.txt HTTP/1.1" 302 0`, `"GET /media/private_inroot/canary_inroot.txt HTTP/1.1" 200 47`.

## Recommendations / Next Steps

- **Upgrade to 1.6.0 or later**: Both defects are fixed in tag `1.6.0`. The `safe_join()` containment and the removal of the Referer-based authorization together close the composed chain.
- **Do not restore Referer-based authorization**: The Referer header is attacker-controlled and must not be used as an authorization mechanism. The `public_media_prefixes` approach in `1.6.0` is the correct pattern.
- **Always use `safe_join()` for file path containment**: Never use `os.path.join()` to construct paths that must stay within a base directory.
- **Testing recommendations**: Add integration tests that send requests with attacker-controlled `Referer` headers and traversal paths to `protected_media()`. Verify that unauthenticated requests with any `Referer` value are denied for non-public media.

## Additional Notes

- **Idempotency**: The script is idempotent — it cleans up generated files (canaries, SQLite DB) before each ref checkout and uses fresh random canaries for each run. Running it multiple times produces the same pass/fail results.
- **Canary approach**: The oracle is a random canary file, never a real secret. No configuration files, databases, `/proc` entries, password material, credentials, or employee records are read or retained.
- **HTTP client**: `curl --path-as-is` is used to preserve dot segments verbatim. Redirects are not followed automatically (`-L` not used), so 302 responses are recorded as-is.
- **Modified files**: The only startup accommodations are creating canary files and a SQLite database, which are untracked data files. No source files (`base/views.py`, `base/urls.py`, routing, middleware, authentication, or file-open behavior) are modified.
- **Dependency note**: `PyMuPDF==1.24.5` (pinned in `requirements.txt`) does not build on Python 3.14. A newer `PyMuPDF` with pre-built wheels is installed instead. This does not affect the `protected_media()` code path.
- **Out of scope**: The `1.6.0` dot-segment prefix-normalization bypass (tracked by `HORILLA-PROTECTED-MEDIA-PREFIX-NORMALIZATION-BYPASS`) is not tested here and is not used to satisfy or fail any control.
