# 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/../../<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`).

## Impact

- **Package/component affected:** `horilla-hr` — `base/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:

```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:<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.
