Skip to content

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.

REPRO-2026-00299 horilla/horilla-hr · github Path Traversal Jul 27, 2026 .txt
Severity
HIGH
Confidence
HIGH
Reproduced in
84m 5s
Tool calls
263
Spend
$4.05
01 · Overview

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).

02 · Severity & CVSS

REPRO-2026-00299 Severity

REPRO-2026-00299 is rated high severity.

HIGH threat level

High — serious impact or readily exploitable. Prioritize remediation.

How to Reproduce REPRO-2026-00299

$ pruva-verify REPRO-2026-00299
or 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
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for REPRO-2026-00299

Authorization bypass — reproduced
  • reached the target end-to-end
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
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

Attack chain
  1. GET /media/base/icon/../../<private-in-root>
  2. base.views.protected_media (1.6.0 b3bd29d1, dev/v2.0 77f515c7)
How the agent worked 712 events · 263 tool calls · 1h 24m
1h 24mDuration
263Tool calls
228Reasoning steps
712Events
28Dead-ends
Agent activity over 1h 24m
Policy
1
Support
14
Repro
398
Judge
145
Variant
149
Verify
1
0:0084:05

Root Cause and Exploit Chain for REPRO-2026-00299

Versions: 1.6.0 (released, current) and dev/v2.0@77f515c7 (repaired

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-hrbase/views.py::protected_media(), routed by base/urls.py as re_path(r"^media/(?P<path>.*)$", views.protected_media, name="protected_media").
  • Affected versions: 1.6.0 (released, current) and dev/v2.0@77f515c7 (repaired 2.x line). The pre-repair 1.5.0 is also affected (the raw-prefix test predates the repair; it used exempted_folders = ["base/icon/"] and os.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 inside MEDIA_ROOT and 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 every public_media_prefixes entry) via GET /media/base/icon/../../secret/<canary> returning HTTP 200 with the canary as the response body, on both 1.6.0 and dev/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_ROOT read (control 3 proves safe_join still 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

  1. Reference: bundle/repro/reproduction_steps.sh.
  2. 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 the horilla-hr mirror.
    • For each pinned ref (1.6.0=b3bd29d1, dev/v2.0=77f515c7, 1.5.0=61bd5173) it creates a git worktree, builds an isolated venv from that ref's requirements.txt, generates + applies migrations against a fresh empty SQLite DB (no DATABASE_URL/REDIS_URL -> SQLite + LocMem), starts the real Horilla app with python manage.py runserver 127.0.0.1:<port> --noreload, and reaches it from a separate curl attacker 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 at MEDIA_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> with Referer: http://attacker.invalid/login -> 302 (control 2: Referer repair intact; repaired refs only).
    • For 1.5.0 it 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.json and validation_verdict.json.
  3. 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_join still 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_join resolves media_path, derive the relative path from MEDIA_ROOT (e.g. rel = os.path.relpath(media_path, settings.MEDIA_ROOT) with a containment guard) and test rel.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_join is satisfied (control 3: escaping path -> 404). This is an authorization bypass bounded to unauthenticated in-root read, not path traversal (which is covered by GHSA-x52c-5hrq-76pq).
  • Modified-file inventory: git status --porcelain per 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 (the 1.6.0/1.5.0 tags ship only migrations/__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.

Event 1/40
0:000:42
0:00
session startedaccounts/fireworks/routers/glm-5p2-fast · HORILLA-PROTECTED-MEDIA-PREFIX-NORMALIZATION-BYPASS · REPRO-20
0:04
0:06
web search
0:07
web search
0:09
0:11
web search
0:12
0:26
0:26
extract_facts
no facts extracted
0:28
0:28
0:28
supportclaim_contract
0:33
0:33
0:33
0:33
0:34
0:34
0:34
0:34
error

Unknown error

0:36
0:36
0:36
0:38
0:39
0:40
0:40
0:42

Artifacts and Evidence for REPRO-2026-00299

Scripts, logs, diffs, and output captured during the reproduction.

bundle/repro/reproduction_steps.sh17.8 KB
bundle/repro/rca_report.md11.2 KB
bundle/repro/runtime_manifest.json2.2 KB
bundle/logs/reproduction_steps.log5.0 KB
bundle/logs/repro/1.6.0_oracle_run1.json0.2 KB
bundle/logs/repro/1.6.0_observed_paths_run1.txt0.5 KB
bundle/logs/repro/1.6.0_dotseg_bypass_run1.resp.body0.0 KB
bundle/logs/repro/1.6.0_oracle_run2.json0.2 KB
bundle/logs/repro/1.6.0_observed_paths_run2.txt0.9 KB
bundle/logs/repro/1.6.0_dotseg_bypass_run2.resp.body0.0 KB
bundle/logs/repro/1.6.0_direct_private_run1.resp.hdr0.7 KB
bundle/logs/repro/1.6.0_direct_private_run2.resp.hdr0.7 KB
bundle/logs/repro/1.6.0_server.log1.2 KB
bundle/logs/repro/1.6.0_modified_files.txt0.0 KB
bundle/logs/repro/1.6.0_head.txt0.0 KB
bundle/logs/repro/dev_v2.0_oracle_run1.json0.3 KB
bundle/logs/repro/dev_v2.0_oracle_run2.json0.3 KB
bundle/logs/repro/dev_v2.0_observed_paths_run1.txt0.5 KB
bundle/logs/repro/dev_v2.0_observed_paths_run2.txt0.9 KB
bundle/logs/repro/dev_v2.0_dotseg_bypass_run1.resp.body0.0 KB
bundle/logs/repro/dev_v2.0_dotseg_bypass_run2.resp.body0.0 KB
bundle/logs/repro/dev_v2.0_direct_private_run1.resp.hdr0.7 KB
bundle/logs/repro/dev_v2.0_direct_private_run2.resp.hdr0.7 KB
bundle/logs/repro/dev_v2.0_server.log1.6 KB
bundle/logs/repro/dev_v2.0_modified_files.txt0.0 KB
bundle/logs/repro/dev_v2.0_head.txt0.1 KB
bundle/logs/repro/1.5.0_oracle_run1.json0.3 KB
bundle/logs/repro/1.5.0_oracle_run2.json0.3 KB
bundle/logs/repro/1.5.0_observed_paths_run1.txt0.3 KB
bundle/logs/repro/1.5.0_observed_paths_run2.txt0.5 KB
bundle/logs/repro/1.5.0_dotseg_bypass_run1.resp.body0.0 KB
bundle/logs/repro/1.5.0_dotseg_bypass_run2.resp.body0.0 KB
bundle/logs/repro/1.5.0_direct_private_run1.resp.hdr0.7 KB
bundle/logs/repro/1.5.0_direct_private_run2.resp.hdr0.7 KB
bundle/logs/repro/1.5.0_server.log0.8 KB
bundle/logs/repro/1.5.0_modified_files.txt0.0 KB
bundle/logs/repro/1.5.0_head.txt0.0 KB
bundle/repro/validation_verdict.json0.8 KB
08 · How to Fix

How to Fix REPRO-2026-00299

Coming soon

Step-by-step mitigation and hardening guidance for REPRO-2026-00299 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: REPRO-2026-00299

Is REPRO-2026-00299 exploitable?

Yes. Pruva independently reproduced REPRO-2026-00299 in horilla/horilla-hr and verified the exploit fires end-to-end in a sandboxed environment. A runnable proof-of-concept script and the full agent transcript are on this page (reproduction REPRO-2026-00299).

How severe is REPRO-2026-00299?

REPRO-2026-00299 is rated high severity.

How can I reproduce REPRO-2026-00299?

Pruva provides a verified reproduction script on this page. Download it and run it inside an isolated environment such as a container or virtual machine — never against production. The reproduction was confirmed end-to-end by Pruva's automated agents.

Is the REPRO-2026-00299 reproduction verified?

Yes. Pruva reproduced REPRO-2026-00299 with high confidence in a sandboxed environment, capturing the full agent transcript and artifacts as evidence.
11 · References

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.