Back to Blog
high SEVERITY8 min read

write_page_jobs(): Unvalidated page_dir Escapes the Run Dir

The deck preparation runtime built per-page working directories by joining the `page_dir` string from a deck state document directly onto the run directory, with no containment check and no schema validation. A crafted or tampered deck record could therefore steer `page_request.json` writes anywhere on the filesystem the process could reach, including outside the run sandbox entirely. The fix routes both call sites through a single `page_dir_for(run_dir, page)` helper so the untrusted `page_dir`

O
By Orbis AppSec
Published September 16, 2026Reviewed September 16, 2026

Answer Summary

The vulnerability is in the first-party deck-preparation runtime, specifically the `page_request()` and `write_page_jobs()` functions that resolved each page's working directory from the untrusted `page_dir` field of a deck state record. An attacker able to influence that field could make the pipeline write `page_request.json` outside the run directory — using `..` segments or an absolute path, which `pathlib`'s `/` operator silently honors by discarding the left-hand base — turning a rendering step into an arbitrary-file-write primitive whose contents later feed the page renderer's command construction. The fix replaces both raw joins with a shared `page_dir_for(run_dir, page)` helper that performs the resolution and containment centrally; there is no package version, as this is a first-party code fix. No CVE, GHSA, or CWE identifier was assigned to this finding.

Vulnerability at a Glance

cweN/A
fixBoth call sites now resolve the page directory through the shared `page_dir_for(run_dir, page)` helper
riskA crafted `page_dir` value writes `page_request.json` outside the run directory, seeding attacker-controlled input into downstream command construction
languagePython
root cause`run_dir / page["page_dir"]` joined an untrusted, unvalidated string with no containment check, inconsistently resolved between two call sites
vulnerabilityPath traversal / unsafe path construction from unvalidated JSON input

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:

  1. 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_dir value be chosen.
  2. write_page_jobs() writes page_request.json to 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.
  3. The contents of that file are also attacker-influenced, because the slide payload 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:

  1. A single place to enforce containment. The untrusted page_dir field is now interpreted by exactly one function. Any hardening — rejecting absolute paths, rejecting .. components, asserting that the resolved path is a descendant of run_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.
  2. The divergence disappears. page_request() and write_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.
  3. It matches the rest of the module's conventions. rel_to_run and sha256_file already 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 and write_page_jobs() did not — and the un-resolved one was the one performing the write. Centralizing on page_dir_for(run_dir, page) removed the class of bug, not just the instance.
  • Machine-generated state files are still untrusted input. page_request.json was treated as trustworthy by the renderer because the pipeline produced it; a controllable page_dir and unvalidated deck["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"], and deck["pages"] were accessed with no type or shape assertions; a schema check at the read boundary would have caught a non-relative or non-string page_dir before it ever reached a filesystem call.

How Orbis AppSec Detected This

  • Source: the page_dir string field of each entry in a deck state record's pages array, deserialized from JSON and passed into page_request() and write_page_jobs() as the page mapping — accessed as page["page_dir"] with no type, shape, or range validation.
  • Sink: filesystem path construction feeding a write — run_dir / page["page_dir"], then page_dir / "page_request.json" handed to write_json().
  • Missing control: no rejection of absolute paths, no rejection of .. path components, and no assertion that the resolved directory is a descendant of run_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 untrusted page_dir value 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.

Prevention and further reading

Frequently Asked Questions

Why was `(run_dir / page["page_dir"]).resolve()` in `page_request()` still unsafe even though it called `.resolve()`?

`.resolve()` only normalizes the path — it collapses `..` and follows symlinks, it does not verify the result is still inside `run_dir`. A `page_dir` of `../../tmp/evil` resolves cleanly to a directory outside the run sandbox, and `.resolve()` reports no error.

What happens if `page_dir` is an absolute path like `/etc/cron.d`?

`pathlib`'s `/` operator discards the left operand when the right operand is absolute, so `run_dir / "/etc/cron.d"` evaluates to `/etc/cron.d`. The run directory base is silently thrown away, which is why a string join was never sufficient containment.

Does `page_dir_for(run_dir, page)` change the layout of `page_request.json` or the `slide` payload it contains?

No. The helper returns the same directory for well-formed deck records, so `page_request()` and `write_page_jobs()` produce identical output for legitimate input; only malformed or traversing `page_dir` values behave differently.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #223

Related Articles

high

modelExporter.js Path Traversal via Unsanitized Directory Concatenation

A path traversal vulnerability in `modelExporter.js` allowed attackers to read arbitrary files by injecting traversal sequences into directory and relative path parameters. The `readSourceFile` function concatenated these unsanitized inputs directly into file URLs passed to `fetch()`. The fix introduces strict path normalization that rejects attempts to escape the intended directory.

critical

How path traversal happens in PHP virtual filesystem adapters and how to fix it

A critical path traversal flaw in `VirtualAdapter.php`'s `resolveMount()` method allowed attackers to escape mounted directory boundaries using sequences like `../../../etc/passwd`. The fix introduces `PathPolicy::normalizeRelative()` to sanitize the remaining path segment before it ever reaches the underlying storage adapter.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

high

How Trust-Prefix Bypass via Path Traversal Happens in Python Copier and How to Fix It

CVE-2026-53951 is a high-severity path traversal vulnerability in Copier 9.15.0 that allowed attackers to bypass trust-prefix checks and execute tasks without user confirmation. Upgrading to Copier 9.15.2 eliminates this attack vector by properly validating file paths before task execution.

critical

How Path Traversal in basic-ftp Leads to File Overwrite Attacks and How to Fix It

CVE-2026-27699 is a critical path traversal vulnerability in basic-ftp versions before 5.3.1 that allows attackers to overwrite arbitrary files on the system by crafting malicious file paths. This vulnerability was fixed by upgrading basic-ftp and enforcing strict version constraints across dependent packages. Understanding this attack and its mitigation is essential for developers using FTP libraries in production environments.

high

ip-address 10.2.0 SSRF: Inconsistent Parsing Bypasses IP Checks

The `ip-address` npm package version 10.2.0 contains an inconsistent parsing vulnerability that allows attackers to bypass IP-based access controls. By representing IPv4 addresses in IPv4-mapped IPv6 notation, attackers can trick applications into allowing requests to blocked internal addresses. Upgrading to 10.3.1 resolves this through stricter address normalization.