Back to Blog
critical SEVERITY6 min read

How path traversal happens in Python os.path and how to fix it

A critical path traversal vulnerability in the TRL backend allowed attackers to read arbitrary system files like `/etc/passwd` and `/proc/self/environ` through the gRPC fine-tuning API. The `_do_training` method passed user-controlled `dataset_source` directly to `os.path.exists()` and `load_dataset()` without validation. The fix implements strict directory containment checks using `os.path.realpath()` to ensure all file operations stay within allowed directories.

O
By Orbis AppSec
Published June 22, 2026Reviewed June 22, 2026

Answer Summary

Path traversal (CWE-22) in Python occurs when user input is passed directly to filesystem functions like `os.path.exists()` without validation. In this TRL backend vulnerability, the `_do_training` method accepted arbitrary paths via gRPC requests, enabling attackers to read sensitive files. The fix validates paths using `os.path.realpath()` and `os.path.abspath()` to ensure they remain within a configurable allowed directory (`LOCALAI_DATASET_DIR`), rejecting any path that escapes the boundary.

Vulnerability at a Glance

cweCWE-22
fixImplement directory containment check using `os.path.realpath()` against allowed directory boundary
riskAttackers can read sensitive system files (/etc/passwd, /proc/self/environ) via gRPC requests
languagePython
root causeUser-controlled `dataset_source` passed directly to `os.path.exists()` without path validation
vulnerabilityPath Traversal / Arbitrary File Read

Introduction

The TRL (Transformer Reinforcement Learning) backend in LocalAI handles fine-tuning requests via gRPC, but a critical flaw in backend/python/trl/backend.py at line 310 created a dangerous attack vector. The _do_training method accepted a dataset_source parameter from incoming StartFineTune gRPC requests and passed it directly to os.path.exists() and load_dataset() without any validation.

This meant an attacker with network access to the gRPC backend on port 50051 could specify paths like /etc/passwd or /proc/self/environ as their "dataset source" and trick the server into reading—and potentially leaking—sensitive system files. For a machine learning backend that's designed to load training data, this represents a complete breakdown of the security boundary between user-supplied input and the underlying filesystem.

The Vulnerability Explained

The Dangerous Code Pattern

The vulnerable code in _do_training looked like this:

dataset_split = request.dataset_split or "train"
if os.path.exists(request.dataset_source):
    if request.dataset_source.endswith('.json') or request.dataset_source.endswith('.jsonl'):
        dataset = load_dataset("json", data_files=request.dataset_source, split=dataset_split)
    elif request.dataset_source.endswith('.csv'):
        # ... load CSV dataset

The problem is immediately apparent: request.dataset_source comes directly from the gRPC request with zero validation. The code trusts that the client will only send legitimate dataset paths, but a malicious actor can send anything.

Attack Scenario

Here's how an attacker could exploit this:

  1. Reconnaissance: The attacker discovers the TRL gRPC backend is exposed on port 50051 (either directly or through a misconfigured network)

  2. Crafting the Request: They send a StartFineTune gRPC request with:
    dataset_source: "/proc/self/environ" dataset_split: "train" model_name: "sshleifer/tiny-gpt2" output_dir: "/tmp/output"

  3. File Access: The backend calls os.path.exists("/proc/self/environ"), which returns True. The code then attempts to load this "dataset," potentially exposing environment variables containing API keys, database credentials, or other secrets.

  4. Escalation: With access to /etc/passwd, the attacker learns system usernames. With /proc/self/environ, they might find AWS_SECRET_ACCESS_KEY, DATABASE_URL, or other sensitive environment variables that the ML backend uses.

Why This Is Critical

This isn't just a theoretical risk. The TRL backend is a Python service that likely runs with access to:
- Model weights and training data (potentially proprietary)
- API tokens for HuggingFace and other services
- Cloud credentials for distributed training
- Database connections for experiment tracking

A path traversal here gives attackers a window into all of these.

The Fix

The fix implements a directory containment check—a security pattern that ensures all file operations stay within a designated safe directory.

Before (Vulnerable)

dataset_split = request.dataset_split or "train"
if os.path.exists(request.dataset_source):
    if request.dataset_source.endswith('.json') or request.dataset_source.endswith('.jsonl'):
        dataset = load_dataset("json", data_files=request.dataset_source, split=dataset_split)

After (Fixed)

dataset_split = request.dataset_split or "train"
if os.path.exists(request.dataset_source):
    _allowed_dir = os.path.realpath(os.path.abspath(os.environ.get("LOCALAI_DATASET_DIR", os.getcwd())))
    _real_path = os.path.realpath(os.path.abspath(request.dataset_source))
    if not (_real_path == _allowed_dir or _real_path.startswith(_allowed_dir + os.sep)):
        raise ValueError("Dataset source path is outside the allowed directory")
    if request.dataset_source.endswith('.json') or request.dataset_source.endswith('.jsonl'):
        dataset = load_dataset("json", data_files=request.dataset_source, split=dataset_split)

How the Fix Works

  1. os.path.abspath(): Converts any relative path to an absolute path based on the current working directory

  2. os.path.realpath(): Resolves all symbolic links to get the true canonical path—this prevents symlink-based bypasses

  3. Boundary Check: The code verifies that the resolved path either equals the allowed directory or starts with the allowed directory followed by a path separator (os.sep). The separator check is crucial—without it, /allowed/dir_malicious would pass a check for /allowed/dir

  4. Configurable Allowed Directory: The LOCALAI_DATASET_DIR environment variable lets operators define where datasets should live, defaulting to the current working directory

The Output Path Fix

The same vulnerability existed in the ExportModel method for output paths. The fix applies identical logic:

_allowed_output_dir = os.path.realpath(os.path.abspath(os.environ.get("LOCALAI_OUTPUT_DIR", os.getcwd())))
_real_output_path = os.path.realpath(os.path.abspath(output_path))
if not (_real_output_path == _allowed_output_dir or _real_output_path.startswith(_allowed_output_dir + os.sep)):
    raise ValueError("Output path is outside the allowed directory")
output_path = _real_output_path

This prevents attackers from writing model exports to arbitrary locations like /etc/cron.d/ or overwriting system files.

Prevention & Best Practices

1. Always Validate File Paths from User Input

Never trust paths from external sources. Always:
- Resolve to canonical paths with os.path.realpath()
- Check against an allowed directory boundary
- Use the path separator in your prefix check

def is_safe_path(base_dir: str, user_path: str) -> bool:
    """Check if user_path is safely within base_dir."""
    base = os.path.realpath(os.path.abspath(base_dir))
    target = os.path.realpath(os.path.abspath(user_path))
    return target == base or target.startswith(base + os.sep)

2. Use Allowlists for Dataset Sources

For ML backends, consider maintaining an allowlist of valid dataset identifiers:

ALLOWED_DATASETS = {"imdb", "squad", "glue", "custom_dataset_v1"}

if request.dataset_source in ALLOWED_DATASETS:
    dataset = load_dataset(request.dataset_source, split=dataset_split)
elif is_safe_path(DATASET_DIR, request.dataset_source):
    # Load from local file

3. Principle of Least Privilege

Run ML backends with minimal filesystem permissions. Use containerization or chroot to limit what the process can access even if path validation fails.

4. Input Validation at the API Boundary

Validate inputs as early as possible—ideally in the gRPC service definition or a middleware layer:

def validate_dataset_source(source: str) -> bool:
    # Reject absolute paths
    if os.path.isabs(source):
        return False
    # Reject path traversal sequences
    if '..' in source:
        return False
    return True

Key Takeaways

  • The _do_training method's direct use of request.dataset_source in os.path.exists() created a critical file read vulnerability—always validate paths before filesystem operations

  • gRPC services are network-exposed attack surfaces—treat all request parameters as potentially malicious, even in internal ML pipelines

  • os.path.realpath() + boundary checking is the correct fix pattern—simple string filtering for ../ is insufficient and can be bypassed

  • Both input paths (dataset_source) and output paths (output_path) need validation—the fix addressed both vectors in _do_training and ExportModel

  • Environment-configurable allowed directories (LOCALAI_DATASET_DIR, LOCALAI_OUTPUT_DIR) enable secure deployment flexibility without hardcoding paths

How Orbis AppSec Detected This

  • Source: The dataset_source field from incoming StartFineTune gRPC requests in backend/python/trl/backend.py
  • Sink: os.path.exists(request.dataset_source) at line 310 and subsequent load_dataset() calls
  • Missing control: No path validation or directory containment check before filesystem access
  • CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)
  • Fix: Added os.path.realpath() resolution and boundary validation against LOCALAI_DATASET_DIR before any filesystem operations

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 vulnerabilities remain one of the most common and dangerous security issues in web services and APIs. This TRL backend vulnerability demonstrates how easily they can slip into ML infrastructure code, where the focus is often on model performance rather than security hardening.

The fix is straightforward but must be applied correctly: resolve paths to their canonical form, check against an allowed directory boundary, and include the path separator in your prefix check. These three steps—implemented consistently across all file operations—form a robust defense against path traversal attacks.

For teams building ML pipelines and fine-tuning services, remember that your training infrastructure is a high-value target. Attackers who compromise these systems gain access to models, training data, and the credentials that connect to your broader infrastructure.

References

Frequently Asked Questions

What is path traversal?

Path traversal is a vulnerability where attackers manipulate file paths to access files outside intended directories, often using sequences like `../` or absolute paths like `/etc/passwd`.

How do you prevent path traversal in Python?

Use `os.path.realpath()` and `os.path.abspath()` to resolve the canonical path, then verify it starts with your allowed base directory using string prefix checking with the path separator.

What CWE is path traversal?

Path traversal is classified as CWE-22 (Improper Limitation of a Pathname to a Restricted Directory).

Is checking for `../` enough to prevent path traversal?

No. Attackers can bypass simple string checks using absolute paths (`/etc/passwd`), URL encoding, or symlinks. Always resolve to the real path and validate against an allowed directory.

Can static analysis detect path traversal?

Yes. Static analysis tools can trace data flow from user inputs to filesystem functions and flag cases where validation is missing before calls like `os.path.exists()` or `open()`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #10422

Related Articles

high

How missing Dependabot cooldown happens in GitHub Actions and how to fix it

A high-severity configuration vulnerability was discovered in a `.github/dependabot.yml` file that lacked a cooldown period for package updates. Without this safeguard, Dependabot could immediately propose updates to newly published package versions—including potentially malicious or unstable releases. The fix adds a simple `cooldown` block with a 7-day waiting period before any new package version is suggested.

high

How Server-Sent Events Injection via Unsanitized Newlines happens in Node.js h3 and how to fix it

A high-severity Server-Sent Events (SSE) injection vulnerability (CVE-2026-33128) was discovered in the h3 HTTP framework, where unsanitized newline characters in event stream fields could allow attackers to inject arbitrary SSE messages. The fix upgrades h3 from version 1.15.5 to 1.15.6 in the frontend's dependency tree, ensuring that newline characters are properly sanitized before being written to event streams.

high

How Memory Exhaustion via Large Comma-Separated Selector Lists happens in Python Soup Sieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in Soup Sieve version 2.8.3, affecting Python applications that parse CSS selectors from user-controlled input. The vulnerability allows attackers to craft malicious selector lists that consume excessive memory, potentially causing denial of service. The fix involves upgrading to soupsieve 2.8.4, which implements proper resource limits on selector parsing.

high

How prototype pollution via `__proto__` key happens in Node.js defu and how to fix it

A high-severity prototype pollution vulnerability (CVE-2026-35209) was discovered in the `defu` package version 6.1.4, which allowed attackers to inject properties into JavaScript's `Object.prototype` via the `__proto__` key in defaults arguments. The fix upgrades `defu` to version 6.1.5 in the frontend's dependency tree, protecting downstream consumers like `c12` and `dotenv` configuration loaders from malicious property injection.

critical

How buffer overflow in memcpy() happens in Node.js N-API bindings and how to fix it

A critical buffer overflow vulnerability was discovered in the GetBufferAsVector() function in examples_nodejs/src/zupt_napi.cpp, where memcpy() copied data from JavaScript Uint8Array buffers without proper bounds validation. This vulnerability could allow attackers to trigger memory corruption by providing maliciously crafted input arrays to the native Node.js module, potentially leading to crashes or arbitrary code execution.

high

How memory exhaustion via large comma-separated selector lists happens in Python soupsieve and how to fix it

A high-severity memory exhaustion vulnerability (CVE-2026-49476) was discovered in soupsieve 2.8.3, a CSS selector library used by BeautifulSoup in Python. An attacker who could influence CSS selector input could craft large comma-separated selector lists to exhaust system memory, causing denial of service. The fix upgrades soupsieve from 2.8.3 to 2.8.4 in the backend's `uv.lock` dependency file.