A rendering pipeline that trusted its own state file
The deck-preparation runtime for the image-to-slides pipeline does something that looks completely mundane: for each page in a deck, it works out where that page's scratch directory lives, then writes a page_request.json job file into it. The renderer worker picks those job files up later.
The problem was how "works out where that page's scratch directory lives" was implemented. Two functions — page_request() and write_page_jobs() — each took a page dictionary that came from a deck state document and built a filesystem path by concatenating one of its string fields onto the run directory:
page_dir = run_dir / page["page_dir"]
request_path = page_dir / "page_request.json"
There is no type check on page["page_dir"], no check that it is relative, and no check that the result is still underneath run_dir. Whatever string that field holds becomes a filesystem destination for a write. This finding was tracked internally as V-003 and rated high severity.
If you write Python that turns configuration or state values into paths, this is a pattern worth recognizing on sight — the bug is invisible precisely because pathlib makes the join read like safe, idiomatic code.
Affected Versions
| Affected | not applicable (first-party code) — the deck-preparation runtime prior to the fix commit |
| Fixed in | not applicable (first-party code) — fixed by routing both call sites through page_dir_for() |
| Ecosystem | not applicable (first-party Python module) |
| CVE / GHSA | not assigned |
| CWE | unknown (no identifier assigned by the scanner) |
The Vulnerability Explained
The two divergent joins
Before the fix, the same conceptual operation was implemented twice, differently. Inside page_request():
def page_request(run_dir, deck, page):
page_dir = (run_dir / page["page_dir"]).resolve()
source, width_px, height_px = page_source_size(run_dir, page)
slide = dict(deck["slide"])
page_id = page["page_id"]
And inside write_page_jobs(), iterating deck["pages"]:
for page in deck["pages"]:
page_dir = run_dir / page["page_dir"]
request_path = page_dir / "page_request.json"
request = page_request(run_dir, deck, page)
write_json(request_path, request)
Note the asymmetry. The first call site normalizes with .resolve(); the second does a bare join and then immediately uses the result as a write destination. So the path used to compute the request body and the path used to persist it could differ — one collapsing .. segments and following symlinks, the other not. That divergence alone is a correctness bug, and it is the kind of inconsistency that makes any later "we validate this" claim untrue for at least one of the two paths.
Two ways page_dir escapes
Relative traversal. A page_dir of ../../../../tmp/staging produces a request_path outside the run directory. Crucially, .resolve() in page_request() does not prevent this — resolve() normalizes a path, it does not confine it. ../../.. resolves perfectly happily to somewhere else on disk and raises nothing.
Absolute override. pathlib's / operator discards the left operand entirely when the right operand is absolute. run_dir / "/var/www/html/uploads" evaluates to /var/www/html/uploads. The run directory sandbox is not escaped so much as deleted from the expression. A single leading slash in the deck record was enough.
No schema validation on the way in
The deck record is read from JSON. The code then reaches straight into it — page["page_dir"], page["page_id"], deck["slide"], deck["pages"] — with no validation of structure, types, or ranges. page_dir could be an absolute path, a string containing .., a value with an embedded null byte, or something that is not a string at all. dict(deck["slide"]) will happily copy whatever mapping it is handed into the emitted request body.
What an attacker gets
The realistic capability chain looks like this:
- The attacker influences a deck state record — by supplying a deck through the ingest path, by tampering with state on shared or multi-tenant storage, or by any other route that lets a
pages[].page_dirvalue be chosen. write_page_jobs()writespage_request.jsonto the attacker-chosen destination. That is an arbitrary-file-write primitive scoped to a fixed filename but an attacker-chosen directory — enough to plant files in drop directories, overwrite a same-named config consumed by another component, or land content in a web-served directory.- The contents of that file are also attacker-influenced, because the
slidepayload and source dimensions are copied out of the deck record without validation. Those values are subsequently consumed by the page renderer, which assembles command invocations from them. A job file that the pipeline itself trusts as machine-generated is a much better position to inject from than an external input, because the trust boundary that would have justified escaping was assumed to be upstream.
That third step is what pushes this from "messy path handling" to high severity: the write does not just place bytes on disk, it seeds a document that a later stage treats as authoritative when building commands.
The Fix
The change is small and deliberately boring: eliminate the two hand-rolled joins and route both through one shared helper that already knows how to resolve a page directory safely.
Before — two independent, inconsistent joins:
# in page_request()
page_dir = (run_dir / page["page_dir"]).resolve()
# in write_page_jobs()
page_dir = run_dir / page["page_dir"]
After — one call, one behavior:
# in page_request()
page_dir = page_dir_for(run_dir, page)
# in write_page_jobs()
page_dir = page_dir_for(run_dir, page)
Supporting this required extending the import from the run-state module to bring page_dir_for alongside the existing helpers (read_json, write_json, rel_to_run, sha256_file, and friends).
Three things this accomplishes that the previous code could not:
- A single place to enforce containment. The untrusted
page_dirfield is now interpreted by exactly one function. Any hardening — rejecting absolute paths, rejecting..components, asserting that the resolved path is a descendant ofrun_dir— applies to both the body-computation path and the write path automatically. Previously you would have had to fix it twice and remember there were two. - The divergence disappears.
page_request()andwrite_page_jobs()can no longer disagree about where a page lives. The path used to build the request is provably the path the request is written to. - It matches the rest of the module's conventions.
rel_to_runandsha256_filealready centralize run-relative path handling. The two raw joins were the outliers — the fix brings them in line rather than inventing a new mechanism, which is why behavior for well-formed decks is unchanged.
For any legitimate deck, page_dir_for(run_dir, page) returns the same directory the old code did, so the emitted page_request.json files are byte-identical. Only hostile or malformed page_dir values see different behavior.
Key Takeaways
Path.resolve()normalizes, it does not confine.(run_dir / page["page_dir"]).resolve()reads like a safety measure but happily resolves../../..to a directory outside the run sandbox. Containment requires an explicit "is this still under the base?" check, not normalization.pathlib's/operator drops the base when the right side is absolute.run_dir / "/etc/cron.d"is/etc/cron.d. Any join whose right operand comes from JSON must reject absolute values before the join, not after.- When two functions compute the same path differently, at least one of them is wrong.
page_request()resolved andwrite_page_jobs()did not — and the un-resolved one was the one performing the write. Centralizing onpage_dir_for(run_dir, page)removed the class of bug, not just the instance. - Machine-generated state files are still untrusted input.
page_request.jsonwas treated as trustworthy by the renderer because the pipeline produced it; a controllablepage_dirand unvalidateddeck["slide"]copy meant an attacker could choose both where it landed and part of what it said. - Reaching into parsed JSON with bare subscripts is the validation gap.
page["page_dir"],page["page_id"], anddeck["pages"]were accessed with no type or shape assertions; a schema check at the read boundary would have caught a non-relative or non-stringpage_dirbefore it ever reached a filesystem call.
How Orbis AppSec Detected This
- Source: the
page_dirstring field of each entry in a deck state record'spagesarray, deserialized from JSON and passed intopage_request()andwrite_page_jobs()as thepagemapping — accessed aspage["page_dir"]with no type, shape, or range validation. - Sink: filesystem path construction feeding a write —
run_dir / page["page_dir"], thenpage_dir / "page_request.json"handed towrite_json(). - Missing control: no rejection of absolute paths, no rejection of
..path components, and no assertion that the resolved directory is a descendant ofrun_dir. The.resolve()call present at one of the two call sites normalized the path without constraining it, and the other call site had no normalization at all. - CWE: unknown — no CWE identifier was assigned to this finding. The pattern is untrusted-path construction from unvalidated deserialized input.
- Fix: both call sites now obtain the page directory from the shared
page_dir_for(run_dir, page)helper, giving the untrustedpage_dirvalue exactly one resolution and containment point.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The dangerous line here was four tokens long: run_dir / page["page_dir"]. It carried a string straight out of a deserialized deck record into a filesystem write destination, with pathlib semantics that quietly discard the base path when the untrusted side is absolute, and a .resolve() at the sibling call site that looked protective while providing no containment at all. Because the resulting page_request.json is later consumed as trusted input by the page renderer's command construction, a traversal here was not merely a misplaced file — it was a foothold in a stage that had stopped escaping its inputs.
Consolidating both joins behind page_dir_for(run_dir, page) fixes the immediate escape and, more importantly, leaves the codebase with one function to harden instead of two to remember. If your own pipeline hands parsed-JSON strings to path constructors, the audit is short: find every base / untrusted_value, confirm the untrusted side is validated as relative, and confirm the resolved result is checked against the base — not just normalized.