SC011: PathTraversal

Overview

Property Value
ID SC011
Name PathTraversal
Group security
Severity WARNING

Description

Flags file operations where paths are constructed from function parameters or external input without proper validation.

Path traversal vulnerabilities allow attackers to:

  • Read sensitive files: Access /etc/passwd, configuration files, source code
  • Write arbitrary files: Overwrite system files or plant malicious code
  • Delete files: Remove critical system or application files
  • Escape sandboxes: Access files outside intended directories

What it checks

The check scans all Python files (excluding test files, .venv/, and __pycache__/) for file operations where paths come from function parameters:

  • open() with parameter-derived paths: open(filename) where filename is a function parameter
  • Path() with parameter-derived paths: Path(user_path) where user_path is a parameter
  • os.path.join() with parameter-derived paths: Joining base path with user input

Result states

  • PASSED: No path traversal patterns found
  • FAILED: Potential path traversal vulnerabilities detected

Detected patterns

# Flagged - parameter goes directly to open()
def read_file(filename):
    with open(filename, "r") as f:  # WARNING
        return f.read()

# Flagged - parameter goes to Path()
def process_file(user_path):
    path = Path(user_path)  # WARNING
    return path.read_text()

# Flagged - parameter used in os.path.join()
def save_file(name, content):
    path = os.path.join("/data", name)  # WARNING
    with open(path, "w") as f:
        f.write(content)

How to fix

Validate and sanitize paths

from pathlib import Path

ALLOWED_DIR = Path("/app/data")

def read_file(filename: str) -> str:
    # Resolve to absolute path and check it's within allowed directory
    requested_path = (ALLOWED_DIR / filename).resolve()

    # Ensure path is still within allowed directory after resolution
    if not requested_path.is_relative_to(ALLOWED_DIR):
        raise ValueError("Access denied: path traversal attempt")

    return requested_path.read_text()

Use resolve() and check containment

from pathlib import Path

def safe_path_join(base_dir: Path, user_input: str) -> Path:
    """Safely join base directory with user input."""
    # Resolve both paths to eliminate .. and symlinks
    base = base_dir.resolve()
    requested = (base / user_input).resolve()

    # Verify the result is still under base_dir
    if not requested.is_relative_to(base):
        raise ValueError(f"Path {user_input} escapes base directory")

    return requested

Reject suspicious patterns early

import re
from pathlib import Path

def validate_filename(filename: str) -> bool:
    """Validate filename doesn't contain path traversal patterns."""
    # Reject path separators and traversal patterns
    if ".." in filename:
        return False
    if "/" in filename or "\\" in filename:
        return False
    # Only allow alphanumeric, underscore, hyphen, and dot
    if not re.match(r"^[\w\-\.]+$", filename):
        return False
    return True

def read_user_file(filename: str) -> str:
    if not validate_filename(filename):
        raise ValueError("Invalid filename")

    path = Path("/app/uploads") / filename
    return path.read_text()

Use os.path.realpath for validation

import os

def safe_read(base_dir: str, filename: str) -> str:
    """Safely read a file within base_dir."""
    # Construct and resolve the path
    base_dir = os.path.realpath(base_dir)
    file_path = os.path.realpath(os.path.join(base_dir, filename))

    # Check the resolved path starts with base_dir
    if not file_path.startswith(base_dir + os.sep):
        raise ValueError("Access denied")

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

Use a whitelist approach

from pathlib import Path
from typing import Optional

ALLOWED_FILES = {
    "config": Path("/app/config/settings.json"),
    "readme": Path("/app/docs/README.md"),
    "license": Path("/app/LICENSE"),
}

def get_file(name: str) -> Optional[str]:
    """Get file by name from whitelist."""
    file_path = ALLOWED_FILES.get(name)
    if file_path is None:
        return None
    return file_path.read_text()

Understanding the attack

# Vulnerable code
def download_file(filename):
    path = f"/app/downloads/{filename}"
    with open(path, "rb") as f:
        return f.read()

# Attack: filename = "../../../etc/passwd"
# Resulting path: /app/downloads/../../../etc/passwd
# Resolves to: /etc/passwd
# Attacker reads system password file!

# Attack: filename = "....//....//....//etc/passwd"
# Some filters only check for ".." but not variations

Common mistakes

Only checking the input string

# WRONG - only checks input, not resolved path
def read_file(base, name):
    if ".." in name:
        raise ValueError("Invalid")
    path = Path(base) / name
    return path.read_text()

# Attacker uses: "subdir/../../etc/passwd"
# Passes the ".." check but still escapes!

Using string startswith

# WRONG - string comparison is insufficient
def read_file(base_dir, filename):
    path = os.path.join(base_dir, filename)
    if not path.startswith(base_dir):
        raise ValueError("Access denied")
    return open(path).read()

# Attack: base_dir = "/app/data"
#         filename = "../data_secret/passwords.txt"
# path = "/app/data/../data_secret/passwords.txt"
# Starts with "/app/data" but escapes it!

Why WARNING severity?

This check is a WARNING because:

  • Some flagged patterns may be safe (internal-only functions)
  • The heuristic cannot determine if input is actually user-controlled
  • Legitimate CLI tools often take file paths as arguments

However, you should carefully review all flagged locations and add proper validation.

Configuration

Skip this check

[tool.pycmdcheck]
skip = ["SC011"]

CLI

pycmdcheck --skip SC011

References