Back to Blog
high SEVERITY3 min read

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.

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

Answer Summary

The markitdown_bridge.py script in affected versions accepted source_path and output_path directly from sys.argv[1:4] without path validation. An attacker could read arbitrary files by passing traversal sequences like ../../../etc/passwd as the source_path, or write to unexpected locations via output_path. The fix adds os.path.realpath() normalization and explicit checks using os.path.isfile() and os.path.isdir() to reject traversal attempts. CWE-22 (Path Traversal).

Vulnerability at a Glance

cweCWE-22
fixAdded os.path.realpath() normalization and file/directory type checks
riskHigh — arbitrary file read and potential file write outside intended directories
languagePython
root causeDirect use of sys.argv paths without normalization or boundary validation
vulnerabilityPath Traversal (Directory Traversal)

Affected Versions

Affected not applicable (first-party code)
Fixed in not applicable (first-party code) — see PR for fix commit
Ecosystem N/A
CVE / GHSA not assigned
CWE CWE-22 (Path Traversal)

The Vulnerability Explained

MDView's markitdown_bridge.py serves as a minimal offline bridge for converting DOCX files to Markdown. The script receives three command-line arguments via sys.argv[1:4]: a package directory, a source file path, and an output file path. The vulnerability lies in how these paths are used without any validation:

def main() -> None:
    package_dir, source_path, output_path = sys.argv[1:4]
    sys.path.insert(0, package_dir)

    from markitdown import MarkItDown
    md = MarkItDown()
    result = md.convert_local(source_path)

The source_path flows directly into MarkItDown.convert_local(), which opens and reads the file. No normalization occurs, and no check ensures the path stays within intended boundaries.

An attacker controlling the command invocation could pass traversal sequences such as ../../../etc/passwd or ../../../sensitive_config.json as source_path. Since the script runs with whatever privileges the parent process holds, this enables reading arbitrary files from the filesystem. The output_path presents similar risks for unauthorized file writes, though the primary concern is information disclosure through the source path.

The Fix

The fix introduces three critical hardening measures using Python's os.path module:

def main() -> None:
    package_dir, source_path, output_path = sys.argv[1:4]
    package_dir = os.path.realpath(package_dir)
    source_path = os.path.realpath(source_path)
    output_path = os.path.realpath(output_path)

    # Reject anything that isn't a real, existing file (blocks traversal via
    # symlinks/"..") and refuse to overwrite a directory as the output.
    if not os.path.isfile(source_path):
        raise SystemExit(f"Invalid source path: {source_path}")
    if os.path.isdir(output_path):
        raise SystemExit(f"Invalid output path: {output_path}")

Normalization with os.path.realpath(): All three paths are resolved to their canonical absolute form, eliminating . and .. components and following symbolic links. This collapses traversal attempts before they reach the file operations.

Existence validation with os.path.isfile(): The source_path must point to an actual file, not a directory or non-existent path. This blocks traversal to directories and ensures the path resolved to something the attacker could legitimately target.

Type checking with os.path.isdir(): The output_path is rejected if it would overwrite a directory, preventing accidental or malicious directory corruption.

Key Takeaways

  • Command-line arguments are user input: Even sys.argv values from a parent process can be attacker-controlled if that process passes through any user-derived data. Treat them with the same skepticism as HTTP parameters.

  • os.path.realpath() over abspath() for security: When preventing traversal, symbolic links matter. realpath() resolves them, closing a bypass where an attacker uses a symlink to "teleport" outside the intended directory after normalization.

  • Validate before importing: The fix validates paths before sys.path.insert(0, package_dir) and the markitdown import. This ordering matters—an invalid package_dir could still affect Python's import path if checked too late.

  • Fail with SystemExit and descriptive messages: The fix uses raise SystemExit(...) rather than silent failures or exceptions, giving operators clear feedback about what validation failed.

How Orbis AppSec Detected This

Source: The sys.argv[1:4] slice accepting source_path and output_path from command-line arguments

Sink: MarkItDown.convert_local(source_path) for reading, and implicit file write operations via output_path

Missing control: No path normalization, no validation that resolved paths stay within intended directories, and no verification that source_path is a regular file

CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)

Fix: Added os.path.realpath() normalization and explicit os.path.isfile()/os.path.isdir() checks to reject traversal attempts and ensure path types match expected usage

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

This vulnerability in markitdown_bridge.py demonstrates how even simple utility scripts can become security liabilities when they accept filesystem paths without validation. The fix's use of os.path.realpath() combined with type-specific checks provides a robust defense-in-depth approach: normalize first, verify existence and type second, and only then proceed with file operations. For developers building Tauri-based applications or any system that spawns helper processes, this pattern of validating paths at the process boundary is essential for maintaining security isolation.

Prevention and further reading

Frequently Asked Questions

Why does the fix use os.path.realpath() instead of os.path.abspath()?

os.path.realpath() resolves symbolic links, which abspath() does not. This prevents an attacker from using a symlink to bypass directory restrictions after the path appears normalized.

Could an attacker still exploit this if they control both the source_path and the package_dir argument?

No. Even with control of package_dir, the os.path.isfile() check on source_path still requires the final resolved path to point to an actual file, and the script exits before any file operations occur if this check fails.

Why check os.path.isdir(output_path) specifically rather than a broader validation?

The output_path is used with MarkItDown.convert_local(), which expects to write to a file. Passing a directory would cause confusing errors or potentially overwrite behavior; this check ensures clean failure with a descriptive message.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7

Related Articles

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.

high

fs.readFileSync(process.argv[2]) Path Traversal in Zola Build

A build-time helper that extracts the expected SHA-256 for a downloaded Zola release passed `process.argv[2]` straight into `fs.readFileSync()` with no directory constraint, so any caller able to influence that argument could make the integrity check read an arbitrary file. The fix resolves the requested path and requires it to be a direct child of the tools directory, which is now passed in as an extra argument, and exits with an error otherwise. Because the bytes read become the "expected" che

high

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`

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

stream_media_file SSRF: src Parameter Reaches requests.get()

A media-download helper accepted a fully attacker-controlled URL from the `src` query parameter and passed it straight to `requests.get()`, turning the service into an open HTTP proxy for internal networks and cloud metadata endpoints. The fix introduces an `assert_safe_url()` guard that resolves the hostname with `getaddrinfo()` and rejects private, loopback, link-local, reserved, and multicast addresses before any request is issued. The guard is now called at the top of both `download_media_fi