REPRO-2026-00299: Verified Reproduction
REPRO-2026-00299: Horilla protected media public-prefix normalization bypass: unauthenticated private in-root read on the repaired release
REPRO-2026-00299 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-00299.
What Is REPRO-2026-00299?
REPRO-2026-00299 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-00299).
REPRO-2026-00299 Severity
REPRO-2026-00299 is rated high severity.
High — serious impact or readily exploitable. Prioritize remediation.
How to Reproduce REPRO-2026-00299
pruva-verify REPRO-2026-00299 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00299/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for REPRO-2026-00299
- reached the target end-to-end
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
requested media path beginning with an allowlisted public prefix followed by dot segments (../) that normalize to a private in-root file; no header/cookie/credential
- GET /media/base/icon/../../<private-in-root>
- base.views.protected_media (1.6.0 b3bd29d1, dev/v2.0 77f515c7)
How the agent worked
Root Cause and Exploit Chain for REPRO-2026-00299
Horilla's base/views.py::protected_media() view authorizes media requests with a
raw-prefix test, is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes), evaluated on the raw request path (the string captured by
the URL route). The filesystem object is then resolved with
safe_join(settings.MEDIA_ROOT, path), which canonicalizes dot segments. Because the
authorization decision and the filesystem decision use two different forms of the same
path, an unauthenticated caller can request
/media/base/icon/../../<private-in-root-file>: the raw path begins with the
allowlisted base/icon/ prefix (so is_public_asset is true and authentication is
skipped), while safe_join normalizes base/icon/../../ away and serves a private file
that lives elsewhere inside MEDIA_ROOT. safe_join still contains the result inside
MEDIA_ROOT, so this is an authorization bypass, not path traversal. The bypass is
present on the repaired releases 1.6.0 (b3bd29d1) and dev/v2.0 (77f515c7), and the
primitive already existed on the pre-repair 1.5.0 (61bd5173).
- Package/component affected:
horilla-hr—base/views.py::protected_media(), routed bybase/urls.pyasre_path(r"^media/(?P<path>.*)$", views.protected_media, name="protected_media"). - Affected versions:
1.6.0(released, current) anddev/v2.0@77f515c7(repaired 2.x line). The pre-repair1.5.0is also affected (the raw-prefix test predates the repair; it usedexempted_folders = ["base/icon/"]andos.path.join). - Risk level and consequences: High confidentiality impact, low exploitability
barrier. Any unauthenticated network caller who can reach the
/media/<path>HTTP route can read arbitrary files that reside insideMEDIA_ROOTand outside the public prefixes, by prepending an allowlisted public prefix and dot segments. No credential, cookie, token, or Referer is required.
Impact Parity
- Disclosed/claimed maximum impact: Unauthenticated read of a private in-root media
object (
authz_bypass) on the repaired release. - Reproduced impact from this run: Unauthenticated read of an exact private canary
placed at
MEDIA_ROOT/secret/<canary>(outside everypublic_media_prefixesentry) viaGET /media/base/icon/../../secret/<canary>returning HTTP 200 with the canary as the response body, on both1.6.0anddev/v2.0, across two clean runs with fresh canaries. - Parity:
full— the claimed authz bypass is demonstrated end-to-end through the real HTTP route, bounded to in-root read. - Not demonstrated: Path traversal / outside-
MEDIA_ROOTread (control 3 provessafe_joinstill contains), arbitrary file read, account takeover, session forgery, or code execution. None of these are claimed by this ticket.
Root Cause
The 1.x authorization repair (b6eaec1386d8b8741a42fe7c78f318f073375791) and the 2.x
authorization repair (7ad517e54ab0b60afe1c632eb42196bd1a74b10a) replaced the previous
Referer-based authorization branch with a raw-prefix allowlist:
# base/views.py (1.6.0 and dev/v2.0)
media_path = safe_join(settings.MEDIA_ROOT, path) # canonical, contained
...
is_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)
if not is_public_asset:
jwt_user = is_jwt_token_valid(request.META.get("HTTP_AUTHORIZATION", ""))
if not request.user.is_authenticated and not jwt_user:
... return redirect("login")
return FileResponse(open(media_path, "rb"))
public_media_prefixes = ("base/icon/", "base/company/icon/", "recruitment/candidate/profile/"). path is the raw URL-captured string. safe_join
(django.utils._os.safe_join) calls os.path.abspath/normpath and therefore collapses
base/icon/../../secret/x to MEDIA_ROOT/secret/x while still confirming the result is
inside MEDIA_ROOT. The authorization gate, however, sees the un-normalized string, so
"base/icon/../../secret/x".startswith("base/icon/") is True and authentication is
skipped. The decision is made on the raw path; the file is served from the canonical
path. The pre-repair 1.5.0 already had path.startswith(folder) against
exempted_folders = ["base/icon/"], so the normalization-mismatch primitive predates the
repair; the repair merely removed the Referer branch and widened the prefix set.
Reproduction Steps
- Reference:
bundle/repro/reproduction_steps.sh. - What the script does:
- Installs
uv+ a managed CPython 3.12 (the sandbox ships 3.14, unsupported by the pinned Django 4.2.x / 5.2 releases), and reuses/clones thehorilla-hrmirror. - For each pinned ref (
1.6.0=b3bd29d1,dev/v2.0=77f515c7,1.5.0=61bd5173) it creates agit worktree, builds an isolated venv from that ref'srequirements.txt, generates + applies migrations against a fresh empty SQLite DB (noDATABASE_URL/REDIS_URL-> SQLite + LocMem), starts the real Horilla app withpython manage.py runserver 127.0.0.1:<port> --noreload, and reaches it from a separatecurlattacker process with--path-as-is(dot segments preserved verbatim). - Per ref it creates a random private canary at
MEDIA_ROOT/secret/<rand>.txt(outside every public prefix) and a public canary atMEDIA_ROOT/base/icon/<rand>.txt, then runs the full matrix twice with new random canaries each run:- readiness
GET /login/(non-5xx); GET /media/secret/<canary>unauthenticated -> must be denied (302) and must not contain the canary (oracle 2);GET /media/base/icon/../../secret/<canary>unauthenticated -> must be 200 with body == private canary (oracle 3+4);GET /media/base/icon/<public>-> 200 (control 1: allowlist alive);GET /media/base/icon/../../../../../../etc/passwd-> 404 (control 3: containment intact; repaired refs only);GET /media/secret/<canary>withReferer: http://attacker.invalid/login-> 302 (control 2: Referer repair intact; repaired refs only).
- readiness
- For
1.5.0it checks control 4 (the dot-segment request returns the canary, proving the primitive predates the repair) on both runs. - It records the server-observed path for every request, the outgoing request
headers (proving no Cookie/Authorization), response status/headers/body, source
identities, and the modified-file inventory, then writes
runtime_manifest.jsonandvalidation_verdict.json.
- Installs
- Expected evidence of reproduction: every repaired ref satisfies the full oracle on
both runs; the dot-segment request returns HTTP 200 with the exact private canary
while the direct private request is 302;
safe_joinstill 404s an escaping path; no cookie/Authorization is sent.
Evidence
- Oracle summaries:
bundle/logs/repro/{1.6.0,dev_v2.0,1.5.0}_oracle_run{1,2}.json - Server-observed paths (dot segments preserved verbatim):
bundle/logs/repro/{ref}_observed_paths_run{1,2}.txt - Response bodies/headers:
bundle/logs/repro/{ref}_{direct_private,dotseg_bypass,public_icon,escape_containment,direct_private_referer}_run{1,2}.{resp.body,resp.hdr,code,req} - Server access logs:
bundle/logs/repro/{ref}_server.log - Source identities:
bundle/logs/repro/{ref}_head.txt - Modified-file inventory:
bundle/logs/repro/{ref}_modified_files.txt - Full run log:
bundle/logs/reproduction_steps.log
Key excerpts (1.6.0, run 1):
# server-observed paths (dot segments NOT collapsed by the client or server)
"GET /media/secret/private_09c89ea4578dcd1f.txt HTTP/1.1" 302 0
"GET /media/base/icon/../../secret/private_09c89ea4578dcd1f.txt HTTP/1.1" 200 31
"GET /media/base/icon/public_09c89ea4578dcd1f.txt HTTP/1.1" 200 28
"GET /media/base/icon/../../../../../../etc/passwd HTTP/1.1" 404 52039
# outgoing request headers for the bypass request (no Cookie, no Authorization)
> GET /media/base/icon/../../secret/private_09c89ea4578dcd1f.txt HTTP/1.1
> Host: 127.0.0.1:19011
> User-Agent: curl/8.18.0
> Accept: */*
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 31
# bypass response body == private canary
PRIVATE_SECRET_09c89ea4578dcd1f
# direct private response
HTTP/1.1 302 Found
Location: /login/
dev/v2.0@77f515c7 run 2 produced the identical pattern (dotseg 200 with the canary,
direct 302, escape 404, referer 302). 1.5.0 run 2: dotseg 200 with the canary
(PRIVATE_SECRET_064ba22f6374f70e), confirming the primitive predates the repair.
Aggregate result across both clean runs:
1.6.0 pass=1, dev_v2.0 pass=1, 1.5.0 pass=1 -> REPRO_CONFIRMED=1.
Environment: Django runserver (WSGIServer/0.2 CPython/3.12.13), SQLite + LocMem cache,
MEDIA_ROOT=/.../media/, MEDIA_URL=/media/, DEBUG=True (default), no
DATABASE_URL/REDIS_URL. curl/8.18.0 with --path-as-is and -v from a separate
process; no Cookie/Authorization sent on the oracle requests.
Recommendations / Next Steps
- Fix: make the authorization decision on the canonical path, not the raw one. After
safe_joinresolvesmedia_path, derive the relative path fromMEDIA_ROOT(e.g.rel = os.path.relpath(media_path, settings.MEDIA_ROOT)with a containment guard) and testrel.startswith(prefix)against that normalized relative path — or reject any request path that contains dot segments before authorization. Either approach aligns the authorization string with the filesystem string. - Defense in depth: do not rely on a string-prefix allowlist for unauthenticated media serving; serve public assets from a separate public root/directory not shared with private uploads, so a prefix test can never reach private objects.
- Upgrade guidance: no released version currently denies this variant; the repaired
releases (
1.6.0,dev/v2.0) are bypassable. A patch is required. - Testing: add a regression test that requests
/media/base/icon/../../<private>unauthenticated and asserts denial (302/403) while/media/base/icon/<public>stays 200, plus a containment test that an escaping path 404s.
Additional Notes
- Idempotency: the script recreates worktrees, fresh SQLite DBs, and new random
canaries on every invocation; venvs are reused across the two in-script runs (they
depend only on the pinned ref's
requirements.txt). Re-running reproduces the same oracle outcome. - Scope boundary: the resolved file never leaves
MEDIA_ROOT;safe_joinis satisfied (control 3: escaping path -> 404). This is an authorization bypass bounded to unauthenticated in-root read, not path traversal (which is covered byGHSA-x52c-5hrq-76pq). - Modified-file inventory:
git status --porcelainper worktree recorded no application-source changes;base/views.py,base/urls.py, routing, middleware, authentication, and file-open behavior were not modified. Only gitignored Django-generated migration files (the1.6.0/1.5.0tags ship onlymigrations/__init__.py) and runtime SQLite files were added. - Client caveat: the HTTP client must preserve dot segments verbatim (
curl --path-as-is); a client that collapses../before sending invalidates the run. The server access log confirms the dots reached the Horilla process unmodified.
REPRO-2026-00299 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.
Unknown error
Artifacts and Evidence for REPRO-2026-00299
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix REPRO-2026-00299
FAQ: REPRO-2026-00299
Is REPRO-2026-00299 exploitable?
How severe is REPRO-2026-00299?
How can I reproduce REPRO-2026-00299?
Is the REPRO-2026-00299 reproduction verified?
References for REPRO-2026-00299
Authoritative sources for REPRO-2026-00299 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.