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)wherefilenameis a function parameterPath()with parameter-derived paths:Path(user_path)whereuser_pathis a parameteros.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 requestedReject 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 variationsCommon mistakes
Not resolving symlinks
# WRONG - symlinks can escape directory
def read_file(base, name):
path = Path(base) / name
if ".." not in name: # Insufficient check!
return path.read_text()
# An attacker could create a symlink:
# name = "safe_file" where safe_file -> /etc/passwdOnly 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