Files
training-software/tests/test_upload.py
Paperclip CTO 1f6a4183d4 feat(TRA-247): M5 security hardening — tests, markers, and header enforcement
- tests/test_security.py: 30 security regression tests covering secure
  headers, CSP directives, middleware ordering, DRF throttle configuration,
  and SecurityAuditMiddleware event-detection logic
- tests/test_upload.py: 19 upload defense tests covering extension allow-list,
  byte-length limits, and magic-byte signature validation (polyglot / disguised
  executable detection)
- pytest.ini: register 'security' and 'upload' markers (--strict-markers
  enforcement was already on)

Security settings already committed in feat(TRA-233) via harness include:
SECURE_REFERRER_POLICY, CSP_* directives, DEFAULT_THROTTLE_*, MAX_UPLOAD_SIZE,
SESSION/CSRF cookie hardening, AWS presigned URL policy, and
SecurityAuditMiddleware with dual-logger (access + security) pattern.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-07 09:11:23 +02:00

189 lines
7.6 KiB
Python

"""
Upload defense tests — M5: Security Hardening and Upload Defense.
Verifies that the upload validators correctly:
- Allow files with permitted extensions and matching magic bytes.
- Reject disallowed extensions.
- Reject oversized files.
- Reject files whose magic bytes contradict the declared extension (polyglots,
disguised executables, etc.).
"""
import io
import pytest
from django.core.exceptions import ValidationError
from api.upload_validators import (
validate_file_extension,
validate_file_size,
validate_file_signature,
validate_upload,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_file(name: str, content: bytes, size: int | None = None):
"""Return a minimal file-like object accepted by the validators."""
buf = io.BytesIO(content)
buf.name = name
buf.size = size if size is not None else len(content)
return buf
# ---------------------------------------------------------------------------
# Extension validation
# ---------------------------------------------------------------------------
@pytest.mark.upload
class TestFileExtension:
def test_allowed_pdf(self, settings):
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf"]
f = _mock_file("report.pdf", b"%PDF-1.4")
validate_file_extension(f) # should not raise
def test_allowed_jpg(self, settings):
settings.ALLOWED_UPLOAD_EXTENSIONS = [".jpg", ".jpeg"]
f = _mock_file("photo.jpg", b"\xFF\xD8\xFF\xE0")
validate_file_extension(f) # should not raise
def test_disallowed_exe(self, settings):
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf", ".jpg"]
f = _mock_file("malware.exe", b"MZ\x00\x00")
with pytest.raises(ValidationError, match="not permitted"):
validate_file_extension(f)
def test_disallowed_php(self, settings):
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf"]
f = _mock_file("shell.php", b"<?php system($_GET['cmd']); ?>")
with pytest.raises(ValidationError, match="not permitted"):
validate_file_extension(f)
def test_case_insensitive_extension(self, settings):
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf"]
f = _mock_file("REPORT.PDF", b"%PDF-1.4")
validate_file_extension(f) # uppercase should still pass
def test_double_extension_blocked(self, settings):
"""file.pdf.exe must be rejected because final extension is .exe."""
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf"]
f = _mock_file("file.pdf.exe", b"MZ\x00\x00")
with pytest.raises(ValidationError):
validate_file_extension(f)
# ---------------------------------------------------------------------------
# Size validation
# ---------------------------------------------------------------------------
@pytest.mark.upload
class TestFileSize:
def test_within_limit(self, settings):
settings.MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
f = _mock_file("small.pdf", b"%PDF", size=5 * 1024 * 1024)
validate_file_size(f) # should not raise
def test_exactly_at_limit(self, settings):
limit = 10 * 1024 * 1024
settings.MAX_UPLOAD_SIZE_BYTES = limit
f = _mock_file("exact.pdf", b"%PDF", size=limit)
validate_file_size(f) # boundary — allowed
def test_exceeds_limit(self, settings):
settings.MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024
f = _mock_file("huge.pdf", b"%PDF", size=11 * 1024 * 1024)
with pytest.raises(ValidationError, match="exceeds"):
validate_file_size(f)
def test_zero_byte_file_passes_size_check(self, settings):
settings.MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024
f = _mock_file("empty.txt", b"", size=0)
validate_file_size(f) # size check only; signature check is a different concern
# ---------------------------------------------------------------------------
# Magic-byte / signature validation
# ---------------------------------------------------------------------------
@pytest.mark.upload
class TestFileSignature:
def test_valid_pdf_signature(self):
f = _mock_file("report.pdf", b"%PDF-1.7 content here")
validate_file_signature(f) # should not raise
def test_valid_png_signature(self):
f = _mock_file("image.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
validate_file_signature(f) # should not raise
def test_valid_jpeg_signature(self):
f = _mock_file("photo.jpg", b"\xFF\xD8\xFF\xE0" + b"\x00" * 100)
validate_file_signature(f) # should not raise
def test_exe_disguised_as_pdf(self):
"""MZ header in a .pdf file should be rejected."""
f = _mock_file("evil.pdf", b"MZ\x90\x00" + b"\x00" * 100)
with pytest.raises(ValidationError, match="does not match"):
validate_file_signature(f)
def test_php_disguised_as_jpg(self):
"""PHP script in a .jpg file should be rejected."""
f = _mock_file("shell.jpg", b"<?php echo 'pwned'; ?>" + b"\x00" * 100)
with pytest.raises(ValidationError, match="does not match"):
validate_file_signature(f)
def test_txt_skips_signature_check(self):
"""Plain text files have no reliable magic bytes — skip signature check."""
f = _mock_file("data.txt", b"Hello, world!")
validate_file_signature(f) # no signature check for .txt
def test_csv_skips_signature_check(self):
f = _mock_file("export.csv", b"id,name,value\n1,foo,bar\n")
validate_file_signature(f) # no signature check for .csv
def test_docx_valid_zip_signature(self):
f = _mock_file("document.docx", b"PK\x03\x04" + b"\x00" * 100)
validate_file_signature(f) # should not raise
def test_file_pointer_reset_after_check(self):
"""Signature check must leave the file pointer at position 0."""
content = b"%PDF-1.7 content here"
f = _mock_file("report.pdf", content)
validate_file_signature(f)
assert f.tell() == 0
# ---------------------------------------------------------------------------
# Combined validate_upload pipeline
# ---------------------------------------------------------------------------
@pytest.mark.upload
class TestValidateUploadPipeline:
def test_valid_pdf_passes_all_checks(self, settings):
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf"]
settings.MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024
f = _mock_file("report.pdf", b"%PDF-1.7 " + b"a" * 100)
validate_upload(f) # should not raise
def test_bad_extension_short_circuits_pipeline(self, settings):
"""Extension check runs first; the pipeline stops there."""
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf"]
settings.MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024
f = _mock_file("malware.exe", b"MZ\x90\x00")
with pytest.raises(ValidationError, match="not permitted"):
validate_upload(f)
def test_oversized_file_with_valid_extension(self, settings):
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf"]
settings.MAX_UPLOAD_SIZE_BYTES = 1 * 1024 # 1 KB limit
f = _mock_file("big.pdf", b"%PDF-1.7 " + b"a" * 100, size=2 * 1024)
with pytest.raises(ValidationError, match="exceeds"):
validate_upload(f)
def test_wrong_magic_bytes_with_valid_extension_and_size(self, settings):
settings.ALLOWED_UPLOAD_EXTENSIONS = [".pdf"]
settings.MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024
f = _mock_file("disguised.pdf", b"MZ\x90\x00" + b"a" * 100)
with pytest.raises(ValidationError, match="does not match"):
validate_upload(f)