/files/{filename}
Version. 0.1
Date. 2026-05-04
Amends. phase-35-cr-render-content-kinds-v0_2.md (CR-2026-047), §3.5 and §7 acceptance gate item 12.
Provenance. Surfaced by Phase 35 Checkpoint B smoke test (smoke-test-phase-35-multi-file.py) HTTP verification.
Status. Amendment. Apply before re-running Checkpoint B HTTP verification, before tagging phase-35-render-content-kinds.
CR v0.2 §3.5 declared the route as GET /engagements/{eid}/renders/{rid}/files/{filename} and §7 acceptance gate item 12 specified that URL parameters containing /, \, or .. return HTTP 400 from the endpoint's validator with the invalid_filename error envelope.
Smoke-test verification revealed a routing-layer gap: when the URL contains %2F-encoded slashes (the most common path-traversal attack form, e.g. ..%2F..%2Fetc%2Fpasswd), Starlette URL-decodes the path before route matching. The decoded path becomes multi-segment and no longer matches /files/{filename} (a single-segment path parameter using Starlette's default str converter). The request is rejected with Starlette's default 404 from the routing layer; the endpoint's validator at renders.py:1020 is never reached.
Security property is preserved. No bytes leak. No traversal occurs. The request is rejected.
Contract violation. The acceptance gate specified 400 with the invalid_filename envelope. The substrate produces 404 with {"detail": "Not Found"} for this most-common attack form.
Why this matters beyond the contract letter. The validator at renders.py:1020 is dead code for %2F-encoded inputs. It still runs for unencoded edge cases (literal .. in a single-segment, literal \ if Starlette accepts), but the production code path doesn't reach it for the attack form smoke testing actually exercises. A future change to the validator could silently regress the safety property without any test catching it, because the dead path isn't tested.
The amendment closes this by routing all multi-segment paths through the endpoint validator.
Current declaration (CR v0.2 §3.5, implementation at src/loomworks/api/routers/renders.py):
@router.get("/engagements/{eid}/renders/{rid}/files/{filename}")
async def render_file_route(
eid: UUID,
rid: UUID,
filename: str,
...
):
...
The {filename} path parameter uses Starlette's default str converter, which matches a single URL path segment.
Amended declaration:
@router.get("/engagements/{eid}/renders/{rid}/files/{filename:path}")
async def render_file_route(
eid: UUID,
rid: UUID,
filename: str,
...
):
...
The {filename:path} converter accepts paths with embedded slashes. URL-encoded %2F (and unencoded /) decode to multi-segment paths, which now match the route. The validator at renders.py:1020 runs against the URL-decoded filename value and rejects any input containing /, \, or .. with the contract-specified HTTP 400 and invalid_filename envelope.
No other code changes. The endpoint validator already handles the rejection; the only change is allowing the request to reach it.
The :path converter sometimes raises eyebrows because it relaxes routing constraints. Three reasons it's safe in this context:
(a) The endpoint validator is the actual security boundary. renders.py:1020 rejects any filename containing /, \, or .. regardless of how the request reached the endpoint. Tightening the route doesn't loosen security; it just ensures the validator runs.
(b) Legitimate filenames are guaranteed single-segment. MultiFileManifestEntry.model_validator rejects any filename containing /, \, or .. at construction (CR v0.2 §3.3). A RenderEvent with a multi_file manifest cannot contain malformed entries — the RenderContentValidationError raises before the event reaches memory_events. So legitimate /files/{filename} requests will always be single-segment lookups against single-segment manifest keys.
(c) Defense in depth is preserved. Three layers of protection still apply:
MultiFileManifestEntry validator at construction (no malformed entries can enter the manifest).renders.py:1020 (rejects malformed URL parameters with 400 + invalid_filename envelope).The amendment moves where layer 2 fires (from "after Starlette accepted the route" to "after Starlette accepted the route, including multi-segment paths") without removing any layer.
CR v0.2 §5.4 Test 15 (test_files_endpoint_traversal_safety) asserted HTTP 400 for traversal attempts. The test passed under Step 6 — Full suite green at 1,311 tests, which means TestClient is handling URL-encoding differently than uvicorn does in production — most likely TestClient passes the URL-encoded path through as a single-segment string without decoding, which reaches the endpoint validator and produces 400 (matching the test's assertion). Production uvicorn URL-decodes before route matching and produces 404 from the routing layer.
After the amendment:
Test 15 should pass without modification under both TestClient and production after the amendment. No test changes required.
Lesson worth capturing for future phases. TestClient assertions of HTTP status codes don't always exercise the same code path as production uvicorn handles. When acceptance gate items specify HTTP status codes for security-relevant rejections, smoke tests against running uvicorn are load-bearing in a way TestClient assertions are not. This is a pre-flight reflex worth adding to the standing arrangement: "for any acceptance gate item that names a specific HTTP status code on a security boundary, smoke-test against uvicorn before tagging."
This lesson is queued for the standing arrangement after the amendment lands; not part of this amendment itself.
CR v0.2 §7 item 12, current text:
> "12. The /files/{filename} handler treats the URL parameter as a manifest key (string equality, no path arithmetic); URL parameters containing /, \, or .. return HTTP 400."
Amended text:
> "12. The /files/{filename:path} handler treats the URL parameter as a manifest key (string equality, no path arithmetic). URL parameters containing /, \, or .. (after URL-decoding) return HTTP 400 with the invalid_filename error envelope from the endpoint validator. Verified end-to-end via Checkpoint B smoke test against running uvicorn, not just TestClient unit tests."
The change names the converter explicitly (so future readers see the route shape), adds "after URL-decoding" (so the contract covers %2F-encoded inputs verbatim), names the error envelope (so client-side error handling has a stable contract), and notes the smoke-test requirement (so future phases inherit the lesson).
Auto-mode posture. Single-step amendment. No checkpoints needed.
Step A1 — Apply route converter change. One-line edit at src/loomworks/api/routers/renders.py (the route declaration line). Verify the existing endpoint validator handles all the rejection paths (it should, no changes there).
Step A2 — Verify substrate. Run the full test suite (uv run pytest -v). Expect 1,311 passing, 2 skipped (no test count change; Test 15 should still pass). If any test fails, halt and report.
Step A3 — Update acceptance gate documentation. Apply the amended item 12 text to the archived CR at docs/phase-crs/phase-35-cr-render-content-kinds-v0_2.md (or note the amendment alongside it; whichever is convention).
Step A4 — Implementation notes update. Append a section to docs/phase-impl-notes/phase-35-implementation-notes-v0_1.md:
{filename:path} vs {filename}) and why.:path converter's safety analysis (the three defense-in-depth layers).
Single commit for A1–A4: Phase 35 amendment: route converter for /files/{filename}.
After this amendment lands and the harness fix for auth lands (separate work; smoke-test script update for session cookie or Bearer token):
--reload pick up the route change).smoke-test-phase-35-multi-file.py (with the auth fix applied)./files/part-1.bin → HTTP 200, bytes match SHA256./files/part-2.dat → HTTP 200, bytes match SHA256./files/{not-in-manifest} → HTTP 404 with the manifest-not-found envelope./files/..%2F..%2Fetc%2Fpasswd → HTTP 400 with the invalid_filename envelope./download → HTTP 200, zip with manifest.json + part-1.bin + part-2.dat, external_reference in manifest.json only.phase-35-render-content-kinds (annotated, on the amendment commit which becomes the canonical Phase 35 final state).
DUNIN7 — Done In Seven LLC — Miami, Florida
Phase 35 CR Amendment — Route converter for /files/{filename} — v0.1 — 2026-05-04