{"repro_id":"REPRO-2026-00295","version":6,"title":"Horilla HRMS protected_media path traversal enables outside-root file read","repro_type":"security","status":"published","severity":"high","cvss_score":7.5,"description":"# Pruva Ticket Brief: Horilla Protected-Media Path Traversal","root_cause":"# Root Cause Analysis: Horilla Protected-Media Path Traversal\n\n## Summary\n\nHorilla 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.\n\n## Impact\n\n- **Affected component:** Horilla HRMS `base.views.protected_media`, exposed by the real `^media/(?P<path>.*)$` route.\n- **Affected target validated:** tag `1.5.0`, commit `61bd5173220d19925ad8220db9152a75c881ea73`.\n- **Fixed target validated:** tag `1.6.0`, commit `b3bd29d15819cbece45c58e6268ddd0614e387d6`.\n- **Risk:** High-severity information disclosure.\n- **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.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Remote information leak through the attacker-facing Horilla endpoint, including the unauthenticated traversal-plus-Referer composition.\n- **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.\n- **Parity:** `full` for the submitted bounded `info_leak` claim.\n- **Not demonstrated:** Account takeover, session forgery, file modification, code execution, server compromise, or readability of every process-readable path through every proxy deployment.\n\n## Root Cause\n\nHorilla 1.5.0 implements `protected_media(request, path)` as follows:\n\n```python\nmedia_path = os.path.join(settings.MEDIA_ROOT, path)\nif not os.path.exists(media_path):\n    raise Http404(\"File not found\")\n...\nreturn FileResponse(open(media_path, \"rb\"))\n```\n\n`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.\n\nThe 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.\n\nThe 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.\n\n## Reproduction Steps\n\n1. Run `PRUVA_ROOT=/path/to/bundle bundle/repro/reproduction_steps.sh` from any working directory.\n2. 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.\n3. 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.\n4. 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.\n5. 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`.\n6. 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.\n\n## Evidence\n\n- `bundle/logs/clean_execution_1.log` and `bundle/logs/clean_execution_2.log` — two consecutive complete final-script passes.\n- `bundle/logs/reproduction_steps.log` — transcript of the latest complete execution.\n- `bundle/logs/http-proof/source_identity.log` — vulnerable, fixed, and patch commit identities.\n- `bundle/logs/http-proof/modified_files.log` — startup accommodation and modified-file inventory.\n- `bundle/logs/http-proof/image_identity_{vulnerable,fixed}.log` and `dependency_identity_{vulnerable,fixed}.json` — runtime launcher and tag-specific dependency identities.\n- `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.\n- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/generated_migrations_inventory.log` — startup-only generated migration paths.\n- `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.\n- `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.\n- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/server.log` — real Django startup and server-observed HTTP paths.\n- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/{target,attacker}_container_identity.log` — distinct process/container and network evidence.\n- `bundle/logs/http-proof/run_{1,2}_{vulnerable,fixed}/result.json` — structured per-instance oracle.\n- `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.\n- `bundle/repro/runtime_manifest.json` — strict machine-readable production-path manifest.\n\nKey current-run observations include:\n\n```text\nvulnerable authenticated_escape:   status=200, equals_outside_canary=true\nvulnerable unauthenticated_escape: status=200, equals_outside_canary=true,\n                                   cookie_sent=false, authorization_sent=false\nfixed authenticated_escape:        status=404, equals_outside_canary=false\nfixed unauthenticated_escape:      status=404, equals_outside_canary=false\nall four authenticated in-root controls: status=200, equals_inside_canary=true\n```\n\nThe 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.\n\n## Recommendations / Next Steps\n\n- Upgrade to Horilla 1.6.0 or later.\n- 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.\n- Never grant access based on the `Referer` header. Use authenticated identity and a narrowly reviewed allowlist of genuinely public media prefixes.\n- Add end-to-end server tests for raw `../`, encoded traversal variants, mixed separators where relevant, absolute paths, directory requests, and symlink-related containment cases.\n- Keep a positive in-root control in regression tests to ensure the patch fails closed without disabling legitimate media delivery.\n\n## Additional Notes\n\n- **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.\n- 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.\n- 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.\n- 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.\n","ghsa_id":"GHSA-x52c-5hrq-76pq","cwe_id":"CWE-22","source_url":"https://github.com/horilla/horilla-hr/security/advisories/GHSA-x52c-5hrq-76pq","package":{"name":"horilla/horilla-hr","ecosystem":"github","affected_versions":">=1.3.1, <1.6.0","fixed_version":"1.6.0","tested_vulnerable":"1.5.0","tested_patched":"1.6.0"},"reproduced_at":"2026-07-25T07:26:08.682340+00:00","duration_secs":5223.0,"tool_calls":743,"handoffs":2,"total_cost_usd":75.868938,"agent_costs":{"claim_matcher":0.0746,"judge":7.345693,"repro":29.571568,"support":0.583467,"vuln_variant":38.29361},"cost_breakdown":{"claim_matcher":{"gpt-5.6-sol":0.0746},"judge":{"gpt-5.6-sol":7.345693},"repro":{"gpt-5.6-sol":29.571568},"support":{"gpt-5.6-sol":0.583467},"vuln_variant":{"gpt-5.6-sol":38.29361}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-25T07:26:10.327459+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":30265,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":9856,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":13769,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":11689,"category":"analysis"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":3739,"category":"other"},{"path":"bundle/logs/clean_execution_1.log","filename":"clean_execution_1.log","size":1237,"category":"log"},{"path":"bundle/logs/clean_execution_2.log","filename":"clean_execution_2.log","size":1214,"category":"log"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":1214,"category":"log"},{"path":"bundle/logs/http-proof/matrix_summary.json","filename":"matrix_summary.json","size":4599,"category":"other"},{"path":"bundle/logs/http-proof/source_identity.log","filename":"source_identity.log","size":333,"category":"log"},{"path":"bundle/logs/http-proof/modified_files.log","filename":"modified_files.log","size":491,"category":"log"},{"path":"bundle/logs/http-proof/run_1_vulnerable/result.json","filename":"result.json","size":1370,"category":"other"},{"path":"bundle/logs/http-proof/run_2_vulnerable/result.json","filename":"result.json","size":1370,"category":"other"},{"path":"bundle/logs/http-proof/run_1_fixed/result.json","filename":"result.json","size":1352,"category":"other"},{"path":"bundle/logs/http-proof/run_2_fixed/result.json","filename":"result.json","size":1352,"category":"other"},{"path":"bundle/logs/http-proof/run_1_vulnerable/server.log","filename":"server.log","size":1060,"category":"log"},{"path":"bundle/logs/http-proof/run_2_vulnerable/server.log","filename":"server.log","size":1060,"category":"log"},{"path":"bundle/logs/http-proof/run_1_fixed/server.log","filename":"server.log","size":1181,"category":"log"},{"path":"bundle/logs/http-proof/run_2_fixed/server.log","filename":"server.log","size":1181,"category":"log"},{"path":"bundle/logs/http-proof/run_1_vulnerable/request_unauthenticated_escape.json","filename":"request_unauthenticated_escape.json","size":346,"category":"other"},{"path":"bundle/logs/http-proof/run_1_vulnerable/response_unauthenticated_escape.json","filename":"response_unauthenticated_escape.json","size":938,"category":"other"},{"path":"bundle/logs/http-proof/run_2_vulnerable/request_unauthenticated_escape.json","filename":"request_unauthenticated_escape.json","size":346,"category":"other"},{"path":"bundle/logs/http-proof/run_2_vulnerable/response_unauthenticated_escape.json","filename":"response_unauthenticated_escape.json","size":938,"category":"other"},{"path":"bundle/logs/http-proof/run_1_fixed/request_unauthenticated_escape.json","filename":"request_unauthenticated_escape.json","size":341,"category":"other"},{"path":"bundle/logs/http-proof/run_1_fixed/response_unauthenticated_escape.json","filename":"response_unauthenticated_escape.json","size":863,"category":"other"},{"path":"bundle/logs/http-proof/run_2_fixed/request_unauthenticated_escape.json","filename":"request_unauthenticated_escape.json","size":341,"category":"other"},{"path":"bundle/logs/http-proof/run_2_fixed/response_unauthenticated_escape.json","filename":"response_unauthenticated_escape.json","size":863,"category":"other"},{"path":"bundle/logs/http-proof/run_1_vulnerable/runtime_binding.log","filename":"runtime_binding.log","size":697,"category":"log"},{"path":"bundle/logs/http-proof/run_2_vulnerable/runtime_binding.log","filename":"runtime_binding.log","size":697,"category":"log"},{"path":"bundle/logs/http-proof/run_1_fixed/runtime_binding.log","filename":"runtime_binding.log","size":697,"category":"log"},{"path":"bundle/logs/http-proof/run_2_fixed/runtime_binding.log","filename":"runtime_binding.log","size":697,"category":"log"},{"path":"bundle/logs/http-proof/run_1_vulnerable/provision_identity.log","filename":"provision_identity.log","size":544,"category":"log"},{"path":"bundle/logs/http-proof/run_2_vulnerable/provision_identity.log","filename":"provision_identity.log","size":544,"category":"log"},{"path":"bundle/logs/http-proof/run_1_fixed/provision_identity.log","filename":"provision_identity.log","size":544,"category":"log"},{"path":"bundle/logs/http-proof/run_2_fixed/provision_identity.log","filename":"provision_identity.log","size":544,"category":"log"},{"path":"bundle/logs/http-proof/run_1_vulnerable/generated_migrations_inventory.log","filename":"generated_migrations_inventory.log","size":2454,"category":"log"},{"path":"bundle/logs/http-proof/run_2_vulnerable/generated_migrations_inventory.log","filename":"generated_migrations_inventory.log","size":2454,"category":"log"},{"path":"bundle/logs/http-proof/run_1_fixed/generated_migrations_inventory.log","filename":"generated_migrations_inventory.log","size":2454,"category":"log"},{"path":"bundle/logs/http-proof/run_2_fixed/generated_migrations_inventory.log","filename":"generated_migrations_inventory.log","size":2454,"category":"log"},{"path":"bundle/logs/http-proof/run_1_vulnerable/target_container_identity.log","filename":"target_container_identity.log","size":220,"category":"log"},{"path":"bundle/logs/http-proof/run_1_vulnerable/attacker_container_identity.log","filename":"attacker_container_identity.log","size":222,"category":"log"},{"path":"bundle/logs/http-proof/run_1_fixed/target_container_identity.log","filename":"target_container_identity.log","size":215,"category":"log"},{"path":"bundle/logs/http-proof/run_1_fixed/attacker_container_identity.log","filename":"attacker_container_identity.log","size":217,"category":"log"},{"path":"bundle/logs/http-proof/run_2_vulnerable/target_container_identity.log","filename":"target_container_identity.log","size":220,"category":"log"},{"path":"bundle/logs/http-proof/run_2_vulnerable/attacker_container_identity.log","filename":"attacker_container_identity.log","size":222,"category":"log"},{"path":"bundle/logs/http-proof/run_2_fixed/target_container_identity.log","filename":"target_container_identity.log","size":215,"category":"log"},{"path":"bundle/logs/http-proof/run_2_fixed/attacker_container_identity.log","filename":"attacker_container_identity.log","size":217,"category":"log"},{"path":"bundle/logs/http-proof/run_1_vulnerable/request_authenticated_escape.json","filename":"request_authenticated_escape.json","size":432,"category":"other"},{"path":"bundle/logs/http-proof/run_1_vulnerable/response_authenticated_escape.json","filename":"response_authenticated_escape.json","size":938,"category":"other"},{"path":"bundle/logs/http-proof/run_1_fixed/request_authenticated_escape.json","filename":"request_authenticated_escape.json","size":422,"category":"other"},{"path":"bundle/logs/http-proof/run_1_fixed/response_authenticated_escape.json","filename":"response_authenticated_escape.json","size":863,"category":"other"},{"path":"bundle/logs/http-proof/run_2_vulnerable/request_authenticated_escape.json","filename":"request_authenticated_escape.json","size":432,"category":"other"},{"path":"bundle/logs/http-proof/run_2_vulnerable/response_authenticated_escape.json","filename":"response_authenticated_escape.json","size":938,"category":"other"},{"path":"bundle/logs/http-proof/run_2_fixed/request_authenticated_escape.json","filename":"request_authenticated_escape.json","size":422,"category":"other"},{"path":"bundle/logs/http-proof/run_2_fixed/response_authenticated_escape.json","filename":"response_authenticated_escape.json","size":863,"category":"other"},{"path":"bundle/vuln_variant/local_http_runtime.py","filename":"local_http_runtime.py","size":3982,"category":"script"},{"path":"bundle/vuln_variant/artifacts/http_variant_attacker.py","filename":"http_variant_attacker.py","size":3450,"category":"script"},{"path":"bundle/vuln_variant/compat/distutils/version.py","filename":"version.py","size":346,"category":"script"},{"path":"bundle/vuln_variant/compat/distutils/util.py","filename":"util.py","size":199,"category":"script"},{"path":"bundle/vuln_variant/compat/distutils/__init__.py","filename":"__init__.py","size":0,"category":"script"},{"path":"bundle/vuln_variant/django_compat/django/__init__.py","filename":"__init__.py","size":578,"category":"script"},{"path":"bundle/vuln_variant/django_compat/django/utils/__init__.py","filename":"__init__.py","size":75,"category":"script"},{"path":"bundle/vuln_variant/django_compat/django/utils/_os.py","filename":"_os.py","size":936,"category":"script"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3210,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2674,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1799,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":2927,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1563,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6784,"category":"documentation"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":820,"category":"log"},{"path":"bundle/logs/vuln_variant/matrix_summary.json","filename":"matrix_summary.json","size":1516,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_fixed/result.json","filename":"result.json","size":1894,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_latest_v1/result.json","filename":"result.json","size":1914,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_latest_v2/result.json","filename":"result.json","size":1914,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_fixed/runtime_binding.json","filename":"runtime_binding.json","size":863,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_latest_v1/runtime_binding.json","filename":"runtime_binding.json","size":883,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_latest_v2/runtime_binding.json","filename":"runtime_binding.json","size":880,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_fixed/request_public_prefix_normalization.json","filename":"request_public_prefix_normalization.json","size":317,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_fixed/response_public_prefix_normalization.json","filename":"response_public_prefix_normalization.json","size":264,"category":"other"},{"path":"bundle/logs/vuln_variant/source_identity.log","filename":"source_identity.log","size":562,"category":"log"},{"path":"bundle/logs/vuln_variant/candidate_matrix.json","filename":"candidate_matrix.json","size":2044,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_submitted/result.json","filename":"result.json","size":1903,"category":"other"},{"path":"bundle/logs/vuln_variant/runtime_newer_release/result.json","filename":"result.json","size":1923,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":820,"category":"other"}]}