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
•Technically reviewed by Anupam Mediratta•Published September 16, 2026•Reviewed 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

critical

path.resolve() Path Traversal in Node CLI's Dynamic import()

A Node.js CLI script for validating expression definitions took a file path from `process.argv[2]`, resolved it with `path.resolve()`, and passed the result straight into a dynamic `import()` — with no check that the resolved path stayed inside the working directory. An attacker (or a malicious skill/plugin invocation) could supply traversal sequences to load and execute arbitrary `.js` files from anywhere on disk. The fix adds a boundary check with `path.relative()` and an extension allowlist b

high

bookDir() Path Traversal via Unsanitized bookId Parameter

The `bookDir()` function accepted unsanitized `bookId` values derived from user-created book titles, enabling path traversal attacks through `../` sequences. A fix was applied that validates the identifier using `path.basename()` and throws on mismatch, ensuring all resolved paths remain within `LIBRARY_DIR`.

high

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

markitdown_bridge.py Path Traversal: Arbitrary File Read via sys.argv

The markitdown_bridge.py script, used by MDView for DOCX-to-Markdown conversion, accepted file paths directly from command-line arguments without validating they stayed within intended directories. An attacker could exploit this to read arbitrary files from the filesystem by passing path traversal sequences in the source_path parameter.

high

SHACL Viewer Path Traversal in graph3d(): Unvalidated `path`

The `graph3d()` and `graph2d()` request handlers in SHACL Viewer directly concatenated user-supplied `path` parameters into filesystem paths, enabling directory traversal outside the intended `/shapes/` directory. The fix introduces `_resolve_shapes_path()` with `os.path.realpath()` validation to enforce containment within the shapes directory.