Back to Blog
high SEVERITY7 min read

How path traversal in open() happens in Python and how to fix it

A high-severity path traversal vulnerability was discovered in `tool/update-doc.py`, where user-controlled input was passed directly to Python's `open()` function without sanitization. This flaw could allow an attacker to read arbitrary files on the server by manipulating the file path. The fix ensures that file paths are validated and restricted to an intended directory before being opened.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published June 6, 2026•Reviewed June 6, 2026

Answer Summary

This is a path traversal vulnerability (CWE-22) in Python's `open()` function inside `tool/update-doc.py`, where user-controlled input was used as a file path without sanitization. An attacker could supply a path like `../../etc/passwd` to read arbitrary files outside the intended directory. The fix involves sanitizing or validating the input path — typically by resolving it to an absolute path and confirming it remains within an allowed base directory before calling `open()`. Orbis AppSec automatically detected and patched this issue.

Vulnerability at a Glance

cweCWE-22
fixValidate and restrict file paths to an intended base directory before calling open()
riskAttackers can read arbitrary files on the server, including credentials, config files, and source code
languagePython
root causeUser-controlled input passed directly to open() without path sanitization or boundary enforcement
vulnerabilityPath Traversal via open()

Summary

A high-severity path traversal vulnerability was discovered in tool/update-doc.py, where user-controlled input was passed directly to Python's open() function without sanitization. This flaw could allow an attacker to read arbitrary files on the server by manipulating the file path. The fix ensures that file paths are validated and restricted to an intended directory before being opened.


Introduction

The tool/update-doc.py script handles documentation updates — a utility role that might seem low-risk at first glance. But a flaw in how it processes file paths created a serious security hole: user-controlled input flowed directly into a call to open() with no sanitization, no boundary checking, and no path normalization.

This is a textbook path traversal vulnerability. The script trusts the caller to provide a safe file path, but an attacker who controls that input can supply something like ../../../../etc/passwd and cause the application to open files it was never meant to touch.

For developers building documentation tools, CLI utilities, or any script that accepts filenames from external sources, this pattern is surprisingly easy to introduce and surprisingly dangerous to leave unaddressed.


The Vulnerability Explained

What went wrong in update-doc.py

The vulnerable pattern looks like this:

# Vulnerable code — user-controlled `doc_path` passed directly to open()
doc_path = request.args.get("doc")  # or sys.argv, or any external source
with open(doc_path, "r") as f:
    content = f.read()

The problem is straightforward: the value of doc_path comes from user-controlled input and is used directly as the argument to open(). Python's open() function will happily accept any valid filesystem path — including ones that traverse parent directories using ../ sequences.

There is no call to os.path.realpath(), no check that the resolved path starts with a trusted base directory, and no filtering of dangerous path components. The script simply trusts the input.

How an attacker exploits this

Imagine the script is intended to open documentation files from a directory like /app/docs/. An attacker who can influence the doc_path value could supply:

../../etc/passwd

Which resolves to:

/app/docs/../../etc/passwd  →  /etc/passwd

Python's open() resolves this path transparently and returns the contents of /etc/passwd. On a real system, this technique can be used to read:

  • /etc/passwd — system user accounts
  • ~/.ssh/id_rsa — private SSH keys
  • Application config files — database credentials, API keys, secrets
  • Other source files — internal business logic, hardcoded tokens

The attacker doesn't need authentication, special privileges, or a complex exploit chain. They just need to control the string passed to open().

Why update-doc.py is a realistic target

Documentation update scripts often run with elevated privileges (they need to write to the filesystem), and they frequently accept filenames from command-line arguments, web requests, or configuration files — all of which can be attacker-influenced. The combination of elevated access and unsanitized input makes this a high-value target.


The Fix

What needs to change

The fix requires two things:
1. Normalize the path — resolve .. sequences and symlinks to get the true absolute path.
2. Enforce a boundary — confirm the resolved path is inside the intended base directory before opening it.

Before (vulnerable)

# No sanitization — attacker controls doc_path entirely
doc_path = get_user_input()
with open(doc_path, "r") as f:
    content = f.read()

After (fixed)

import os

BASE_DIR = os.path.realpath("/app/docs")

def safe_open_doc(user_input):
    # Resolve the full, normalized absolute path
    requested_path = os.path.realpath(os.path.join(BASE_DIR, user_input))

    # Enforce that the resolved path is within the allowed base directory
    if not requested_path.startswith(BASE_DIR + os.sep):
        raise ValueError(f"Access denied: path traversal detected in '{user_input}'")

    with open(requested_path, "r") as f:
        return f.read()

Why this fix works

  • os.path.realpath() resolves all ../ sequences, symlinks, and redundant separators, giving you the true absolute path on the filesystem. An attacker can't hide traversal in URL encoding or double-dot tricks once this is applied.
  • startswith(BASE_DIR + os.sep) ensures the resolved path is genuinely inside the base directory, not just a string that happens to start with the same prefix. The + os.sep prevents a bypass where /app/docs_evil would incorrectly match /app/docs.
  • The check happens before open() is called, so the file is never opened if the path is out of bounds.

Using pathlib (Python 3.6+) offers an equally clean and Pythonic approach:

from pathlib import Path

BASE_DIR = Path("/app/docs").resolve()

def safe_open_doc(user_input):
    requested_path = (BASE_DIR / user_input).resolve()

    if not requested_path.is_relative_to(BASE_DIR):
        raise ValueError("Path traversal detected")

    return requested_path.read_text()

Path.is_relative_to() (Python 3.9+) makes the boundary check explicit and readable.


Key Takeaways

  • open(user_input) in update-doc.py is a direct path traversal sink — any user-controlled string reaching this call without normalization and boundary checking is exploitable.
  • String filtering for ../ is not a safe mitigation — attackers can encode, double-encode, or use OS-specific tricks to bypass blocklists. Use realpath() + prefix check instead.
  • Documentation and utility scripts are not low-risk — tools like update-doc.py often run with elevated permissions and accept filenames as input, making them attractive targets.
  • The startswith(BASE_DIR + os.sep) pattern is critical — omitting + os.sep creates a bypass where sibling directories with similar names pass the check incorrectly.
  • pathlib.Path.is_relative_to() is the modern, readable way to enforce directory boundaries in Python 3.9+ and should be preferred in new code.

How Orbis AppSec Detected This

  • Source: User-controlled input (e.g., command-line argument, request parameter, or external config value) providing the file path in tool/update-doc.py
  • Sink: open(doc_path, ...) called with the unsanitized user-controlled path, allowing arbitrary file reads
  • Missing control: No call to os.path.realpath() or equivalent normalization; no check that the resolved path falls within an intended base directory before the file is opened
  • CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
  • Fix: Normalize the input path with os.path.realpath() and verify it resolves within the allowed base directory before passing it to open()

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

Path traversal in Python's open() is one of those vulnerabilities that looks simple but carries serious consequences. In tool/update-doc.py, the absence of a single normalization-and-boundary-check pattern was enough to expose the entire server filesystem to anyone who could influence the file path argument.

The fix is not complex — os.path.realpath() combined with a startswith() check, or pathlib's is_relative_to(), closes the door entirely. The lesson for developers is to treat every file path that originates from outside your code as untrusted, normalize it unconditionally, and verify it resolves where you expect before acting on it.

Security in file handling isn't about trusting your users. It's about building code that stays safe regardless of what input it receives.


Prevention and further reading

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.