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://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
- on the real production code path
Unauthenticated /media/base/icon/../../<private-file> is classified by the raw public prefix but opened at the normalized private MEDIA_ROOT target, bypassing protected-media authentication on fixed and current Horilla revisions.
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.
Variant Analysis & Alternative Triggers for GHSA-x52c-5hrq-76pq
A distinct authorization bypass remains in Horilla's real GET /media/<path> endpoint after the path-containment fix. protected_media() decides that a request is public by testing the raw route string with path.startswith("base/icon/") (and, on current branches, two additional public prefixes), but it opens the normalized filesystem path returned by safe_join(). An unauthenticated requester can therefore ask for /media/base/icon/../../private_<canary>.txt: safe_join() correctly keeps the result inside MEDIA_ROOT, while normalization resolves the filename to MEDIA_ROOT/private_<canary>.txt; the raw string still starts with the public prefix, so authentication is skipped and the private media canary is returned. The final script confirmed this on fixed tag 1.6.0, current 1.x, current v2, and the older release/v2.1.0 sibling. It also confirmed that plain outside-root traversal is blocked on fixed/current branches. This is an authorization bypass inside MEDIA_ROOT, not a bypass of safe_join()'s outside-root containment invariant.
Fix Coverage / Assumptions
The original containment fix, commit 67ac2056813ee95d4c4a0bfe7c0124a361cb6c48, changes base.views.protected_media from os.path.join(settings.MEDIA_ROOT, path) to safe_join(settings.MEDIA_ROOT, path), catches rejected paths as HTTP 404, and adds os.path.isfile(). Its correct invariant is: the normalized absolute filesystem target must stay beneath MEDIA_ROOT and be a regular file.
That fix explicitly covers:
- plain outside-root
..traversal; - absolute-path escape attempts;
- directory requests; and
- the sole file-open sink in
protected_media().
The fix assumes that containment and authorization are independent and that prefix authorization can safely inspect the unnormalized path argument. That assumption is false. A path can remain contained under MEDIA_ROOT while normalizing from an allowlisted public lexical prefix to a different, non-public subtree. The patch does not canonicalize the relative path before startswith() authorization, require a path-component boundary after canonicalization, or verify that the final resolved target remains beneath the selected public subtree.
Later 1.x/current-v2 code removes Referer-based authorization and uses public_media_prefixes, but preserves the same raw-string-prefix assumption. Thus it closes the parent public Referer composition while leaving this separate unauthenticated media authorization bypass.
The repository's SECURITY.md was read for each tested lineage. It treats authentication/authorization and input validation as security concerns and contains no exclusion that would make an unauthenticated private-media read expected behavior.
Variant / Alternate Trigger
Confirmed candidate C3-public-prefix-normalization:
- Entrypoint: real Horilla HTTP route
GET /media/<path>(base/urls.py,re_path(r"^media/(?P<path>.*)$", views.protected_media, ...)). - Request:
GET /media/base/icon/../../private_<random>.txtwith no Cookie and no Authorization header. - Data path: Django route capture →
base.views.protected_media(request, path)→safe_join(MEDIA_ROOT, path)→ rawpath.startswith(public_prefix)authorization →FileResponse(open(media_path, "rb")). - Distinctness: unlike the parent, this request does not escape
MEDIA_ROOTand does not need the Referer bypass. It crosses the same remote HTTP trust boundary and reaches the same protected-media file-open sink, but exploits inconsistent representations between authorization (raw path) and file selection (normalized path).
The script also tested:
C1-newer-2x-release-line-sibling: ordinary outside-root traversal through the newer 2.x release-line implementation. It reproduced on submitted 1.5.0 and oldrelease/v2.1.0, but fixed 1.6.0 and both current branches returned 404. This records a fixed-line regression/sibling history, but it is not the primary current bypass.C2-double-percent-encoded-traversal: all tested targets returned 404; there is no second decode aftersafe_join()in this runtime path.C3-public-prefix-normalization: all tested targets returned HTTP 200 with the exact private in-root canary, including fixed/current targets; this is the confirmed bypass.
- Component: Horilla HRMS
base.views.protected_mediaand the real/media/<path>route. - Confirmed affected targets:
- fixed tag
1.6.0, commitb3bd29d15819cbece45c58e6268ddd0614e387d6; - current 1.x/master, commit
11c4e3a2596c58f2381bda4c6bbc319a4430b097; release/v2.1.0, commitb8cbf0af11d105e6e8c3708c3156322248f93e77;- current
dev/v2.0, commita971ff98662904f798819415203ccb26bc4b0db3.
- fixed tag
- Risk: unauthenticated information disclosure of a process-readable regular file under
MEDIA_ROOTwhen its relative location is known or guessable. - Boundary: the demonstrated file is a fresh harmless canary at the root of
MEDIA_ROOT. No secret, database, configuration file,/procentry, or credential was read.
Impact Parity
- Parent claimed maximum impact: unauthenticated remote read outside
MEDIA_ROOTby composing path traversal and Referer authorization bypass. - Variant reproduced impact: unauthenticated remote read of an exact private canary inside
MEDIA_ROOT, without Referer, cookie, bearer token, or session. - Parity:
partial. The confidentiality class and remote unauthenticated trust boundary match, but this variant does not escapeMEDIA_ROOT. - Not demonstrated: outside-root read on fixed/current branches, arbitrary-file discovery, account takeover, file write, code execution, session forgery, or access through every reverse proxy.
Root Cause
The same endpoint uses two representations of one attacker-controlled path:
media_path = safe_join(settings.MEDIA_ROOT, path) # normalized target
...
is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)
...
return FileResponse(open(media_path, "rb"))
For path == "base/icon/../../private.txt", filesystem normalization produces MEDIA_ROOT/private.txt, which is still contained and therefore accepted by safe_join(). Authorization nevertheless sees the raw string beginning with base/icon/ and classifies it as public. The final open uses the normalized target, so a private file is served without authentication. Tag 1.6.0 uses the equivalent single-prefix test (exempted_folders) after the same safe_join() call.
This is root-cause-equivalent to the submitted issue at the sink and trust boundary: an untrusted route path controls the file selected by protected_media() and the endpoint opens it without enforcing the intended security boundary. The missed boundary here is the public-media subtree rather than MEDIA_ROOT itself.
Known containment fix: 67ac2056813ee95d4c4a0bfe7c0124a361cb6c48.
Reproduction Steps
- Run
PRUVA_ROOT=/path/to/bundle bundle/vuln_variant/reproduction_steps.shfrom any directory. - The script resolves exact source identities, materializes immutable git archives, reads each target's
SECURITY.md, starts the real Django WSGI route in a target process, and sends raw HTTP requests from a distinct attacker process. - It runs submitted 1.5.0 first, patched 1.6.0 second, then
release/v2.1.0, current 1.x/master, and current v2. - Each target gets fresh inside, outside, and private-in-root canaries. The script tests the same plain traversal, double-encoded traversal, public-prefix normalization request, and a positive public in-root control.
- Expected success is exit 0 and the final line:
PASS: public-prefix normalization bypass reads private media on fixed 1.6.0 and both current branches.
Evidence
bundle/logs/vuln_variant/reproduction_steps.log— final complete execution transcript.bundle/logs/vuln_variant/matrix_summary.json— structured five-target, three-candidate result.bundle/logs/vuln_variant/source_identity.log,fixed_version.txt, andlatest_version.txt— exact submitted, fixed, release, and latest branch revisions.bundle/logs/vuln_variant/candidate_matrix.json— source inspection and bounded candidate matrix.bundle/logs/vuln_variant/security_policy_{submitted,fixed,newer_release,latest_v1,latest_v2}.md— threat-model/security-policy copies read during the run.bundle/logs/vuln_variant/runtime_<role>/request_*.jsonandresponse_*.json— scrubbed raw request targets and exact-canary comparisons.bundle/logs/vuln_variant/runtime_<role>/result.json— per-target runtime oracle.bundle/logs/vuln_variant/runtime_<role>/server.log— live Django access logs preserving traversal targets.bundle/logs/vuln_variant/runtime_<role>/runtime_binding.json— exactbase/views.pyandbase/urls.pypaths/hashes and runtime identity.
Key final observations:
plain outside-root traversal:
submitted 1.5.0=200; release/v2.1.0=200
fixed 1.6.0=404; latest 1.x=404; latest v2=404
public-prefix normalization private-media read:
submitted=200; fixed 1.6.0=200; release/v2.1.0=200;
latest 1.x=200; latest v2=200
every body_equals_private_canary=true
positive /media/base/icon/<inside-canary> controls:
all targets=200 and body_equals_inside_canary=true
Recommendations / Next Steps
- Perform authorization against a single canonical relative path, not the raw route string.
- For every public prefix, compute a canonical public directory with
safe_join(MEDIA_ROOT, prefix)and require the canonical target to remain beneath that directory using path-aware containment (os.path.commonpath()or equivalent), including a component boundary. - Reject raw or decoded dot segments before authorization as defense in depth, but do not rely on string filtering alone.
- Prefer explicit storage namespaces or a dedicated public-media endpoint/storage backend rather than mixing public and private files behind one route.
- Resolve symlinks or open relative to a directory descriptor if untrusted users can influence filesystem links;
safe_join()is lexical containment and does not itself enforce realpath containment. - Add end-to-end tests for each public prefix with
prefix/../private,prefix/../../private, duplicate separators, percent encodings, and symlink targets, while retaining positive public and authenticated-private controls.
Additional Notes
- Idempotency: confirmed. The final stage script completed successfully twice consecutively with fresh random canaries and ports.
- The final runtime used Horilla's real URLconf, exact target
base/urls.py, exact targetbase/views.py, and Django WSGI server. Source hashes are bound in eachruntime_binding.json. - The Docker daemon became unavailable after the reproduction stage. To preserve fresh runtime validation, the stage used distinct local target/attacker processes over TCP. Optional ABI-bound document/data/image imports were stubbed only to accommodate the worker's Python ABI; affected URL/view files were not modified and are hash-bound to git. The v2 dependency cache advertised Django 4.2.24 although v2 requires 5.2, so a narrowly scoped Django compatibility namespace reports 5.2 and supplies the unchanged
safe_joinsemantics needed by current v2. This limitation does not alter the vulnerable raw-prefix/open logic, but production-container confirmation remains useful before release. - C3's path remains within
MEDIA_ROOT; it must not be described as arbitrary host filesystem traversal.
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.