# Variant Root Cause Analysis: Horilla Protected-Media Public-Prefix Normalization Bypass

## Summary

A distinct authorization bypass remains in Horilla's real `GET /media/<path>` endpoint after the path-containment fix. `protected_media()` decides that a request is public by testing the **raw route string** with `path.startswith("base/icon/")` (and, on current branches, two additional public prefixes), but it opens the **normalized filesystem path** returned by `safe_join()`. An unauthenticated requester can therefore ask for `/media/base/icon/../../private_<canary>.txt`: `safe_join()` correctly keeps the result inside `MEDIA_ROOT`, while normalization resolves the filename to `MEDIA_ROOT/private_<canary>.txt`; the raw string still starts with the public prefix, so authentication is skipped and the private media canary is returned. The final script confirmed this on fixed tag 1.6.0, current 1.x, current v2, and the older `release/v2.1.0` sibling. It also confirmed that plain outside-root traversal is blocked on fixed/current branches. This is an authorization bypass inside `MEDIA_ROOT`, not a bypass of `safe_join()`'s outside-root containment invariant.

## Fix Coverage / Assumptions

The original containment fix, commit `67ac2056813ee95d4c4a0bfe7c0124a361cb6c48`, changes `base.views.protected_media` from `os.path.join(settings.MEDIA_ROOT, path)` to `safe_join(settings.MEDIA_ROOT, path)`, catches rejected paths as HTTP 404, and adds `os.path.isfile()`. Its correct invariant is: the normalized absolute filesystem target must stay beneath `MEDIA_ROOT` and be a regular file.

That fix explicitly covers:

- plain outside-root `..` traversal;
- absolute-path escape attempts;
- directory requests; and
- the sole file-open sink in `protected_media()`.

The fix assumes that containment and authorization are independent and that prefix authorization can safely inspect the unnormalized `path` argument. That assumption is false. A path can remain contained under `MEDIA_ROOT` while normalizing from an allowlisted public lexical prefix to a different, non-public subtree. The patch does not canonicalize the relative path before `startswith()` authorization, require a path-component boundary after canonicalization, or verify that the final resolved target remains beneath the selected public subtree.

Later 1.x/current-v2 code removes Referer-based authorization and uses `public_media_prefixes`, but preserves the same raw-string-prefix assumption. Thus it closes the parent public Referer composition while leaving this separate unauthenticated media authorization bypass.

The repository's `SECURITY.md` was read for each tested lineage. It treats authentication/authorization and input validation as security concerns and contains no exclusion that would make an unauthenticated private-media read expected behavior.

## Variant / Alternate Trigger

Confirmed candidate `C3-public-prefix-normalization`:

- **Entrypoint:** real Horilla HTTP route `GET /media/<path>` (`base/urls.py`, `re_path(r"^media/(?P<path>.*)$", views.protected_media, ...)`).
- **Request:** `GET /media/base/icon/../../private_<random>.txt` with no Cookie and no Authorization header.
- **Data path:** Django route capture → `base.views.protected_media(request, path)` → `safe_join(MEDIA_ROOT, path)` → raw `path.startswith(public_prefix)` authorization → `FileResponse(open(media_path, "rb"))`.
- **Distinctness:** unlike the parent, this request does not escape `MEDIA_ROOT` and does not need the Referer bypass. It crosses the same remote HTTP trust boundary and reaches the same protected-media file-open sink, but exploits inconsistent representations between authorization (raw path) and file selection (normalized path).

The script also tested:

1. `C1-newer-2x-release-line-sibling`: ordinary outside-root traversal through the newer 2.x release-line implementation. It reproduced on submitted 1.5.0 and old `release/v2.1.0`, but fixed 1.6.0 and both current branches returned 404. This records a fixed-line regression/sibling history, but it is not the primary current bypass.
2. `C2-double-percent-encoded-traversal`: all tested targets returned 404; there is no second decode after `safe_join()` in this runtime path.
3. `C3-public-prefix-normalization`: all tested targets returned HTTP 200 with the exact private in-root canary, including fixed/current targets; this is the confirmed bypass.

## Impact

- **Component:** Horilla HRMS `base.views.protected_media` and the real `/media/<path>` route.
- **Confirmed affected targets:**
  - fixed tag `1.6.0`, commit `b3bd29d15819cbece45c58e6268ddd0614e387d6`;
  - current 1.x/master, commit `11c4e3a2596c58f2381bda4c6bbc319a4430b097`;
  - `release/v2.1.0`, commit `b8cbf0af11d105e6e8c3708c3156322248f93e77`;
  - current `dev/v2.0`, commit `a971ff98662904f798819415203ccb26bc4b0db3`.
- **Risk:** unauthenticated information disclosure of a process-readable regular file under `MEDIA_ROOT` when its relative location is known or guessable.
- **Boundary:** the demonstrated file is a fresh harmless canary at the root of `MEDIA_ROOT`. No secret, database, configuration file, `/proc` entry, or credential was read.

## Impact Parity

- **Parent claimed maximum impact:** unauthenticated remote read outside `MEDIA_ROOT` by composing path traversal and Referer authorization bypass.
- **Variant reproduced impact:** unauthenticated remote read of an exact private canary **inside** `MEDIA_ROOT`, without Referer, cookie, bearer token, or session.
- **Parity:** `partial`. The confidentiality class and remote unauthenticated trust boundary match, but this variant does not escape `MEDIA_ROOT`.
- **Not demonstrated:** outside-root read on fixed/current branches, arbitrary-file discovery, account takeover, file write, code execution, session forgery, or access through every reverse proxy.

## Root Cause

The same endpoint uses two representations of one attacker-controlled path:

```python
media_path = safe_join(settings.MEDIA_ROOT, path)  # normalized target
...
is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)
...
return FileResponse(open(media_path, "rb"))
```

For `path == "base/icon/../../private.txt"`, filesystem normalization produces `MEDIA_ROOT/private.txt`, which is still contained and therefore accepted by `safe_join()`. Authorization nevertheless sees the raw string beginning with `base/icon/` and classifies it as public. The final open uses the normalized target, so a private file is served without authentication. Tag 1.6.0 uses the equivalent single-prefix test (`exempted_folders`) after the same `safe_join()` call.

This is root-cause-equivalent to the submitted issue at the sink and trust boundary: an untrusted route path controls the file selected by `protected_media()` and the endpoint opens it without enforcing the intended security boundary. The missed boundary here is the public-media subtree rather than `MEDIA_ROOT` itself.

Known containment fix: `67ac2056813ee95d4c4a0bfe7c0124a361cb6c48`.

## Reproduction Steps

1. Run `PRUVA_ROOT=/path/to/bundle bundle/vuln_variant/reproduction_steps.sh` from any directory.
2. The script resolves exact source identities, materializes immutable git archives, reads each target's `SECURITY.md`, starts the real Django WSGI route in a target process, and sends raw HTTP requests from a distinct attacker process.
3. It runs submitted 1.5.0 first, patched 1.6.0 second, then `release/v2.1.0`, current 1.x/master, and current v2.
4. Each target gets fresh inside, outside, and private-in-root canaries. The script tests the same plain traversal, double-encoded traversal, public-prefix normalization request, and a positive public in-root control.
5. Expected success is exit 0 and the final line: `PASS: public-prefix normalization bypass reads private media on fixed 1.6.0 and both current branches`.

## Evidence

- `bundle/logs/vuln_variant/reproduction_steps.log` — final complete execution transcript.
- `bundle/logs/vuln_variant/matrix_summary.json` — structured five-target, three-candidate result.
- `bundle/logs/vuln_variant/source_identity.log`, `fixed_version.txt`, and `latest_version.txt` — exact submitted, fixed, release, and latest branch revisions.
- `bundle/logs/vuln_variant/candidate_matrix.json` — source inspection and bounded candidate matrix.
- `bundle/logs/vuln_variant/security_policy_{submitted,fixed,newer_release,latest_v1,latest_v2}.md` — threat-model/security-policy copies read during the run.
- `bundle/logs/vuln_variant/runtime_<role>/request_*.json` and `response_*.json` — scrubbed raw request targets and exact-canary comparisons.
- `bundle/logs/vuln_variant/runtime_<role>/result.json` — per-target runtime oracle.
- `bundle/logs/vuln_variant/runtime_<role>/server.log` — live Django access logs preserving traversal targets.
- `bundle/logs/vuln_variant/runtime_<role>/runtime_binding.json` — exact `base/views.py` and `base/urls.py` paths/hashes and runtime identity.

Key final observations:

```text
plain outside-root traversal:
  submitted 1.5.0=200; release/v2.1.0=200
  fixed 1.6.0=404; latest 1.x=404; latest v2=404

public-prefix normalization private-media read:
  submitted=200; fixed 1.6.0=200; release/v2.1.0=200;
  latest 1.x=200; latest v2=200
  every body_equals_private_canary=true

positive /media/base/icon/<inside-canary> controls:
  all targets=200 and body_equals_inside_canary=true
```

## Recommendations / Next Steps

- Perform authorization against a single canonical relative path, not the raw route string.
- For every public prefix, compute a canonical public directory with `safe_join(MEDIA_ROOT, prefix)` and require the canonical target to remain beneath that directory using path-aware containment (`os.path.commonpath()` or equivalent), including a component boundary.
- Reject raw or decoded dot segments before authorization as defense in depth, but do not rely on string filtering alone.
- Prefer explicit storage namespaces or a dedicated public-media endpoint/storage backend rather than mixing public and private files behind one route.
- Resolve symlinks or open relative to a directory descriptor if untrusted users can influence filesystem links; `safe_join()` is lexical containment and does not itself enforce realpath containment.
- Add end-to-end tests for each public prefix with `prefix/../private`, `prefix/../../private`, duplicate separators, percent encodings, and symlink targets, while retaining positive public and authenticated-private controls.

## Additional Notes

- **Idempotency:** confirmed. The final stage script completed successfully twice consecutively with fresh random canaries and ports.
- The final runtime used Horilla's real URLconf, exact target `base/urls.py`, exact target `base/views.py`, and Django WSGI server. Source hashes are bound in each `runtime_binding.json`.
- The Docker daemon became unavailable after the reproduction stage. To preserve fresh runtime validation, the stage used distinct local target/attacker processes over TCP. Optional ABI-bound document/data/image imports were stubbed only to accommodate the worker's Python ABI; affected URL/view files were not modified and are hash-bound to git. The v2 dependency cache advertised Django 4.2.24 although v2 requires 5.2, so a narrowly scoped Django compatibility namespace reports 5.2 and supplies the unchanged `safe_join` semantics needed by current v2. This limitation does not alter the vulnerable raw-prefix/open logic, but production-container confirmation remains useful before release.
- C3's path remains within `MEDIA_ROOT`; it must not be described as arbitrary host filesystem traversal.
