Files
gh-christianlouis-docuelevate/tests/test_path_traversal_security.py
T
google-labs-jules[bot] 9b748db4d4 refactor(tasks): extract filename regex to shared constant
Move the valid filename regex pattern to a shared constant in `app/utils/filename_utils.py` and update both the task logic and security tests to use it. This eliminates duplication and ensures consistency across the codebase.

Also normalized line endings to LF in affected files to ensure CI compatibility.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 14:23:31 +00:00

500 lines
18 KiB
Python

"""
Security tests for path traversal vulnerabilities.
Tests all file path operations to ensure they properly prevent path traversal attacks.
"""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.security
@pytest.mark.unit
class TestFilenameSanitization:
"""Test that filename sanitization prevents path traversal attacks."""
def test_sanitize_removes_parent_directory_traversal(self):
"""Test that ../ patterns are removed."""
from app.utils.filename_utils import sanitize_filename
# Unix-style path traversal
result = sanitize_filename("../../../etc/passwd")
assert ".." not in result
assert "/" not in result
assert "etc" in result
assert "passwd" in result
def test_sanitize_removes_windows_path_traversal(self):
"""Test that ..\\ patterns are removed."""
from app.utils.filename_utils import sanitize_filename
# Windows-style path traversal
result = sanitize_filename("..\\..\\windows\\system32")
assert ".." not in result
assert "\\" not in result
assert "windows" in result
def test_sanitize_removes_unix_path_separators(self):
"""Test that Unix path separators are removed."""
from app.utils.filename_utils import sanitize_filename
result = sanitize_filename("/etc/passwd")
assert "/" not in result
assert result == "etc_passwd"
def test_sanitize_removes_windows_path_separators(self):
"""Test that Windows path separators are removed."""
from app.utils.filename_utils import sanitize_filename
result = sanitize_filename("C:\\Windows\\System32")
assert "\\" not in result
assert ":" not in result
def test_sanitize_handles_mixed_path_separators(self):
"""Test that mixed path separators are handled."""
from app.utils.filename_utils import sanitize_filename
result = sanitize_filename("../folder/..\\..\\file.pdf")
assert "/" not in result
assert "\\" not in result
assert ".." not in result
def test_sanitize_removes_null_bytes(self):
"""Test that null bytes are removed (security issue)."""
from app.utils.filename_utils import sanitize_filename
result = sanitize_filename("file\x00.pdf")
assert "\x00" not in result
def test_sanitize_preserves_safe_characters(self):
"""Test that safe characters are preserved."""
from app.utils.filename_utils import sanitize_filename
result = sanitize_filename("Document_2024-01-15.pdf")
assert result == "Document_2024-01-15.pdf"
def test_sanitize_handles_unicode_attacks(self):
"""Test that Unicode path separators are handled."""
from app.utils.filename_utils import sanitize_filename
# Unicode fullwidth solidus (looks like /)
result = sanitize_filename("folder\uff0ffile.pdf")
# Should be replaced with underscore
assert "\uff0f" not in result
@pytest.mark.security
@pytest.mark.unit
class TestEmbedMetadataPathTraversal:
"""Test that embed_metadata_into_pdf prevents path traversal via metadata filename."""
def test_malicious_filename_in_metadata_is_sanitized(self, tmp_path):
"""Test that malicious filenames from GPT metadata are sanitized."""
from app.utils.filename_utils import get_unique_filepath_with_counter, sanitize_filename
# Simulate malicious metadata from GPT
malicious_filename = "../../etc/passwd"
# This should be sanitized before being used
sanitized = sanitize_filename(malicious_filename)
# Verify sanitization removes path traversal
assert ".." not in sanitized
assert "/" not in sanitized
assert "\\" not in sanitized
# Verify unique_filepath with sanitized name stays in directory
result = get_unique_filepath_with_counter(str(tmp_path), sanitized, ".pdf")
result_path = Path(result)
# Ensure result is within tmp_path
assert result_path.parent == tmp_path
def test_embed_metadata_validates_filename_field(self, tmp_path):
"""Test that embed_metadata_into_pdf sanitizes the filename from metadata."""
from app.utils.filename_utils import get_unique_filepath_with_counter, sanitize_filename
# Test various malicious filenames
malicious_filenames = [
"../../../etc/passwd",
"..\\..\\windows\\system32",
"/etc/shadow",
"C:\\Windows\\win.ini",
"folder/../file",
"folder\\..\\file",
]
for malicious in malicious_filenames:
# Sanitize as the task should do
sanitized = sanitize_filename(malicious)
# Verify no path traversal is possible
result = get_unique_filepath_with_counter(str(tmp_path), sanitized, ".pdf")
result_path = Path(result)
# Result must be direct child of tmp_path
assert result_path.parent == tmp_path, f"Failed for: {malicious}"
@patch("app.tasks.embed_metadata_into_pdf.settings")
@patch("app.tasks.embed_metadata_into_pdf.SessionLocal")
@patch("app.tasks.embed_metadata_into_pdf.log_task_progress")
@patch("app.tasks.embed_metadata_into_pdf.finalize_document_storage")
@patch("pypdf.PdfReader")
@patch("pypdf.PdfWriter")
def test_embed_metadata_full_flow_with_malicious_filename(
self,
mock_pdf_writer,
mock_pdf_reader,
mock_finalize,
mock_log,
mock_session,
mock_settings,
tmp_path,
):
"""Integration test: full embed_metadata_into_pdf with malicious filename."""
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
# Setup
mock_settings.workdir = str(tmp_path)
# Create a temporary PDF file
test_pdf = tmp_path / "test.pdf"
test_pdf.write_bytes(b"%PDF-1.4\n")
# Mock PDF operations
mock_reader_instance = MagicMock()
mock_reader_instance.pages = []
mock_pdf_reader.return_value = mock_reader_instance
mock_writer_instance = MagicMock()
mock_pdf_writer.return_value = mock_writer_instance
# Malicious metadata from GPT
malicious_metadata = {
"filename": "../../../etc/passwd", # Path traversal attempt
"document_type": "Invoice",
"tags": ["test"],
}
# Create processed directory
processed_dir = tmp_path / "processed"
processed_dir.mkdir()
# Execute task (called directly, Celery injects 'self' automatically)
result = embed_metadata_into_pdf(
str(test_pdf),
"test text",
malicious_metadata,
file_id=1,
)
# Verify the result file is in the processed directory
# and not in /etc/ or parent directories
if "file" in result:
result_path = Path(result["file"])
# Should be in processed directory
assert result_path.parent == processed_dir
# Should not contain path traversal
assert ".." not in result_path.name
assert "/" not in result_path.name
assert "\\" not in result_path.name
@pytest.mark.security
@pytest.mark.unit
class TestExtractMetadataFilenameValidation:
"""Test that extract_metadata_with_gpt validates GPT-provided filenames."""
def test_validates_filename_format(self):
"""Test that invalid filename formats are rejected."""
import re
from app.utils.filename_utils import VALID_FILENAME_PATTERN
# Valid pattern from app.utils.filename_utils
valid_pattern = VALID_FILENAME_PATTERN
# Test valid filenames
valid_filenames = [
"2024-01-15_Invoice.pdf",
"Document_Name.pdf",
"My Document 2024.pdf",
"file-name_123.pdf",
]
for filename in valid_filenames:
# Remove extension for test
name_only = filename.rsplit(".", 1)[0]
assert re.match(valid_pattern, name_only), f"Valid filename rejected: {filename}"
# Test invalid filenames
invalid_filenames = [
"../../../etc/passwd",
"/etc/shadow",
"C:\\Windows\\system32",
"folder/../file",
"file:name.pdf",
"file|name.pdf",
"file<>name.pdf",
]
for filename in invalid_filenames:
assert not re.match(valid_pattern, filename), f"Invalid filename accepted: {filename}"
def test_rejects_path_traversal_in_filename(self):
"""Test that path traversal patterns are detected."""
malicious_filenames = [
"../../../etc/passwd",
"..\\..\\windows\\system32",
"/etc/shadow",
"folder/../file",
]
for filename in malicious_filenames:
# Check for path traversal indicators
has_traversal = ".." in filename or "/" in filename or "\\" in filename
assert has_traversal, f"Path traversal not detected: {filename}"
@pytest.mark.security
@pytest.mark.unit
class TestPathValidationSecurity:
"""Test secure path validation using pathlib."""
def test_is_relative_to_prevents_traversal(self, tmp_path):
"""Test that is_relative_to prevents directory traversal."""
base_dir = tmp_path / "workdir"
base_dir.mkdir()
# Create a file outside base_dir
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
outside_file = outside_dir / "file.txt"
outside_file.write_text("test")
# Attempt to access file outside base_dir
try:
outside_resolved = outside_file.resolve()
base_resolved = base_dir.resolve()
# Should return False (file is not relative to base_dir)
is_safe = outside_resolved.is_relative_to(base_resolved)
assert not is_safe, "Path traversal not detected"
except ValueError:
# In some Python versions, is_relative_to may raise ValueError
# This is also acceptable (indicates not relative)
pass
def test_resolve_prevents_symlink_attacks(self, tmp_path):
"""Test that resolve() handles symlink attacks."""
base_dir = tmp_path / "workdir"
base_dir.mkdir()
# Create target outside base_dir
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
target_file = outside_dir / "secret.txt"
target_file.write_text("secret")
# Create symlink inside base_dir pointing outside
symlink_path = base_dir / "link.txt"
symlink_path.symlink_to(target_file)
# Resolve should give us the real path
resolved = symlink_path.resolve()
base_resolved = base_dir.resolve()
# The resolved path should NOT be relative to base_dir
try:
is_safe = resolved.is_relative_to(base_resolved)
assert not is_safe, "Symlink attack not detected"
except ValueError:
# is_relative_to raises ValueError if not relative
pass
def test_string_based_validation_is_insecure(self, tmp_path):
"""Demonstrate why string-based path validation is insecure."""
base_dir = tmp_path / "workdir"
base_dir.mkdir()
# Create a similar-named directory
fake_dir = tmp_path / "workdir-fake"
fake_dir.mkdir()
fake_file = fake_dir / "file.txt"
fake_file.write_text("content")
# String-based check (insecure)
base_str = str(base_dir)
fake_str = str(fake_file)
# This would INCORRECTLY pass string.startswith() if not careful
# because "workdir-fake" starts with "workdir"
if base_str.endswith("/") or base_str.endswith("\\"):
# Properly add separator
string_check_unsafe = fake_str.startswith(base_str)
else:
# Without separator, vulnerable to partial matches
string_check_unsafe = fake_str.startswith(base_str)
# Pathlib-based check (secure)
try:
pathlib_check = fake_file.resolve().is_relative_to(base_dir.resolve())
# Should correctly identify this is NOT under base_dir
assert not pathlib_check, "Pathlib should reject this path"
except ValueError:
# Correctly rejected
pass
@pytest.mark.security
@pytest.mark.unit
class TestFileUploadSecurity:
"""Test that file upload endpoints prevent path traversal."""
def test_ui_upload_uses_basename(self):
"""Test that ui_upload extracts basename to prevent path traversal."""
from app.utils.filename_utils import sanitize_filename
# Simulate malicious filenames
malicious_filenames = [
"../../../etc/passwd",
"/etc/shadow",
"folder/../file.pdf",
]
for malicious in malicious_filenames:
# os.path.basename should extract just the filename
basename = os.path.basename(malicious)
# Verify no path traversal remains in basename
assert ".." not in basename, f"Path traversal not removed: {malicious} -> {basename}"
assert "/" not in basename, f"Path separator not removed: {malicious} -> {basename}"
# Windows-style backslash paths: os.path.basename on Linux does NOT
# split on backslash, so the application also uses sanitize_filename
# to handle these. Verify the combined approach is safe.
windows_paths = [
"..\\..\\windows\\system32",
]
for malicious in windows_paths:
sanitized = sanitize_filename(os.path.basename(malicious))
assert ".." not in sanitized, f"Path traversal not removed after sanitize: {malicious} -> {sanitized}"
assert "\\" not in sanitized, f"Backslash not removed after sanitize: {malicious} -> {sanitized}"
def test_sanitize_after_basename(self):
"""Test that sanitization happens after basename extraction."""
from app.utils.filename_utils import sanitize_filename
malicious = "../../../passwd.pdf"
# Step 1: Extract basename (as ui_upload does)
basename = os.path.basename(malicious)
assert basename == "passwd.pdf"
# Step 2: Sanitize (as ui_upload does)
sanitized = sanitize_filename(basename)
assert sanitized == "passwd.pdf"
# Final result is safe
assert ".." not in sanitized
assert "/" not in sanitized
@pytest.mark.security
@pytest.mark.unit
class TestFileHashSecurity:
"""Test that file hashing doesn't introduce vulnerabilities."""
def test_hash_file_with_absolute_path_only(self, tmp_path):
"""Test that hash_file should only accept absolute paths."""
from app.utils.file_operations import hash_file
# Create a test file
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
# Should work with absolute path
result = hash_file(str(test_file))
assert isinstance(result, str)
assert len(result) == 64 # SHA-256 hex digest length
def test_hash_file_rejects_path_traversal(self):
"""Test that hash_file doesn't allow path traversal."""
from app.utils.file_operations import hash_file
# Attempt to hash a file using path traversal
# This should fail because the file doesn't exist
with pytest.raises(FileNotFoundError):
hash_file("../../../etc/passwd")
@pytest.mark.security
@pytest.mark.integration
class TestEndToEndPathTraversal:
"""Integration tests for path traversal prevention."""
def test_full_upload_flow_prevents_traversal(self, tmp_path):
"""Test complete upload flow prevents path traversal."""
import uuid
from app.utils.filename_utils import sanitize_filename
# Simulate ui_upload flow
malicious_upload_filename = "../../../etc/passwd"
# Step 1: Extract basename
base_filename = os.path.basename(malicious_upload_filename)
assert base_filename == "passwd"
# Step 2: Sanitize
safe_filename = sanitize_filename(base_filename)
assert safe_filename == "passwd"
# Step 3: Add UUID (as ui_upload does)
unique_id = str(uuid.uuid4())
target_filename = f"{unique_id}.{safe_filename}"
# Step 4: Join with workdir
target_path = os.path.join(str(tmp_path), target_filename)
# Verify final path is safe
final_path = Path(target_path)
assert final_path.parent == tmp_path
assert ".." not in target_filename
assert "/" not in target_filename
def test_metadata_embedding_flow_prevents_traversal(self, tmp_path):
"""Test metadata embedding flow prevents path traversal."""
from app.utils.filename_utils import sanitize_filename
# Simulate GPT returning malicious filename
gpt_metadata = {
"filename": "../../../etc/shadow",
"document_type": "Invoice",
}
# Step 1: Extract filename from metadata
suggested_filename = gpt_metadata.get("filename", "fallback")
# Step 2: Sanitize (as embed_metadata_into_pdf should do)
suggested_filename = sanitize_filename(suggested_filename)
# Step 3: Remove extension
suggested_filename = os.path.splitext(suggested_filename)[0]
# Step 4: Build final path
processed_dir = tmp_path / "processed"
processed_dir.mkdir()
final_path = os.path.join(str(processed_dir), f"{suggested_filename}.pdf")
# Verify final path is safe
result_path = Path(final_path)
assert result_path.parent == processed_dir
assert ".." not in str(result_path)