GHSA-x52c-5hrq-76pq: Verified Reproduction
GHSA-x52c-5hrq-76pq: Horilla HRMS protected media path traversal enables outside-root file read
GHSA-x52c-5hrq-76pq is verified against horilla/horilla-hr · github. Affected versions: >=1.3.1, <1.6.0. Fixed in 1.6.0. Vulnerability class: Path Traversal. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00295.
What Is GHSA-x52c-5hrq-76pq?
GHSA-x52c-5hrq-76pq is a high-severity Path Traversal vulnerability affecting horilla/horilla-hr >=1.3.1, <1.6.0. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00295).
GHSA-x52c-5hrq-76pq Severity & CVSS Score
GHSA-x52c-5hrq-76pq is rated high severity, with a CVSS base score of 7.5 out of 10.
High — serious impact or readily exploitable. Prioritize remediation.
Affected horilla/horilla-hr Versions
horilla/horilla-hr · github versions >=1.3.1, <1.6.0 are affected.
How to Reproduce GHSA-x52c-5hrq-76pq
pruva-verify REPRO-2026-00295 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00295/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for GHSA-x52c-5hrq-76pq
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
Raw /media/../../runtime/<fresh-canary> request target; Referer: http://attacker.invalid/login only for the unauthenticated composition
- Separate attacker container
- TCP HTTP
- real Horilla /media/<path> route
- base.views.protected_media
- outside-root canary FileResponse
How the agent worked
Root Cause and Exploit Chain for GHSA-x52c-5hrq-76pq
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.
- Affected component: Horilla HRMS
base.views.protected_media, exposed by the real^media/(?P<path>.*)$route. - Affected target validated: tag
1.5.0, commit61bd5173220d19925ad8220db9152a75c881ea73. - Fixed target validated: tag
1.6.0, commitb3bd29d15819cbece45c58e6268ddd0614e387d6. - 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:
fullfor the submitted boundedinfo_leakclaim. - 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:
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. 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
- Run
PRUVA_ROOT=/path/to/bundle bundle/repro/reproduction_steps.shfrom any working directory. - The script reads
bundle/project_cache_context.jsonand uses<project_cache_dir>/repowhen prepared. It resolves the exact commits, proves the vulnerable commit lackssafe_join, proves the fixed release contains it, and creates clean git-archive source trees. - 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. - For every target, it creates a regular Django
Userplus requiredEmployee, assertsis_staff=falseandis_superuser=false, and does not create a session during setup. - 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 withReferer: http://attacker.invalid/login. - One invocation runs two fresh vulnerable and two fresh fixed targets. The final script was executed twice consecutively; both
bundle/logs/clean_execution_1.logandclean_execution_2.logend in the PASS oracle.
Evidence
bundle/logs/clean_execution_1.logandbundle/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}.loganddependency_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.pyand/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_*.jsonandresponse_*.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 vulnerable200outside-canary reads and two fixed404controls with live200in-root controls.bundle/repro/runtime_manifest.json— strict machine-readable production-path manifest.
Key current-run observations include:
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
Refererheader. 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.txtdigest checks. - Horilla tags ship only
__init__.pyin application migration directories. The script runs the product's ownmanage.py makemigrationsas a documented startup accommodation, inventories generated files, and verifies affectedbase/views.pyandbase/urls.pybytes 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.
GHSA-x52c-5hrq-76pq Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
Artifacts and Evidence for GHSA-x52c-5hrq-76pq
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix GHSA-x52c-5hrq-76pq
Upgrade horilla/horilla-hr · github to 1.6.0 or later.
FAQ: GHSA-x52c-5hrq-76pq
Is GHSA-x52c-5hrq-76pq exploitable?
How severe is GHSA-x52c-5hrq-76pq?
What type of vulnerability is GHSA-x52c-5hrq-76pq?
Which versions of horilla/horilla-hr are affected by GHSA-x52c-5hrq-76pq?
Is there a fix for GHSA-x52c-5hrq-76pq?
How can I reproduce GHSA-x52c-5hrq-76pq?
Is the GHSA-x52c-5hrq-76pq reproduction verified?
References for GHSA-x52c-5hrq-76pq
Authoritative sources for GHSA-x52c-5hrq-76pq — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.