GHSA-9WJX-4J4R-FF8W: Verified Reproduction
GHSA-9WJX-4J4R-FF8W: Reported Horilla protected media Referer authentication bypass
GHSA-9WJX-4J4R-FF8W is verified against horilla/horilla-hr · github. Vulnerability class: Auth Bypass. This high reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00296.
What Is GHSA-9WJX-4J4R-FF8W?
GHSA-9WJX-4J4R-FF8W is a high-severity Auth Bypass vulnerability affecting horilla/horilla-hr. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00296).
GHSA-9WJX-4J4R-FF8W Severity
GHSA-9WJX-4J4R-FF8W is rated high severity.
High — serious impact or readily exploitable. Prioritize remediation.
How to Reproduce GHSA-9WJX-4J4R-FF8W
pruva-verify REPRO-2026-00296 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00296/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for GHSA-9WJX-4J4R-FF8W
- reached the target end-to-end
- on the real production code path
- high confidence
Unauthenticated /media/<path> URL containing base/icon/../../private-data/... dot-segments; no cookie, authorization header, or Referer is required for the variant.
- /media/base/icon/../../private-data/20260722T061846Z-17685/fixed-secret.txt
reproduction_steps.sh Bypass of Horilla 1.6.0 protected_media fix: an unauthenticated /media/ request whose raw path starts with an allowlisted public prefix such as base/icon/ but then uses ../../ dot-segments is classified as public while safe_join resolves it to a private in-root media file, which is returned without cookie, token, or R…
How the agent worked
Root Cause and Exploit Chain for GHSA-9WJX-4J4R-FF8W
- Affected version reproduced: tag
1.5.0, commit61bd5173220d19925ad8220db9152a75c881ea73. - Fixed version verified: tag
1.6.0, commitb3bd29d15819cbece45c58e6268ddd0614e387d6; source fix commitb6eaec1386d8b8741a42fe7c78f318f073375791is an ancestor of the fixed tag. - Risk level and consequences: high severity authorization bypass. An unauthenticated attacker who knows or can guess a private in-root media path can read that file without a cookie, session, bearer token, JWT, or any application credential. This run demonstrated unauthorized read of a randomly generated private canary under
MEDIA_ROOTand did not demonstrate traversal, account takeover, session forgery, or code execution.
Impact Parity
- Disclosed/claimed maximum impact:
authz_bypass/ unauthorized read of an exact nonpublic in-root media file via attacker-controlledRefereron the real Horilla/media/<path>route. - Reproduced impact from this run: full production-path
authz_bypass. Horilla 1.5.0 denied the private canary with noRefererbut returned HTTP 200 and the exact canary body when onlyReferer: http://attacker.invalid/loginwas added. Horilla 1.6.0 denied both requests while still serving a generated public liveness asset. - Parity:
full. - Not demonstrated: path traversal, broader arbitrary file read outside
MEDIA_ROOT, authenticated privilege changes, account takeover, session forgery, denial of service, or code execution.
Root Cause
In Horilla 1.5.0, protected_media(request, path) computes:
referer_path = urlparse(request.META.get("HTTP_REFERER", "")).path
and then skips the authentication/JWT check when referer_path is one of several public pages such as /login. Because Referer is fully controlled by the HTTP client and is not an authentication boundary, an unauthenticated attacker can set a cross-origin value whose path is /login. The view then falls through to:
return FileResponse(open(media_path, "rb"))
for any existing media path not under the small exempted folder list. The source evidence in bundle/artifacts/horilla_referer_bypass/20260722T055646Z-13223/source/protected_media_hunks.log shows this vulnerable logic in tag 1.5.0 and the fixed 1.6.0 logic. The fixed version removes public-page Referer authorization and instead permits only explicit public media path prefixes, uses safe_join(settings.MEDIA_ROOT, path), and requires an authenticated user or valid JWT for all nonpublic media paths. The known fix commit is b6eaec1386d8b8741a42fe7c78f318f073375791.
Reproduction Steps
- Run
bundle/repro/reproduction_steps.shfrom any directory, optionally withPRUVA_ROOT=/path/to/bundle. - The script reads
bundle/project_cache_context.json, reuses or creates/pruva/project-cache/repo, verifies the vulnerable and fixed tag commits, installs each tag's ownrequirements.txtinto isolated dependency directories, prepares migrated SQLite runtime templates, starts real Horilla Djangomanage.py runservercontainers, and sends attacker-processcurl --path-as-isrequests to the real/media/<path>route. - For each of two clean attempts per role, the script creates a new private canary under a nonpublic
MEDIA_ROOT/private-data/<run>/...path and a public liveness canary underMEDIA_ROOT/base/icon/.... It records requests, responses, curl traces, container logs, source identities, and modified-file inventories. - Expected evidence: vulnerable attempts show public liveness
200, baseline private request with noReferer302denied with no canary in the body, and spoofedReferer: http://attacker.invalid/login200with response body equal to the private canary. Fixed attempts show public liveness200while both baseline and spoofed private requests return302and do not contain the canary.
Evidence
- Main reproduction script:
bundle/repro/reproduction_steps.sh. - Latest successful runtime manifest:
bundle/repro/runtime_manifest.json. - Latest structured verdict:
bundle/repro/validation_verdict.json. - Latest complete successful proof directory:
bundle/artifacts/horilla_referer_bypass/20260722T055646Z-13223/. - Matrix summary:
bundle/artifacts/horilla_referer_bypass/20260722T055646Z-13223/matrix_summary.jsonwith"all_oracles_passed": true. - Source comparison:
bundle/artifacts/horilla_referer_bypass/20260722T055646Z-13223/source/protected_media_hunks.log. - Per-attempt request/response artifacts include:
- Vulnerable attempt 1 request pair:
attempts/vuln_1/baseline_no_referer_request.txtandattempts/vuln_1/trigger_spoofed_referer_request.txt. - Vulnerable attempt 1 body evidence:
attempts/vuln_1/private_canary.txtandattempts/vuln_1/trigger_spoofed_referer_response_body.binmatch exactly. - Fixed attempt controls:
attempts/fixed_1/attempt_result.jsonandattempts/fixed_2/attempt_result.jsonshow both private requests denied and the public liveness route working.
- Vulnerable attempt 1 request pair:
- Key latest matrix results:
{
"all_oracles_passed": true,
"attempt_results": [
{"role": "fixed", "attempt": 1, "public_status": "200", "baseline_status": "302", "trigger_status": "302", "oracle_passed": true},
{"role": "fixed", "attempt": 2, "public_status": "200", "baseline_status": "302", "trigger_status": "302", "oracle_passed": true},
{"role": "vuln", "attempt": 1, "public_status": "200", "baseline_status": "302", "trigger_status": "200", "oracle_passed": true},
{"role": "vuln", "attempt": 2, "public_status": "200", "baseline_status": "302", "trigger_status": "200", "oracle_passed": true}
]
}
- The final script revision was executed successfully twice consecutively. The latest run completed at
2026-07-22T06:00:10Zand the prior successful run completed at2026-07-22T05:56:08Z.
Recommendations / Next Steps
- Upgrade Horilla deployments to version 1.6.0 or later, or apply fix commit
b6eaec1386d8b8741a42fe7c78f318f073375791if maintaining a downstream branch. - Do not use
Referer,Origin, or other client-controlled metadata as an authorization decision for protected media. - Keep a strict allowlist of genuinely public media prefixes and require a real authenticated user/session or valid token for every other media path.
- Retain path containment checks such as Django
safe_join(settings.MEDIA_ROOT, path)and verify that only regular files underMEDIA_ROOTcan be opened. - Add regression tests at the real HTTP route level: absent
Referer, cross-origin/loginReferer, same-origin public pages, authenticated access, JWT access, and public-prefix liveness should all be covered.
Additional Notes
- Idempotency confirmation:
bundle/repro/reproduction_steps.shpassed two consecutive clean runs. Each run creates fresh random private and public canaries and clean per-attempt SQLite databases. - Runtime accommodations: to start the tagged application in this clean environment, the script installs each tag's dependencies and prepares SQLite migrations using
manage.py makemigrationsfor the Horilla project apps beforemigrate --run-syncdb. It records modified-file inventories. It does not modifybase/views.py,base/urls.py, routing, middleware, authentication logic, or file-open behavior. - The attacker requests are made by
curlfrom outside the Horilla Django process, do not follow redirects, and explicitly record that no cookie or authorization header is sent.
Variant Analysis & Alternative Triggers for GHSA-9WJX-4J4R-FF8W
Fix Coverage / Assumptions
The original fix commit is b6eaec1386d8b8741a42fe7c78f318f073375791, included in the fixed release tag 1.6.0 at commit b3bd29d15819cbece45c58e6268ddd0614e387d6.
The fix changes base/views.py::protected_media() by:
- removing the old
public_pageslist and theurlparse(request.META.get("HTTP_REFERER", "")).pathauthorization bypass; - introducing
public_media_prefixes = ("base/icon/", "base/company/icon/", "recruitment/candidate/profile/"); - using
safe_join(settings.MEDIA_ROOT, path)to keep file opens underMEDIA_ROOT; - checking
os.path.isfile(media_path)in addition to existence; and - requiring an authenticated session or valid JWT only when
is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)is false.
The invariant the fixed code assumes is: if the raw route parameter starts with an allowlisted public prefix, then the normalized filesystem path is also inside that public subtree. That invariant is false when the attacker includes dot-segments after the allowlisted prefix. The code path explicitly covers traversal outside MEDIA_ROOT through safe_join(), but it does not cover traversal from an allowlisted public subtree to a different nonpublic subtree that is still inside MEDIA_ROOT.
The target repository's SECURITY.md describes unauthorized access to sensitive resources as in scope and recommends strong authentication/authorization and user-input validation. It does not exclude protected media authorization bypasses from the threat model. The variant stays within the same attacker-facing trust boundary as the parent issue: an unauthenticated remote HTTP client controls the /media/<path> request path.
Variant / Alternate Trigger
The validated bypass is a different attacker-controlled data path from the parent Referer trigger:
- Parent trigger: request the direct private path and add
Referer: http://attacker.invalid/login. - Variant trigger: omit
Refererentirely and request a raw URL path that starts with an allowlisted public media prefix but uses dot-segments to normalize to a private media path, for example:/media/base/icon/../../private-data/<run>/fixed-secret.txt
Entry point:
- Real Horilla Django HTTP route:
base/urls.py,re_path(r"^media/(?P<path>.*)$", views.protected_media, name="protected_media").
Relevant fixed code path:
base/views.py::protected_media()lines 7508-7540 in tag1.6.0.- Primary sink:
return FileResponse(open(media_path, "rb"))after public-prefix authorization is skipped. - Bypass check:
is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)is evaluated on the raw route parameter, not on the normalized relative path.
The runtime matrix in bundle/vuln_variant/reproduction_steps.sh tests both versions side by side. On the vulnerable tag, the original Referer trigger and the dot-segment public-prefix variant both return the private canary. On the fixed tag, the original Referer trigger is denied as expected, but the dot-segment public-prefix variant returns the exact private canary.
- Submitted vulnerable target tested: tag
1.5.0, commit61bd5173220d19925ad8220db9152a75c881ea73. - Fixed/bypass target tested: tag
1.6.0, commitb3bd29d15819cbece45c58e6268ddd0614e387d6. - Latest/default branch source inspected:
origin/master, commit11c4e3a2596c58f2381bda4c6bbc319a4430b097; it retained the same raw-prefix public-media authorization pattern, though the runtime-confirmed bypass target is the fixed 1.6.0 release. - Risk level and consequences: high severity authorization bypass. An unauthenticated attacker who knows or can guess a private in-root media path can read that file if they can express it relative to an allowlisted public prefix using dot-segments while staying under
MEDIA_ROOTafter normalization.
Impact Parity
- Disclosed/claimed maximum impact for the parent:
authz_bypass, specifically unauthorized read of an exact nonpublic in-root media file via the real/media/<path>route. - Reproduced impact from this variant run:
authz_bypasson the fixed release. The fixed target denied the direct private path with noReferer, denied the original direct private path with spoofed/loginReferer, but returned HTTP 200 and the exact private canary for the dot-segment public-prefix URL. - Parity:
fullfor unauthorized read of an exact in-root private media file. - Not demonstrated: path traversal outside
MEDIA_ROOT, account takeover, session forgery, write access, denial of service, or code execution.
Root Cause
The same protected-media authorization sink remains reachable because the fix separates filesystem containment normalization from public-prefix authorization. In fixed Horilla 1.6.0, safe_join(settings.MEDIA_ROOT, path) correctly prevents escaping the media root, but the public/private authorization decision is still based on the attacker-controlled raw path string:
is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)
For path = "base/icon/../../private-data/.../fixed-secret.txt", the raw string starts with base/icon/, so the function skips authentication. After safe_join(), the resolved file is MEDIA_ROOT/private-data/.../fixed-secret.txt, which is nonpublic but still inside MEDIA_ROOT. The vulnerable sink then serves it with FileResponse(open(media_path, "rb")).
This is a bypass of the fix commit b6eaec1386d8b8741a42fe7c78f318f073375791: the commit removed Referer authorization but introduced/retained a raw-prefix assumption for public media that can be defeated with path normalization.
Reproduction Steps
- Run
bundle/vuln_variant/reproduction_steps.shfrom any directory, optionally settingPRUVA_ROOT=/path/to/bundle. - The script prepares isolated worktrees for Horilla 1.5.0 and 1.6.0, installs each tag's requirements into reusable dependency directories, prepares SQLite runtime databases, starts the real Django
manage.py runservertarget in Docker, and sends attacker-processcurl --path-as-isrequests to the live/media/<path>route. - For each target it creates a private canary outside public prefixes and a public liveness canary under
base/icon/. It then sends: a public liveness request, a direct private request with noReferer, a direct private request with the original spoofed/loginReferer, and the dot-segment public-prefix variant request with noReferer. - Expected fixed-target evidence: public liveness
200; direct private no-Referer302; direct private spoofed-Referer302; dot-segment variant200with response body exactly equal to the private canary. Exit code0means the fixed-release bypass was reproduced.
Evidence
Primary reproducer:
bundle/vuln_variant/reproduction_steps.sh
Latest successful matrix:
- Summary:
bundle/logs/vuln_variant/latest_matrix_summary.json - Latest per-run log:
bundle/logs/vuln_variant/reproduction_steps_20260722T061846Z-17685.log - Runtime proof directory:
bundle/vuln_variant/runtime_20260722T061846Z-17685/ - Fixed attempt result:
bundle/vuln_variant/runtime_20260722T061846Z-17685/attempts/fixed/attempt_result.json - Fixed variant request:
bundle/vuln_variant/runtime_20260722T061846Z-17685/attempts/fixed/dotsegment_public_prefix_variant_request.txt - Fixed variant response body:
bundle/vuln_variant/runtime_20260722T061846Z-17685/attempts/fixed/dotsegment_public_prefix_variant_response_body.bin - Fixed private canary:
bundle/vuln_variant/runtime_20260722T061846Z-17685/attempts/fixed/private_canary.txt - Source/patch/threat-model evidence:
bundle/logs/vuln_variant/patch_and_security_scope.log - Tested fixed source identity:
bundle/vuln_variant/source_identity.json
Key latest matrix excerpt:
{
"fixed_bypass_oracle_passed": true,
"variant_confirmed_on_fixed": true,
"attempt_results": [
{
"role": "fixed",
"direct_private_no_referer_status": "302",
"direct_private_spoofed_referer_status": "302",
"dotsegment_public_prefix_variant_status": "200",
"dotsegment_variant_returned_exact_canary": true,
"oracle_passed": true
}
]
}
Environment details are captured in bundle/vuln_variant/runtime_manifest.json, including Docker, Python base image, exact vulnerable/fixed commits, latest inspected default-branch commit, and proof artifact paths.
Recommendations / Next Steps
- Do not authorize public media using the raw route parameter. Normalize first, then compute a normalized relative path from
MEDIA_ROOTand apply public-prefix checks to that normalized relative path. - Reject any public-media request whose raw path contains dot-segments, encoded dot-segments, backslashes, duplicated separators, or whose normalized relative path differs materially from the requested public-prefix path.
- After
safe_join(), require that a public asset's normalized absolute path is under one of the normalized allowlisted public directories, not merely that the raw path starts with a string prefix. - Add route-level regression tests for at least:
/media/base/icon/../../private-data/secret.txt/media/base/company/icon/../../../private-data/secret.txt/media/recruitment/candidate/profile/../../../private-data/secret.txt- URL-encoded dot-segment equivalents if the framework decodes them before routing
- normal public liveness files and authenticated/JWT protected access
- Continue to avoid
Referer,Origin, or other client-controlled metadata as authorization signals.
Additional Notes
- Idempotency confirmed:
bundle/vuln_variant/reproduction_steps.shwas executed twice consecutively and returned exit code0both times. The successful runs completed around2026-07-22T06:18:44Zand2026-07-22T06:22:03Z. - The variant is not a claim of filesystem traversal outside the media root. The demonstrated file remains inside
MEDIA_ROOT; the security failure is bypassing the nonpublic-media authentication check after raw-prefix misclassification. - The script records exact tested source revisions in
bundle/vuln_variant/source_identity.jsonandbundle/logs/vuln_variant/fixed_version.txt.
GHSA-9WJX-4J4R-FF8W 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-9WJX-4J4R-FF8W
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix GHSA-9WJX-4J4R-FF8W
FAQ: GHSA-9WJX-4J4R-FF8W
Is GHSA-9WJX-4J4R-FF8W exploitable?
How severe is GHSA-9WJX-4J4R-FF8W?
How can I reproduce GHSA-9WJX-4J4R-FF8W?
Is the GHSA-9WJX-4J4R-FF8W reproduction verified?
References for GHSA-9WJX-4J4R-FF8W
Authoritative sources for GHSA-9WJX-4J4R-FF8W — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.