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

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

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

picomatch 2.3.1 ReDoS: Extglob Pattern Catastrophic Backtracking

picomatch versions below 2.3.2, 3.0.2, or 4.0.4 contain a Regular Expression Denial of Service vulnerability in extglob pattern parsing. An attacker can cause catastrophic backtracking with patterns containing nested alternations and quantifiers, freezing any Node.js process that evaluates untrusted glob expressions.