## Summary
Horilla's 1.6.0 fix removes the original `Referer: .../login` authorization decision, but the fixed `protected_media()` still performs the public-media allowlist check on the raw, unnormalized `path` captured by the real `/media/<path>` route. A remote unauthenticated attacker can request a URL such as `/media/base/icon/../../private-data/<run>/fixed-secret.txt`: the raw path starts with public prefix `base/icon/`, so the fixed view treats it as public, while `safe_join()` normalizes the dot-segments and opens the private file inside `MEDIA_ROOT`. The variant reproducer confirmed this bypass on Horilla 1.6.0 commit `b3bd29d15819cbece45c58e6268ddd0614e387d6` through the real Django HTTP route with no cookie, authorization header, or `Referer`.

## Fix Coverage / Assumptions
The original fix commit is `b6eaec1386d8b8741a42fe7c78f318f073375791`, included in the fixed release tag `1.6.0` at commit `b3bd29d15819cbece45c58e6268ddd0614e387d6`.

The fix changes `base/views.py::protected_media()` by:
- removing the old `public_pages` list and the `urlparse(request.META.get("HTTP_REFERER", "")).path` authorization bypass;
- introducing `public_media_prefixes = ("base/icon/", "base/company/icon/", "recruitment/candidate/profile/")`;
- using `safe_join(settings.MEDIA_ROOT, path)` to keep file opens under `MEDIA_ROOT`;
- checking `os.path.isfile(media_path)` in addition to existence; and
- requiring an authenticated session or valid JWT only when `is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)` is false.

The invariant the fixed code assumes is: if the raw route parameter starts with an allowlisted public prefix, then the normalized filesystem path is also inside that public subtree. That invariant is false when the attacker includes dot-segments after the allowlisted prefix. The code path explicitly covers traversal outside `MEDIA_ROOT` through `safe_join()`, but it does not cover traversal from an allowlisted public subtree to a different nonpublic subtree that is still inside `MEDIA_ROOT`.

The target repository's `SECURITY.md` describes unauthorized access to sensitive resources as in scope and recommends strong authentication/authorization and user-input validation. It does not exclude protected media authorization bypasses from the threat model. The variant stays within the same attacker-facing trust boundary as the parent issue: an unauthenticated remote HTTP client controls the `/media/<path>` request path.

## Variant / Alternate Trigger
The validated bypass is a different attacker-controlled data path from the parent `Referer` trigger:

- Parent trigger: request the direct private path and add `Referer: http://attacker.invalid/login`.
- Variant trigger: omit `Referer` entirely and request a raw URL path that starts with an allowlisted public media prefix but uses dot-segments to normalize to a private media path, for example:
  - `/media/base/icon/../../private-data/<run>/fixed-secret.txt`

Entry point:
- Real Horilla Django HTTP route: `base/urls.py`, `re_path(r"^media/(?P<path>.*)$", views.protected_media, name="protected_media")`.

Relevant fixed code path:
- `base/views.py::protected_media()` lines 7508-7540 in tag `1.6.0`.
- Primary sink: `return FileResponse(open(media_path, "rb"))` after public-prefix authorization is skipped.
- Bypass check: `is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)` is evaluated on the raw route parameter, not on the normalized relative path.

The runtime matrix in `bundle/vuln_variant/reproduction_steps.sh` tests both versions side by side. On the vulnerable tag, the original `Referer` trigger and the dot-segment public-prefix variant both return the private canary. On the fixed tag, the original `Referer` trigger is denied as expected, but the dot-segment public-prefix variant returns the exact private canary.

## Impact
- Package/component affected: Horilla HRMS, `base.views.protected_media()` served through the real `/media/<path>` endpoint.
- Submitted vulnerable target tested: tag `1.5.0`, commit `61bd5173220d19925ad8220db9152a75c881ea73`.
- Fixed/bypass target tested: tag `1.6.0`, commit `b3bd29d15819cbece45c58e6268ddd0614e387d6`.
- Latest/default branch source inspected: `origin/master`, commit `11c4e3a2596c58f2381bda4c6bbc319a4430b097`; it retained the same raw-prefix public-media authorization pattern, though the runtime-confirmed bypass target is the fixed 1.6.0 release.
- 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 if they can express it relative to an allowlisted public prefix using dot-segments while staying under `MEDIA_ROOT` after normalization.

## Impact Parity
- Disclosed/claimed maximum impact for the parent: `authz_bypass`, specifically unauthorized read of an exact nonpublic in-root media file via the real `/media/<path>` route.
- Reproduced impact from this variant run: `authz_bypass` on the fixed release. The fixed target denied the direct private path with no `Referer`, denied the original direct private path with spoofed `/login` `Referer`, but returned HTTP 200 and the exact private canary for the dot-segment public-prefix URL.
- Parity: `full` for unauthorized read of an exact in-root private media file.
- Not demonstrated: path traversal outside `MEDIA_ROOT`, account takeover, session forgery, write access, denial of service, or code execution.

## Root Cause
The same protected-media authorization sink remains reachable because the fix separates filesystem containment normalization from public-prefix authorization. In fixed Horilla 1.6.0, `safe_join(settings.MEDIA_ROOT, path)` correctly prevents escaping the media root, but the public/private authorization decision is still based on the attacker-controlled raw `path` string:

```python
is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)
```

For `path = "base/icon/../../private-data/.../fixed-secret.txt"`, the raw string starts with `base/icon/`, so the function skips authentication. After `safe_join()`, the resolved file is `MEDIA_ROOT/private-data/.../fixed-secret.txt`, which is nonpublic but still inside `MEDIA_ROOT`. The vulnerable sink then serves it with `FileResponse(open(media_path, "rb"))`.

This is a bypass of the fix commit `b6eaec1386d8b8741a42fe7c78f318f073375791`: the commit removed `Referer` authorization but introduced/retained a raw-prefix assumption for public media that can be defeated with path normalization.

## Reproduction Steps
1. Run `bundle/vuln_variant/reproduction_steps.sh` from any directory, optionally setting `PRUVA_ROOT=/path/to/bundle`.
2. The script prepares isolated worktrees for Horilla 1.5.0 and 1.6.0, installs each tag's requirements into reusable dependency directories, prepares SQLite runtime databases, starts the real Django `manage.py runserver` target in Docker, and sends attacker-process `curl --path-as-is` requests to the live `/media/<path>` route.
3. For each target it creates a private canary outside public prefixes and a public liveness canary under `base/icon/`. It then sends: a public liveness request, a direct private request with no `Referer`, a direct private request with the original spoofed `/login` `Referer`, and the dot-segment public-prefix variant request with no `Referer`.
4. Expected fixed-target evidence: public liveness `200`; direct private no-`Referer` `302`; direct private spoofed-`Referer` `302`; dot-segment variant `200` with response body exactly equal to the private canary. Exit code `0` means the fixed-release bypass was reproduced.

## Evidence
Primary reproducer:
- `bundle/vuln_variant/reproduction_steps.sh`

Latest successful matrix:
- Summary: `bundle/logs/vuln_variant/latest_matrix_summary.json`
- Latest per-run log: `bundle/logs/vuln_variant/reproduction_steps_20260722T061846Z-17685.log`
- Runtime proof directory: `bundle/vuln_variant/runtime_20260722T061846Z-17685/`
- Fixed attempt result: `bundle/vuln_variant/runtime_20260722T061846Z-17685/attempts/fixed/attempt_result.json`
- Fixed variant request: `bundle/vuln_variant/runtime_20260722T061846Z-17685/attempts/fixed/dotsegment_public_prefix_variant_request.txt`
- Fixed variant response body: `bundle/vuln_variant/runtime_20260722T061846Z-17685/attempts/fixed/dotsegment_public_prefix_variant_response_body.bin`
- Fixed private canary: `bundle/vuln_variant/runtime_20260722T061846Z-17685/attempts/fixed/private_canary.txt`
- Source/patch/threat-model evidence: `bundle/logs/vuln_variant/patch_and_security_scope.log`
- Tested fixed source identity: `bundle/vuln_variant/source_identity.json`

Key latest matrix excerpt:

```json
{
  "fixed_bypass_oracle_passed": true,
  "variant_confirmed_on_fixed": true,
  "attempt_results": [
    {
      "role": "fixed",
      "direct_private_no_referer_status": "302",
      "direct_private_spoofed_referer_status": "302",
      "dotsegment_public_prefix_variant_status": "200",
      "dotsegment_variant_returned_exact_canary": true,
      "oracle_passed": true
    }
  ]
}
```

Environment details are captured in `bundle/vuln_variant/runtime_manifest.json`, including Docker, Python base image, exact vulnerable/fixed commits, latest inspected default-branch commit, and proof artifact paths.

## Recommendations / Next Steps
- Do not authorize public media using the raw route parameter. Normalize first, then compute a normalized relative path from `MEDIA_ROOT` and apply public-prefix checks to that normalized relative path.
- Reject any public-media request whose raw path contains dot-segments, encoded dot-segments, backslashes, duplicated separators, or whose normalized relative path differs materially from the requested public-prefix path.
- After `safe_join()`, require that a public asset's normalized absolute path is under one of the normalized allowlisted public directories, not merely that the raw path starts with a string prefix.
- Add route-level regression tests for at least:
  - `/media/base/icon/../../private-data/secret.txt`
  - `/media/base/company/icon/../../../private-data/secret.txt`
  - `/media/recruitment/candidate/profile/../../../private-data/secret.txt`
  - URL-encoded dot-segment equivalents if the framework decodes them before routing
  - normal public liveness files and authenticated/JWT protected access
- Continue to avoid `Referer`, `Origin`, or other client-controlled metadata as authorization signals.

## Additional Notes
- Idempotency confirmed: `bundle/vuln_variant/reproduction_steps.sh` was executed twice consecutively and returned exit code `0` both times. The successful runs completed around `2026-07-22T06:18:44Z` and `2026-07-22T06:22:03Z`.
- The variant is not a claim of filesystem traversal outside the media root. The demonstrated file remains inside `MEDIA_ROOT`; the security failure is bypassing the nonpublic-media authentication check after raw-prefix misclassification.
- The script records exact tested source revisions in `bundle/vuln_variant/source_identity.json` and `bundle/logs/vuln_variant/fixed_version.txt`.
