# REPRO-2026-00295: Horilla HRMS protected_media path traversal enables outside-root file read ## Summary Status: published Severity: high CVSS: 7.5 / 10 CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00295 GHSA: GHSA-x52c-5hrq-76pq ## Package Name: horilla/horilla-hr Ecosystem: github Affected: >=1.3.1, <1.6.0 Fixed: 1.6.0 ## Root Cause # Root Cause Analysis: Horilla Protected-Media Path Traversal ## Summary Horilla HRMS 1.5.0 constructs the filesystem path for its real `/media/` 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.*)$` 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 `/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. ## Reproduction Details Reproduced: 2026-07-25T07:26:08.682Z Duration: 5223 seconds Tool calls: 743 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00295 pruva-verify GHSA-x52c-5hrq-76pq Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00295&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00295/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - GitHub Advisory: https://github.com/horilla/horilla-hr/security/advisories/GHSA-x52c-5hrq-76pq - Source: https://github.com/horilla/horilla-hr/security/advisories/GHSA-x52c-5hrq-76pq ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 30265 bytes) - bundle/repro/rca_report.md (analysis, 9856 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 13769 bytes) - bundle/vuln_variant/rca_report.md (analysis, 11689 bytes) - bundle/repro/runtime_manifest.json (other, 3739 bytes) - bundle/logs/clean_execution_1.log (log, 1237 bytes) - bundle/logs/clean_execution_2.log (log, 1214 bytes) - bundle/logs/reproduction_steps.log (log, 1214 bytes) - bundle/logs/http-proof/matrix_summary.json (other, 4599 bytes) - bundle/logs/http-proof/source_identity.log (log, 333 bytes) - bundle/logs/http-proof/modified_files.log (log, 491 bytes) - bundle/logs/http-proof/run_1_vulnerable/result.json (other, 1370 bytes) - bundle/logs/http-proof/run_2_vulnerable/result.json (other, 1370 bytes) - bundle/logs/http-proof/run_1_fixed/result.json (other, 1352 bytes) - bundle/logs/http-proof/run_2_fixed/result.json (other, 1352 bytes) - bundle/logs/http-proof/run_1_vulnerable/server.log (log, 1060 bytes) - bundle/logs/http-proof/run_2_vulnerable/server.log (log, 1060 bytes) - bundle/logs/http-proof/run_1_fixed/server.log (log, 1181 bytes) - bundle/logs/http-proof/run_2_fixed/server.log (log, 1181 bytes) - bundle/logs/http-proof/run_1_vulnerable/request_unauthenticated_escape.json (other, 346 bytes) - bundle/logs/http-proof/run_1_vulnerable/response_unauthenticated_escape.json (other, 938 bytes) - bundle/logs/http-proof/run_2_vulnerable/request_unauthenticated_escape.json (other, 346 bytes) - bundle/logs/http-proof/run_2_vulnerable/response_unauthenticated_escape.json (other, 938 bytes) - bundle/logs/http-proof/run_1_fixed/request_unauthenticated_escape.json (other, 341 bytes) - bundle/logs/http-proof/run_1_fixed/response_unauthenticated_escape.json (other, 863 bytes) - bundle/logs/http-proof/run_2_fixed/request_unauthenticated_escape.json (other, 341 bytes) - bundle/logs/http-proof/run_2_fixed/response_unauthenticated_escape.json (other, 863 bytes) - bundle/logs/http-proof/run_1_vulnerable/runtime_binding.log (log, 697 bytes) - bundle/logs/http-proof/run_2_vulnerable/runtime_binding.log (log, 697 bytes) - bundle/logs/http-proof/run_1_fixed/runtime_binding.log (log, 697 bytes) - bundle/logs/http-proof/run_2_fixed/runtime_binding.log (log, 697 bytes) - bundle/logs/http-proof/run_1_vulnerable/provision_identity.log (log, 544 bytes) - bundle/logs/http-proof/run_2_vulnerable/provision_identity.log (log, 544 bytes) - bundle/logs/http-proof/run_1_fixed/provision_identity.log (log, 544 bytes) - bundle/logs/http-proof/run_2_fixed/provision_identity.log (log, 544 bytes) - bundle/logs/http-proof/run_1_vulnerable/generated_migrations_inventory.log (log, 2454 bytes) - bundle/logs/http-proof/run_2_vulnerable/generated_migrations_inventory.log (log, 2454 bytes) - bundle/logs/http-proof/run_1_fixed/generated_migrations_inventory.log (log, 2454 bytes) - bundle/logs/http-proof/run_2_fixed/generated_migrations_inventory.log (log, 2454 bytes) - bundle/logs/http-proof/run_1_vulnerable/target_container_identity.log (log, 220 bytes) - bundle/logs/http-proof/run_1_vulnerable/attacker_container_identity.log (log, 222 bytes) - bundle/logs/http-proof/run_1_fixed/target_container_identity.log (log, 215 bytes) - bundle/logs/http-proof/run_1_fixed/attacker_container_identity.log (log, 217 bytes) - bundle/logs/http-proof/run_2_vulnerable/target_container_identity.log (log, 220 bytes) - bundle/logs/http-proof/run_2_vulnerable/attacker_container_identity.log (log, 222 bytes) - bundle/logs/http-proof/run_2_fixed/target_container_identity.log (log, 215 bytes) - bundle/logs/http-proof/run_2_fixed/attacker_container_identity.log (log, 217 bytes) - bundle/logs/http-proof/run_1_vulnerable/request_authenticated_escape.json (other, 432 bytes) - bundle/logs/http-proof/run_1_vulnerable/response_authenticated_escape.json (other, 938 bytes) - bundle/logs/http-proof/run_1_fixed/request_authenticated_escape.json (other, 422 bytes) - bundle/logs/http-proof/run_1_fixed/response_authenticated_escape.json (other, 863 bytes) - bundle/logs/http-proof/run_2_vulnerable/request_authenticated_escape.json (other, 432 bytes) - bundle/logs/http-proof/run_2_vulnerable/response_authenticated_escape.json (other, 938 bytes) - bundle/logs/http-proof/run_2_fixed/request_authenticated_escape.json (other, 422 bytes) - bundle/logs/http-proof/run_2_fixed/response_authenticated_escape.json (other, 863 bytes) - bundle/vuln_variant/local_http_runtime.py (script, 3982 bytes) - bundle/vuln_variant/artifacts/http_variant_attacker.py (script, 3450 bytes) - bundle/vuln_variant/compat/distutils/version.py (script, 346 bytes) - bundle/vuln_variant/compat/distutils/util.py (script, 199 bytes) - bundle/vuln_variant/compat/distutils/__init__.py (script, 0 bytes) - bundle/vuln_variant/django_compat/django/__init__.py (script, 578 bytes) - bundle/vuln_variant/django_compat/django/utils/__init__.py (script, 75 bytes) - bundle/vuln_variant/django_compat/django/utils/_os.py (script, 936 bytes) - bundle/vuln_variant/variant_manifest.json (other, 3210 bytes) - bundle/vuln_variant/validation_verdict.json (other, 2674 bytes) - bundle/vuln_variant/source_identity.json (other, 1799 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 2927 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 1563 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 6784 bytes) - bundle/logs/vuln_variant/reproduction_steps.log (log, 820 bytes) - bundle/logs/vuln_variant/matrix_summary.json (other, 1516 bytes) - bundle/logs/vuln_variant/runtime_fixed/result.json (other, 1894 bytes) - bundle/logs/vuln_variant/runtime_latest_v1/result.json (other, 1914 bytes) - bundle/logs/vuln_variant/runtime_latest_v2/result.json (other, 1914 bytes) - bundle/logs/vuln_variant/runtime_fixed/runtime_binding.json (other, 863 bytes) - bundle/logs/vuln_variant/runtime_latest_v1/runtime_binding.json (other, 883 bytes) - bundle/logs/vuln_variant/runtime_latest_v2/runtime_binding.json (other, 880 bytes) - bundle/logs/vuln_variant/runtime_fixed/request_public_prefix_normalization.json (other, 317 bytes) - bundle/logs/vuln_variant/runtime_fixed/response_public_prefix_normalization.json (other, 264 bytes) - bundle/logs/vuln_variant/source_identity.log (log, 562 bytes) - bundle/logs/vuln_variant/candidate_matrix.json (other, 2044 bytes) - bundle/logs/vuln_variant/runtime_submitted/result.json (other, 1903 bytes) - bundle/logs/vuln_variant/runtime_newer_release/result.json (other, 1923 bytes) - bundle/repro/validation_verdict.json (other, 820 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00295 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00295/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/reproductions/REPRO-2026-00295 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev