REPRO-2026-00298: Verified Reproduction
REPRO-2026-00298: Horilla protected media composed chain: unauthenticated outside-root file read, with necessity controls for each component defect
REPRO-2026-00298 is verified against horilla/horilla-hr · github. Vulnerability class: Path Traversal. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00298.
What Is REPRO-2026-00298?
REPRO-2026-00298 is a high-severity Path Traversal vulnerability affecting horilla/horilla-hr. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00298).
REPRO-2026-00298 Severity
REPRO-2026-00298 is rated high severity.
High — serious impact or readily exploitable. Prioritize remediation.
How to Reproduce REPRO-2026-00298
pruva-verify REPRO-2026-00298 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00298/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for REPRO-2026-00298
- 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
media path (../canary_outside.txt) and Referer header (http://attacker.invalid/login); no credential
- GET /media/../canary_outside.txt with Referer: http://attacker.invalid/login
How the agent worked
Root Cause and Exploit Chain for REPRO-2026-00298
Horilla HRMS's protected_media() view in base/views.py composes two independently necessary defects: (1) a Referer-based authorization bypass that treats any request whose Referer path matches a public page (e.g., /login) as authenticated, and (2) an uncontained os.path.join() path traversal that allows the requested media path to escape MEDIA_ROOT via dot-segment traversal (../). A single HTTP request carrying no cookie, no session, no JWT, and no bearer token — only a crafted Referer: http://attacker.invalid/login header and a traversal path /media/../canary_outside.txt — returns the contents of a file stored outside the media directory. This reproduction establishes the composition by showing that removing either defect breaks the chain while the other remains independently exploitable.
- Package/component affected:
base/views.py—protected_media()view, routed viabase/urls.pyasre_path(r"^media/(?P<path>.*)$", views.protected_media, name="protected_media"). - Affected versions: Horilla HRMS
1.5.0(commit61bd5173220d19925ad8220db9152a75c881ea73). Both defects are present. The traversal fix landed in commit67ac2056813ee95d4c4a0bfe7c0124a361cb6c48(2026-02-23) and the authorization fix landed in commitb6eaec1386d8b8741a42fe7c78f318f073375791(2026-05-20), both included in tag1.6.0(commitb3bd29d15819cbece45c58e6268ddd0614e387d6). - Risk level and consequences: High. An unauthenticated remote attacker can read arbitrary files accessible to the Horilla process by combining the Referer bypass with the path traversal. Impact is bounded to file read (info_leak); no code execution, privilege escalation, or account takeover is claimed.
Impact Parity
- Disclosed/claimed maximum impact: Unauthenticated arbitrary file read (info_leak) via the real
/media/<path>HTTP route. - Reproduced impact from this run: Unauthenticated read of a canary file stored outside
MEDIA_ROOTvia a single HTTP request withReferer: http://attacker.invalid/loginand path/media/../canary_outside.txt. Response body exactly equals the outside-root canary. No credential of any kind was sent. - Parity:
full. - Not demonstrated: No code execution, privilege escalation, session forgery, or secret extraction. The oracle is a random canary, not a real secret.
Root Cause
Defect 1: Referer Authorization Bypass (GHSA-9wjx-4j4r-ff8w)
The protected_media() view on 1.5.0 checks whether the URL path extracted from the Referer header is in a public_pages list:
referer_path = urlparse(request.META.get("HTTP_REFERER", "")).path
if referer_path not in public_pages and not any(
path.startswith(f) for f in exempted_folders
):
if not request.user.is_authenticated and not jwt_user:
# ... redirect to login
Since /login is in public_pages, any request with Referer: http://attacker.invalid/login bypasses the authentication check entirely — regardless of whether the request carries a cookie, session, or token. The attacker controls the Referer header, so this is a trivial authorization bypass.
Defect 2: Uncontained os.path.join() Path Traversal (GHSA-x52c-5hrq-76pq)
The view constructs the file path with:
media_path = os.path.join(settings.MEDIA_ROOT, path)
Python's os.path.join() does not contain the result within MEDIA_ROOT. If path contains .. segments (e.g., ../canary_outside.txt), the resulting path escapes the media directory. The os.path.exists() check and FileResponse(open(media_path, "rb")) call then operate on a file outside MEDIA_ROOT.
Composition
A single unauthenticated request with both Referer: http://attacker.invalid/login and path /media/../canary_outside.txt:
- The Referer bypass skips the authentication gate.
- The
os.path.join()traversal escapesMEDIA_ROOT. FileResponsereturns the outside-root file.
Fixes
- Traversal fix (commit
67ac2056): Replacedos.path.join()with Django'ssafe_join(), which raisesValueErrorif the path escapes the base directory. Also addedos.path.isfile()check. - Authorization fix (commit
b6eaec13, included in1.6.0): Removed theReferer-based authorization entirely. Replaced withpublic_media_prefixesthat only exempt specific asset directories (e.g.,base/icon/), requiring authentication for all other media.
Reproduction Steps
- Script:
bundle/repro/reproduction_steps.sh - What the script does:
- Clones (or reuses cached) the Horilla HRMS repository.
- For each of three upstream refs (
1.5.0,67ac2056,1.6.0), checks out the ref, installs itsrequirements.txt, runs migrations on a fresh SQLite database, creates two random canary files (one outsideMEDIA_ROOT, one inside at a nonpublic path), and starts the real Djangorunserver. - Sends real HTTP requests via
curl --path-as-is(preserving dot segments) from a separate attacker process to the running Horilla server. - 1.5.0 (vulnerable oracle): Sends the escaping request without Referer (expect 302 denied), with Referer (expect 200 + outside canary), and as an authenticated low-priv user (expect 200 + outside canary). Also sends an in-root request with Referer (expect 200 + in-root canary).
- 67ac2056 (containment necessity control): Sends the escaping chain request with Referer (expect 404 — safe_join blocks traversal) and an in-root request with Referer (expect 200 + in-root canary — Referer bypass still works).
- 1.6.0 (fixed control): Sends the chain request with Referer (expect 404), in-root request with Referer (expect 302 — no bypass), and a legitimate authenticated in-root request (expect 200 — service works).
- Runs the complete three-ref matrix twice from clean targets with new canaries.
- Expected evidence: All tests pass (exit 0). Evidence files under
bundle/repro/evidence/include request/response captures, HTTP headers, status codes, server logs with server-observed paths, and canary verification for every request.
Evidence
- Main log:
bundle/logs/reproduction_steps.log— complete execution output. - Run logs:
bundle/logs/reproduction_run1.log,bundle/logs/reproduction_run2.log(if separate). - Evidence directory:
bundle/repro/evidence/run{1,2}/{1.5.0,67ac2056,1.6.0}/request_*.txt— request metadata (method, URL, path, cookie/auth status, headers)response_*.txt— response bodyheaders_*.txt— response headersstatus_*.txt— HTTP status codeserver.log— Django dev server log with server-observed pathsserver_observed_paths.txt— extracted server-observed pathscheckout.log— git checkout and resolved commitmigrate.log— migration outputmodified_files.txt— startup accommodation inventorymedia_root_check.txt— MEDIA_ROOT and outside-canary path verificationoutside_canary_value.txt/inroot_canary_value.txt— canary valuescreate_user.log— low-priv user creation (is_staff=False, is_superuser=False)cookies.txt— session cookies for authenticated requests
- Runtime manifest:
bundle/repro/runtime_manifest.json - Validation verdict:
bundle/repro/validation_verdict.json
Key evidence excerpts (from initial validation)
On 1.5.0:
GET /media/../canary_outside.txtwithout Referer → HTTP 302 (redirect to login), no canary in body.GET /media/../canary_outside.txtwithReferer: http://attacker.invalid/login→ HTTP 200, body = outside-root canary.- Server log:
"GET /media/../canary_outside.txt HTTP/1.1" 302 0then"GET /media/../canary_outside.txt HTTP/1.1" 200 21.
On 67ac2056:
GET /media/../canary_outside.txtwith Referer → HTTP 404 (safe_join blocks traversal).GET /media/private_inroot/canary_inroot.txtwith Referer → HTTP 200, body = in-root canary.- Server log:
"GET /media/../canary_outside.txt HTTP/1.1" 404 52013then"GET /media/private_inroot/canary_inroot.txt HTTP/1.1" 200 47.
On 1.6.0:
GET /media/../canary_outside.txtwith Referer → HTTP 404 (safe_join blocks traversal).GET /media/private_inroot/canary_inroot.txtwith Referer → HTTP 302 (no Referer bypass, redirect to login).- Authenticated
GET /media/private_inroot/canary_inroot.txt→ HTTP 200, body = in-root canary (service works). - Server log:
"GET /media/../canary_outside.txt HTTP/1.1" 404 52013,"GET /media/private_inroot/canary_inroot.txt HTTP/1.1" 302 0,"GET /media/private_inroot/canary_inroot.txt HTTP/1.1" 200 47.
Recommendations / Next Steps
- Upgrade to 1.6.0 or later: Both defects are fixed in tag
1.6.0. Thesafe_join()containment and the removal of the Referer-based authorization together close the composed chain. - Do not restore Referer-based authorization: The Referer header is attacker-controlled and must not be used as an authorization mechanism. The
public_media_prefixesapproach in1.6.0is the correct pattern. - Always use
safe_join()for file path containment: Never useos.path.join()to construct paths that must stay within a base directory. - Testing recommendations: Add integration tests that send requests with attacker-controlled
Refererheaders and traversal paths toprotected_media(). Verify that unauthenticated requests with anyReferervalue are denied for non-public media.
Additional Notes
- Idempotency: The script is idempotent — it cleans up generated files (canaries, SQLite DB) before each ref checkout and uses fresh random canaries for each run. Running it multiple times produces the same pass/fail results.
- Canary approach: The oracle is a random canary file, never a real secret. No configuration files, databases,
/procentries, password material, credentials, or employee records are read or retained. - HTTP client:
curl --path-as-isis used to preserve dot segments verbatim. Redirects are not followed automatically (-Lnot used), so 302 responses are recorded as-is. - Modified files: The only startup accommodations are creating canary files and a SQLite database, which are untracked data files. No source files (
base/views.py,base/urls.py, routing, middleware, authentication, or file-open behavior) are modified. - Dependency note:
PyMuPDF==1.24.5(pinned inrequirements.txt) does not build on Python 3.14. A newerPyMuPDFwith pre-built wheels is installed instead. This does not affect theprotected_media()code path. - Out of scope: The
1.6.0dot-segment prefix-normalization bypass (tracked byHORILLA-PROTECTED-MEDIA-PREFIX-NORMALIZATION-BYPASS) is not tested here and is not used to satisfy or fail any control.
REPRO-2026-00298 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 REPRO-2026-00298
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix REPRO-2026-00298
FAQ: REPRO-2026-00298
Is REPRO-2026-00298 exploitable?
How severe is REPRO-2026-00298?
How can I reproduce REPRO-2026-00298?
Is the REPRO-2026-00298 reproduction verified?
References for REPRO-2026-00298
Authoritative sources for REPRO-2026-00298 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.