{"repro_id":"REPRO-2026-00299","version":6,"title":"Horilla protected_media public-prefix normalization bypass: unauthenticated private in-root read on the repaired release","repro_type":"security","status":"published","severity":"high","description":"Horilla's protected_media() view makes the authorization decision using path.startswith() on the RAW request path, while the filesystem decision uses safe_join(settings.MEDIA_ROOT, path) on the CANONICAL path. When an attacker requests <public-prefix>/../../<private-path>, the raw path passes the allowlist prefix check (authorization granted), but the filesystem normalizes the dot segments and serves a private in-root media file. The resolved file never leaves MEDIA_ROOT, so safe_join() is satisfied. This is NOT path traversal.","root_cause":"# RCA Report — Horilla protected_media public-prefix normalization authorization bypass\n\n## Summary\n\nHorilla's `base/views.py::protected_media()` view authorizes media requests with a\nraw-prefix test, `is_public_asset = any(path.startswith(prefix) for prefix in\npublic_media_prefixes)`, evaluated on the **raw request path** (the string captured by\nthe URL route). The filesystem object is then resolved with\n`safe_join(settings.MEDIA_ROOT, path)`, which canonicalizes dot segments. Because the\nauthorization decision and the filesystem decision use two different forms of the same\npath, an unauthenticated caller can request\n`/media/base/icon/../../<private-in-root-file>`: the raw path begins with the\nallowlisted `base/icon/` prefix (so `is_public_asset` is true and authentication is\nskipped), while `safe_join` normalizes `base/icon/../../` away and serves a private file\nthat lives elsewhere inside `MEDIA_ROOT`. `safe_join` still contains the result inside\n`MEDIA_ROOT`, so this is an authorization bypass, **not** path traversal. The bypass is\npresent on the repaired releases `1.6.0` (`b3bd29d1`) and `dev/v2.0` (`77f515c7`), and the\nprimitive already existed on the pre-repair `1.5.0` (`61bd5173`).\n\n## Impact\n\n- **Package/component affected:** `horilla-hr` — `base/views.py::protected_media()`,\n  routed by `base/urls.py` as `re_path(r\"^media/(?P<path>.*)$\", views.protected_media,\n  name=\"protected_media\")`.\n- **Affected versions:** `1.6.0` (released, current) and `dev/v2.0@77f515c7` (repaired\n  2.x line). The pre-repair `1.5.0` is also affected (the raw-prefix test predates the\n  repair; it used `exempted_folders = [\"base/icon/\"]` and `os.path.join`).\n- **Risk level and consequences:** High confidentiality impact, low exploitability\n  barrier. Any unauthenticated network caller who can reach the `/media/<path>` HTTP\n  route can read arbitrary files that reside inside `MEDIA_ROOT` and outside the public\n  prefixes, by prepending an allowlisted public prefix and dot segments. No credential,\n  cookie, token, or Referer is required.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Unauthenticated read of a private in-root media\n  object (`authz_bypass`) on the repaired release.\n- **Reproduced impact from this run:** Unauthenticated read of an exact private canary\n  placed at `MEDIA_ROOT/secret/<canary>` (outside every `public_media_prefixes` entry)\n  via `GET /media/base/icon/../../secret/<canary>` returning HTTP 200 with the canary as\n  the response body, on both `1.6.0` and `dev/v2.0`, across two clean runs with fresh\n  canaries.\n- **Parity:** `full` — the claimed authz bypass is demonstrated end-to-end through the\n  real HTTP route, bounded to in-root read.\n- **Not demonstrated:** Path traversal / outside-`MEDIA_ROOT` read (control 3 proves\n  `safe_join` still contains), arbitrary file read, account takeover, session forgery, or\n  code execution. None of these are claimed by this ticket.\n\n## Root Cause\n\nThe 1.x authorization repair (`b6eaec1386d8b8741a42fe7c78f318f073375791`) and the 2.x\nauthorization repair (`7ad517e54ab0b60afe1c632eb42196bd1a74b10a`) replaced the previous\nReferer-based authorization branch with a raw-prefix allowlist:\n\n```python\n# base/views.py (1.6.0 and dev/v2.0)\nmedia_path = safe_join(settings.MEDIA_ROOT, path)   # canonical, contained\n...\nis_public_asset = any(path.startswith(prefix) for prefix in public_media_prefixes)\nif not is_public_asset:\n    jwt_user = is_jwt_token_valid(request.META.get(\"HTTP_AUTHORIZATION\", \"\"))\n    if not request.user.is_authenticated and not jwt_user:\n        ... return redirect(\"login\")\nreturn FileResponse(open(media_path, \"rb\"))\n```\n\n`public_media_prefixes = (\"base/icon/\", \"base/company/icon/\",\n\"recruitment/candidate/profile/\")`. `path` is the raw URL-captured string. `safe_join`\n(`django.utils._os.safe_join`) calls `os.path.abspath`/`normpath` and therefore collapses\n`base/icon/../../secret/x` to `MEDIA_ROOT/secret/x` while still confirming the result is\ninside `MEDIA_ROOT`. The authorization gate, however, sees the un-normalized string, so\n`\"base/icon/../../secret/x\".startswith(\"base/icon/\")` is `True` and authentication is\nskipped. The decision is made on the raw path; the file is served from the canonical\npath. The pre-repair `1.5.0` already had `path.startswith(folder)` against\n`exempted_folders = [\"base/icon/\"]`, so the normalization-mismatch primitive predates the\nrepair; the repair merely removed the Referer branch and widened the prefix set.\n\n## Reproduction Steps\n\n1. Reference: `bundle/repro/reproduction_steps.sh`.\n2. What the script does:\n   - Installs `uv` + a managed CPython 3.12 (the sandbox ships 3.14, unsupported by the\n     pinned Django 4.2.x / 5.2 releases), and reuses/clones the `horilla-hr` mirror.\n   - For each pinned ref (`1.6.0`=`b3bd29d1`, `dev/v2.0`=`77f515c7`, `1.5.0`=`61bd5173`)\n     it creates a `git worktree`, builds an isolated venv from that ref's\n     `requirements.txt`, generates + applies migrations against a fresh empty SQLite DB\n     (no `DATABASE_URL`/`REDIS_URL` -> SQLite + LocMem), starts the real Horilla app with\n     `python manage.py runserver 127.0.0.1:<port> --noreload`, and reaches it from a\n     separate `curl` attacker process with `--path-as-is` (dot segments preserved\n     verbatim).\n   - Per ref it creates a random private canary at `MEDIA_ROOT/secret/<rand>.txt`\n     (outside every public prefix) and a public canary at\n     `MEDIA_ROOT/base/icon/<rand>.txt`, then runs the full matrix **twice** with new\n     random canaries each run:\n     - readiness `GET /login/` (non-5xx);\n     - `GET /media/secret/<canary>` unauthenticated -> must be denied (302) and must not\n       contain the canary (oracle 2);\n     - `GET /media/base/icon/../../secret/<canary>` unauthenticated -> must be 200 with\n       body == private canary (oracle 3+4);\n     - `GET /media/base/icon/<public>` -> 200 (control 1: allowlist alive);\n     - `GET /media/base/icon/../../../../../../etc/passwd` -> 404 (control 3:\n       containment intact; repaired refs only);\n     - `GET /media/secret/<canary>` with `Referer: http://attacker.invalid/login` -> 302\n       (control 2: Referer repair intact; repaired refs only).\n   - For `1.5.0` it checks control 4 (the dot-segment request returns the canary,\n     proving the primitive predates the repair) on both runs.\n   - It records the server-observed path for every request, the outgoing request\n     headers (proving no Cookie/Authorization), response status/headers/body, source\n     identities, and the modified-file inventory, then writes\n     `runtime_manifest.json` and `validation_verdict.json`.\n3. Expected evidence of reproduction: every repaired ref satisfies the full oracle on\n   both runs; the dot-segment request returns HTTP 200 with the exact private canary\n   while the direct private request is 302; `safe_join` still 404s an escaping path; no\n   cookie/Authorization is sent.\n\n## Evidence\n\n- Oracle summaries: `bundle/logs/repro/{1.6.0,dev_v2.0,1.5.0}_oracle_run{1,2}.json`\n- Server-observed paths (dot segments preserved verbatim):\n  `bundle/logs/repro/{ref}_observed_paths_run{1,2}.txt`\n- 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}`\n- Server access logs: `bundle/logs/repro/{ref}_server.log`\n- Source identities: `bundle/logs/repro/{ref}_head.txt`\n- Modified-file inventory: `bundle/logs/repro/{ref}_modified_files.txt`\n- Full run log: `bundle/logs/reproduction_steps.log`\n\nKey excerpts (`1.6.0`, run 1):\n\n```\n# server-observed paths (dot segments NOT collapsed by the client or server)\n\"GET /media/secret/private_09c89ea4578dcd1f.txt HTTP/1.1\" 302 0\n\"GET /media/base/icon/../../secret/private_09c89ea4578dcd1f.txt HTTP/1.1\" 200 31\n\"GET /media/base/icon/public_09c89ea4578dcd1f.txt HTTP/1.1\" 200 28\n\"GET /media/base/icon/../../../../../../etc/passwd HTTP/1.1\" 404 52039\n\n# outgoing request headers for the bypass request (no Cookie, no Authorization)\n> GET /media/base/icon/../../secret/private_09c89ea4578dcd1f.txt HTTP/1.1\n> Host: 127.0.0.1:19011\n> User-Agent: curl/8.18.0\n> Accept: */*\n< HTTP/1.1 200 OK\n< Content-Type: text/plain\n< Content-Length: 31\n\n# bypass response body == private canary\nPRIVATE_SECRET_09c89ea4578dcd1f\n\n# direct private response\nHTTP/1.1 302 Found\nLocation: /login/\n```\n\n`dev/v2.0@77f515c7` run 2 produced the identical pattern (`dotseg 200` with the canary,\n`direct 302`, `escape 404`, `referer 302`). `1.5.0` run 2: `dotseg 200` with the canary\n(`PRIVATE_SECRET_064ba22f6374f70e`), confirming the primitive predates the repair.\n\nAggregate result across both clean runs:\n`1.6.0` pass=1, `dev_v2.0` pass=1, `1.5.0` pass=1 -> `REPRO_CONFIRMED=1`.\n\nEnvironment: Django runserver (`WSGIServer/0.2 CPython/3.12.13`), SQLite + LocMem cache,\n`MEDIA_ROOT=/.../media/`, `MEDIA_URL=/media/`, `DEBUG=True` (default), no\n`DATABASE_URL`/`REDIS_URL`. `curl/8.18.0` with `--path-as-is` and `-v` from a separate\nprocess; no Cookie/Authorization sent on the oracle requests.\n\n## Recommendations / Next Steps\n\n- **Fix:** make the authorization decision on the canonical path, not the raw one. After\n  `safe_join` resolves `media_path`, derive the relative path from `MEDIA_ROOT`\n  (e.g. `rel = os.path.relpath(media_path, settings.MEDIA_ROOT)` with a containment\n  guard) and test `rel.startswith(prefix)` against that normalized relative path — or\n  reject any request path that contains dot segments before authorization. Either\n  approach aligns the authorization string with the filesystem string.\n- **Defense in depth:** do not rely on a string-prefix allowlist for unauthenticated\n  media serving; serve public assets from a separate public root/directory not shared\n  with private uploads, so a prefix test can never reach private objects.\n- **Upgrade guidance:** no released version currently denies this variant; the repaired\n  releases (`1.6.0`, `dev/v2.0`) are bypassable. A patch is required.\n- **Testing:** add a regression test that requests\n  `/media/base/icon/../../<private>` unauthenticated and asserts denial (302/403) while\n  `/media/base/icon/<public>` stays 200, plus a containment test that an escaping path\n  404s.\n\n## Additional Notes\n\n- **Idempotency:** the script recreates worktrees, fresh SQLite DBs, and new random\n  canaries on every invocation; venvs are reused across the two in-script runs (they\n  depend only on the pinned ref's `requirements.txt`). Re-running reproduces the same\n  oracle outcome.\n- **Scope boundary:** the resolved file never leaves `MEDIA_ROOT`; `safe_join` is\n  satisfied (control 3: escaping path -> 404). This is an authorization bypass bounded\n  to unauthenticated in-root read, not path traversal (which is covered by\n  `GHSA-x52c-5hrq-76pq`).\n- **Modified-file inventory:** `git status --porcelain` per worktree recorded no\n  application-source changes; `base/views.py`, `base/urls.py`, routing, middleware,\n  authentication, and file-open behavior were not modified. Only gitignored\n  Django-generated migration files (the `1.6.0`/`1.5.0` tags ship only\n  `migrations/__init__.py`) and runtime SQLite files were added.\n- **Client caveat:** the HTTP client must preserve dot segments verbatim (`curl\n  --path-as-is`); a client that collapses `../` before sending invalidates the run. The\n  server access log confirms the dots reached the Horilla process unmodified.\n","source_url":"https://github.com/horilla/horilla-hr","package":{"name":"horilla/horilla-hr","ecosystem":"github"},"reproduced_at":"2026-07-27T01:26:06.311715+00:00","duration_secs":5045.0,"tool_calls":263,"handoffs":2,"total_cost_usd":4.051433,"agent_costs":{"claim_matcher":0.020241,"judge":0.078166,"learning_policy":0.019901,"repro":3.153106,"support":0.059337,"vuln_variant":0.720682},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.020241},"judge":{"gpt-5.4-mini":0.078166},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.019901},"repro":{"accounts/fireworks/routers/glm-5p2-fast":3.153106},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.059337},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":0.720682}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-27T01:26:07.214146+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":18256,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":11419,"category":"analysis"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2284,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":5086,"category":"log"},{"path":"bundle/logs/repro/1.6.0_oracle_run1.json","filename":"1.6.0_oracle_run1.json","size":254,"category":"other"},{"path":"bundle/logs/repro/1.6.0_observed_paths_run1.txt","filename":"1.6.0_observed_paths_run1.txt","size":462,"category":"other"},{"path":"bundle/logs/repro/1.6.0_dotseg_bypass_run1.resp.body","filename":"1.6.0_dotseg_bypass_run1.resp.body","size":31,"category":"other"},{"path":"bundle/logs/repro/1.6.0_oracle_run2.json","filename":"1.6.0_oracle_run2.json","size":254,"category":"other"},{"path":"bundle/logs/repro/1.6.0_observed_paths_run2.txt","filename":"1.6.0_observed_paths_run2.txt","size":924,"category":"other"},{"path":"bundle/logs/repro/1.6.0_dotseg_bypass_run2.resp.body","filename":"1.6.0_dotseg_bypass_run2.resp.body","size":31,"category":"other"},{"path":"bundle/logs/repro/1.6.0_direct_private_run1.resp.hdr","filename":"1.6.0_direct_private_run1.resp.hdr","size":748,"category":"other"},{"path":"bundle/logs/repro/1.6.0_direct_private_run2.resp.hdr","filename":"1.6.0_direct_private_run2.resp.hdr","size":748,"category":"other"},{"path":"bundle/logs/repro/1.6.0_server.log","filename":"1.6.0_server.log","size":1258,"category":"log"},{"path":"bundle/logs/repro/1.6.0_modified_files.txt","filename":"1.6.0_modified_files.txt","size":0,"category":"other"},{"path":"bundle/logs/repro/1.6.0_head.txt","filename":"1.6.0_head.txt","size":47,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_oracle_run1.json","filename":"dev_v2.0_oracle_run1.json","size":257,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_oracle_run2.json","filename":"dev_v2.0_oracle_run2.json","size":257,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_observed_paths_run1.txt","filename":"dev_v2.0_observed_paths_run1.txt","size":463,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_observed_paths_run2.txt","filename":"dev_v2.0_observed_paths_run2.txt","size":926,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_dotseg_bypass_run1.resp.body","filename":"dev_v2.0_dotseg_bypass_run1.resp.body","size":31,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_dotseg_bypass_run2.resp.body","filename":"dev_v2.0_dotseg_bypass_run2.resp.body","size":31,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_direct_private_run1.resp.hdr","filename":"dev_v2.0_direct_private_run1.resp.hdr","size":748,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_direct_private_run2.resp.hdr","filename":"dev_v2.0_direct_private_run2.resp.hdr","size":748,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_server.log","filename":"dev_v2.0_server.log","size":1629,"category":"log"},{"path":"bundle/logs/repro/dev_v2.0_modified_files.txt","filename":"dev_v2.0_modified_files.txt","size":0,"category":"other"},{"path":"bundle/logs/repro/dev_v2.0_head.txt","filename":"dev_v2.0_head.txt","size":71,"category":"other"},{"path":"bundle/logs/repro/1.5.0_oracle_run1.json","filename":"1.5.0_oracle_run1.json","size":262,"category":"other"},{"path":"bundle/logs/repro/1.5.0_oracle_run2.json","filename":"1.5.0_oracle_run2.json","size":262,"category":"other"},{"path":"bundle/logs/repro/1.5.0_observed_paths_run1.txt","filename":"1.5.0_observed_paths_run1.txt","size":281,"category":"other"},{"path":"bundle/logs/repro/1.5.0_observed_paths_run2.txt","filename":"1.5.0_observed_paths_run2.txt","size":562,"category":"other"},{"path":"bundle/logs/repro/1.5.0_dotseg_bypass_run1.resp.body","filename":"1.5.0_dotseg_bypass_run1.resp.body","size":31,"category":"other"},{"path":"bundle/logs/repro/1.5.0_dotseg_bypass_run2.resp.body","filename":"1.5.0_dotseg_bypass_run2.resp.body","size":31,"category":"other"},{"path":"bundle/logs/repro/1.5.0_direct_private_run1.resp.hdr","filename":"1.5.0_direct_private_run1.resp.hdr","size":748,"category":"other"},{"path":"bundle/logs/repro/1.5.0_direct_private_run2.resp.hdr","filename":"1.5.0_direct_private_run2.resp.hdr","size":748,"category":"other"},{"path":"bundle/logs/repro/1.5.0_server.log","filename":"1.5.0_server.log","size":782,"category":"log"},{"path":"bundle/logs/repro/1.5.0_modified_files.txt","filename":"1.5.0_modified_files.txt","size":0,"category":"other"},{"path":"bundle/logs/repro/1.5.0_head.txt","filename":"1.5.0_head.txt","size":47,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":826,"category":"other"}]}