# Root Cause Analysis: Horilla Protected-Media Path Traversal

## Summary

Horilla HRMS 1.5.0 constructs the filesystem path for its real `/media/<path>` endpoint with `os.path.join(settings.MEDIA_ROOT, path)` and opens the result without verifying that canonical resolution remains beneath `MEDIA_ROOT`. A low-privilege user can therefore send `..` segments in an HTTP request target and read a process-readable file outside the media directory. The same release also trusts the path portion of the attacker-controlled `Referer` header for media authorization; `Referer: http://attacker.invalid/login` is treated as a public-page request and composes with traversal into an unauthenticated outside-root read. Two consecutive executions of the final reproducer confirmed both paths against exact Horilla 1.5.0 source and confirmed fail-closed behavior in unchanged Horilla 1.6.0, using only fresh harmless canaries.

## Impact

- **Affected component:** Horilla HRMS `base.views.protected_media`, exposed by the real `^media/(?P<path>.*)$` route.
- **Affected target validated:** tag `1.5.0`, commit `61bd5173220d19925ad8220db9152a75c881ea73`.
- **Fixed target validated:** tag `1.6.0`, commit `b3bd29d15819cbece45c58e6268ddd0614e387d6`.
- **Risk:** High-severity information disclosure.
- **Consequence:** A regular authenticated user can escape `MEDIA_ROOT`. Combined with the Referer authorization defect, an unauthenticated remote requester can read a file readable by the Horilla process. The proof is deliberately limited to fresh outside-root canaries; it does not read configuration, databases, source secrets, `/proc`, password files, or reusable credentials.

## Impact Parity

- **Disclosed/claimed maximum impact:** Remote information leak through the attacker-facing Horilla endpoint, including the unauthenticated traversal-plus-Referer composition.
- **Reproduced impact:** In each of two consecutive complete script executions, two fresh Horilla 1.5.0 target instances returned HTTP 200 and the exact outside-root canary for (1) an authenticated low-privilege traversal and (2) a no-cookie/no-Authorization traversal carrying the crafted Referer. Two fresh Horilla 1.6.0 instances returned HTTP 404 without the outside canary, while authenticated in-root controls remained HTTP 200.
- **Parity:** `full` for the submitted bounded `info_leak` claim.
- **Not demonstrated:** Account takeover, session forgery, file modification, code execution, server compromise, or readability of every process-readable path through every proxy deployment.

## Root Cause

Horilla 1.5.0 implements `protected_media(request, path)` as follows:

```python
media_path = os.path.join(settings.MEDIA_ROOT, path)
if not os.path.exists(media_path):
    raise Http404("File not found")
...
return FileResponse(open(media_path, "rb"))
```

`os.path.join()` combines path strings but does not enforce containment. When `path` includes enough `../` components, filesystem resolution escapes `MEDIA_ROOT`. The route captures the remainder of `/media/`, and the reproducer uses `HTTPConnection.putrequest()` with a raw target so no client normalization removes those segments. Live Django access logs show the same traversal target observed after the TCP boundary.

The authorization logic independently derives `referer_path` from `request.META["HTTP_REFERER"]` and treats paths such as `/login` as public. Since only the path is compared, an attacker-controlled origin such as `attacker.invalid` is ignored. Supplying a Referer whose path is `/login` therefore skips authentication, and traversal plus this bypass yields an unauthenticated read in one request.

The containment fix is commit [`67ac2056813ee95d4c4a0bfe7c0124a361cb6c48`](https://github.com/horilla/horilla-hr/commit/67ac2056813ee95d4c4a0bfe7c0124a361cb6c48). It replaces string joining with Django's `safe_join(settings.MEDIA_ROOT, path)`, maps rejected traversal to HTTP 404, and requires a regular file. Horilla 1.6.0 additionally removes Referer-based authorization in favor of explicit public-media prefixes.

## Reproduction Steps

1. Run `PRUVA_ROOT=/path/to/bundle bundle/repro/reproduction_steps.sh` from any working directory.
2. The script reads `bundle/project_cache_context.json` and uses `<project_cache_dir>/repo` when prepared. It resolves the exact commits, proves the vulnerable commit lacks `safe_join`, proves the fixed release contains it, and creates clean git-archive source trees.
3. It installs each tag's exact `requirements.txt`, generates Horilla's startup migrations (the releases ship migration-package stubs), records every generated migration path, migrates a fresh SQLite database, and starts the real Django application with the affected view and route byte-bound to their commit identities.
4. For every target, it creates a regular Django `User` plus required `Employee`, asserts `is_staff=false` and `is_superuser=false`, and does not create a session during setup.
5. A distinct attacker container GETs `/login/`, extracts CSRF, POSTs the actual login form, and obtains the session over HTTP. It then sends an authenticated in-root request, an authenticated traversal, and a no-cookie/no-Authorization traversal with `Referer: http://attacker.invalid/login`.
6. One invocation runs two fresh vulnerable and two fresh fixed targets. The final script was executed twice consecutively; both `bundle/logs/clean_execution_1.log` and `clean_execution_2.log` end in the PASS oracle.

## Evidence

- `bundle/logs/clean_execution_1.log` and `bundle/logs/clean_execution_2.log` — two consecutive complete final-script passes.
- `bundle/logs/reproduction_steps.log` — transcript of the latest complete execution.
- `bundle/logs/http-proof/source_identity.log` — vulnerable, fixed, and patch commit identities.
- `bundle/logs/http-proof/modified_files.log` — startup accommodation and modified-file inventory.
- `bundle/logs/http-proof/image_identity_{vulnerable,fixed}.log` and `dependency_identity_{vulnerable,fixed}.json` — runtime launcher and tag-specific dependency identities.
- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/runtime_binding.log` — live target module paths and SHA-256 values for `/app/base/views.py` and `/app/base/urls.py`, plus Django version.
- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/generated_migrations_inventory.log` — startup-only generated migration paths.
- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/provision_identity.log` — `is_staff=false`, `is_superuser=false`, active Employee relation, and no setup-created session.
- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/request_*.json` and `response_*.json` — scrubbed raw targets, header presence, statuses, digests, and exact-canary comparisons.
- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/server.log` — real Django startup and server-observed HTTP paths.
- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/{target,attacker}_container_identity.log` — distinct process/container and network evidence.
- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/result.json` — structured per-instance oracle.
- `bundle/logs/http-proof/matrix_summary.json` — latest four-instance aggregate. It records two vulnerable `200` outside-canary reads and two fixed `404` controls with live `200` in-root controls.
- `bundle/repro/runtime_manifest.json` — strict machine-readable production-path manifest.

Key current-run observations include:

```text
vulnerable authenticated_escape:   status=200, equals_outside_canary=true
vulnerable unauthenticated_escape: status=200, equals_outside_canary=true,
                                   cookie_sent=false, authorization_sent=false
fixed authenticated_escape:        status=404, equals_outside_canary=false
fixed unauthenticated_escape:      status=404, equals_outside_canary=false
all four authenticated in-root controls: status=200, equals_inside_canary=true
```

The request capture states that the supplied target was emitted without URL normalization; server logs independently show `/media/../../runtime/outside_...`. Passwords and cookie values are redacted or omitted from public logs.

## Recommendations / Next Steps

- Upgrade to Horilla 1.6.0 or later.
- On maintained branches, resolve user-controlled paths with `safe_join()` (or equivalent canonical containment), reject containment failures, and require a regular file before opening it.
- Never grant access based on the `Referer` header. Use authenticated identity and a narrowly reviewed allowlist of genuinely public media prefixes.
- Add end-to-end server tests for raw `../`, encoded traversal variants, mixed separators where relevant, absolute paths, directory requests, and symlink-related containment cases.
- Keep a positive in-root control in regression tests to ensure the patch fails closed without disabling legitimate media delivery.

## Additional Notes

- **Idempotency:** Confirmed. The final script ran twice consecutively. Every invocation creates fresh databases, networks, containers, users, passwords, and canaries. Images/dependency directories are reused only after exact revision or `requirements.txt` digest checks.
- Horilla tags ship only `__init__.py` in application migration directories. The script runs the product's own `manage.py makemigrations` as a documented startup accommodation, inventories generated files, and verifies affected `base/views.py` and `base/urls.py` bytes against git before and from each live target.
- The server is Django's real `manage.py runserver 0.0.0.0:8000 --noreload`; attacker requests cross a private Docker TCP network from a separate container.
- The observed impact is bounded to exact random canaries. Results do not assert that a URL-normalizing reverse proxy would preserve the same raw path or that every sensitive file is reachable.
