## Summary
Horilla HRMS tag 1.5.0 exposes a protected media authorization bypass in the real Django `/media/<path>` route. The vulnerable `protected_media()` view treats the path portion of the unauthenticated request's `Referer` header as proof that the media request originated from a public page. A remote unauthenticated attacker can therefore request a nonpublic file under `MEDIA_ROOT` and change only `Referer` from absent to `http://attacker.invalid/login`; Horilla 1.5.0 then returns the exact private file body. Horilla 1.6.0 removes this Referer-derived authorization decision and denies the same unauthenticated request.

## Impact
- Package/component affected: Horilla HRMS, `base.views.protected_media()` served through the real `/media/<path>` HTTP endpoint.
- Affected version reproduced: tag `1.5.0`, commit `61bd5173220d19925ad8220db9152a75c881ea73`.
- Fixed version verified: tag `1.6.0`, commit `b3bd29d15819cbece45c58e6268ddd0614e387d6`; source fix commit `b6eaec1386d8b8741a42fe7c78f318f073375791` is an ancestor of the fixed tag.
- Risk level and consequences: high severity authorization bypass. An unauthenticated attacker who knows or can guess a private in-root media path can read that file without a cookie, session, bearer token, JWT, or any application credential. This run demonstrated unauthorized read of a randomly generated private canary under `MEDIA_ROOT` and did not demonstrate traversal, account takeover, session forgery, or code execution.

## Impact Parity
- Disclosed/claimed maximum impact: `authz_bypass` / unauthorized read of an exact nonpublic in-root media file via attacker-controlled `Referer` on the real Horilla `/media/<path>` route.
- Reproduced impact from this run: full production-path `authz_bypass`. Horilla 1.5.0 denied the private canary with no `Referer` but returned HTTP 200 and the exact canary body when only `Referer: http://attacker.invalid/login` was added. Horilla 1.6.0 denied both requests while still serving a generated public liveness asset.
- Parity: `full`.
- Not demonstrated: path traversal, broader arbitrary file read outside `MEDIA_ROOT`, authenticated privilege changes, account takeover, session forgery, denial of service, or code execution.

## Root Cause
In Horilla 1.5.0, `protected_media(request, path)` computes:

```python
referer_path = urlparse(request.META.get("HTTP_REFERER", "")).path
```

and then skips the authentication/JWT check when `referer_path` is one of several public pages such as `/login`. Because `Referer` is fully controlled by the HTTP client and is not an authentication boundary, an unauthenticated attacker can set a cross-origin value whose path is `/login`. The view then falls through to:

```python
return FileResponse(open(media_path, "rb"))
```

for any existing media path not under the small exempted folder list. The source evidence in `bundle/artifacts/horilla_referer_bypass/20260722T055646Z-13223/source/protected_media_hunks.log` shows this vulnerable logic in tag 1.5.0 and the fixed 1.6.0 logic. The fixed version removes public-page `Referer` authorization and instead permits only explicit public media path prefixes, uses `safe_join(settings.MEDIA_ROOT, path)`, and requires an authenticated user or valid JWT for all nonpublic media paths. The known fix commit is `b6eaec1386d8b8741a42fe7c78f318f073375791`.

## Reproduction Steps
1. Run `bundle/repro/reproduction_steps.sh` from any directory, optionally with `PRUVA_ROOT=/path/to/bundle`.
2. The script reads `bundle/project_cache_context.json`, reuses or creates `/pruva/project-cache/repo`, verifies the vulnerable and fixed tag commits, installs each tag's own `requirements.txt` into isolated dependency directories, prepares migrated SQLite runtime templates, starts real Horilla Django `manage.py runserver` containers, and sends attacker-process `curl --path-as-is` requests to the real `/media/<path>` route.
3. For each of two clean attempts per role, the script creates a new private canary under a nonpublic `MEDIA_ROOT/private-data/<run>/...` path and a public liveness canary under `MEDIA_ROOT/base/icon/...`. It records requests, responses, curl traces, container logs, source identities, and modified-file inventories.
4. Expected evidence: vulnerable attempts show public liveness `200`, baseline private request with no `Referer` `302` denied with no canary in the body, and spoofed `Referer: http://attacker.invalid/login` `200` with response body equal to the private canary. Fixed attempts show public liveness `200` while both baseline and spoofed private requests return `302` and do not contain the canary.

## Evidence
- Main reproduction script: `bundle/repro/reproduction_steps.sh`.
- Latest successful runtime manifest: `bundle/repro/runtime_manifest.json`.
- Latest structured verdict: `bundle/repro/validation_verdict.json`.
- Latest complete successful proof directory: `bundle/artifacts/horilla_referer_bypass/20260722T055646Z-13223/`.
- Matrix summary: `bundle/artifacts/horilla_referer_bypass/20260722T055646Z-13223/matrix_summary.json` with `"all_oracles_passed": true`.
- Source comparison: `bundle/artifacts/horilla_referer_bypass/20260722T055646Z-13223/source/protected_media_hunks.log`.
- Per-attempt request/response artifacts include:
  - Vulnerable attempt 1 request pair: `attempts/vuln_1/baseline_no_referer_request.txt` and `attempts/vuln_1/trigger_spoofed_referer_request.txt`.
  - Vulnerable attempt 1 body evidence: `attempts/vuln_1/private_canary.txt` and `attempts/vuln_1/trigger_spoofed_referer_response_body.bin` match exactly.
  - Fixed attempt controls: `attempts/fixed_1/attempt_result.json` and `attempts/fixed_2/attempt_result.json` show both private requests denied and the public liveness route working.
- Key latest matrix results:

```json
{
  "all_oracles_passed": true,
  "attempt_results": [
    {"role": "fixed", "attempt": 1, "public_status": "200", "baseline_status": "302", "trigger_status": "302", "oracle_passed": true},
    {"role": "fixed", "attempt": 2, "public_status": "200", "baseline_status": "302", "trigger_status": "302", "oracle_passed": true},
    {"role": "vuln", "attempt": 1, "public_status": "200", "baseline_status": "302", "trigger_status": "200", "oracle_passed": true},
    {"role": "vuln", "attempt": 2, "public_status": "200", "baseline_status": "302", "trigger_status": "200", "oracle_passed": true}
  ]
}
```

- The final script revision was executed successfully twice consecutively. The latest run completed at `2026-07-22T06:00:10Z` and the prior successful run completed at `2026-07-22T05:56:08Z`.

## Recommendations / Next Steps
- Upgrade Horilla deployments to version 1.6.0 or later, or apply fix commit `b6eaec1386d8b8741a42fe7c78f318f073375791` if maintaining a downstream branch.
- Do not use `Referer`, `Origin`, or other client-controlled metadata as an authorization decision for protected media.
- Keep a strict allowlist of genuinely public media prefixes and require a real authenticated user/session or valid token for every other media path.
- Retain path containment checks such as Django `safe_join(settings.MEDIA_ROOT, path)` and verify that only regular files under `MEDIA_ROOT` can be opened.
- Add regression tests at the real HTTP route level: absent `Referer`, cross-origin `/login` `Referer`, same-origin public pages, authenticated access, JWT access, and public-prefix liveness should all be covered.

## Additional Notes
- Idempotency confirmation: `bundle/repro/reproduction_steps.sh` passed two consecutive clean runs. Each run creates fresh random private and public canaries and clean per-attempt SQLite databases.
- Runtime accommodations: to start the tagged application in this clean environment, the script installs each tag's dependencies and prepares SQLite migrations using `manage.py makemigrations` for the Horilla project apps before `migrate --run-syncdb`. It records modified-file inventories. It does not modify `base/views.py`, `base/urls.py`, routing, middleware, authentication logic, or file-open behavior.
- The attacker requests are made by `curl` from outside the Horilla Django process, do not follow redirects, and explicitly record that no cookie or authorization header is sent.
