You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

test_files_security.py 10KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import io
  2. from unittest.mock import patch
  3. import pytest
  4. from werkzeug.exceptions import Forbidden
  5. from controllers.common.errors import (
  6. FilenameNotExistsError,
  7. FileTooLargeError,
  8. NoFileUploadedError,
  9. TooManyFilesError,
  10. UnsupportedFileTypeError,
  11. )
  12. from services.errors.file import FileTooLargeError as ServiceFileTooLargeError
  13. from services.errors.file import UnsupportedFileTypeError as ServiceUnsupportedFileTypeError
  14. class TestFileUploadSecurity:
  15. """Test file upload security logic without complex framework setup"""
  16. # Test 1: Basic file validation
  17. def test_should_validate_file_presence(self):
  18. """Test that missing file is detected"""
  19. from flask import Flask, request
  20. app = Flask(__name__)
  21. with app.test_request_context(method="POST", data={}):
  22. # Simulate the check in FileApi.post()
  23. if "file" not in request.files:
  24. with pytest.raises(NoFileUploadedError):
  25. raise NoFileUploadedError()
  26. def test_should_validate_multiple_files(self):
  27. """Test that multiple files are rejected"""
  28. from flask import Flask, request
  29. app = Flask(__name__)
  30. file_data = {
  31. "file": (io.BytesIO(b"content1"), "file1.txt", "text/plain"),
  32. "file2": (io.BytesIO(b"content2"), "file2.txt", "text/plain"),
  33. }
  34. with app.test_request_context(method="POST", data=file_data, content_type="multipart/form-data"):
  35. # Simulate the check in FileApi.post()
  36. if len(request.files) > 1:
  37. with pytest.raises(TooManyFilesError):
  38. raise TooManyFilesError()
  39. def test_should_validate_empty_filename(self):
  40. """Test that empty filename is rejected"""
  41. from flask import Flask, request
  42. app = Flask(__name__)
  43. file_data = {"file": (io.BytesIO(b"content"), "", "text/plain")}
  44. with app.test_request_context(method="POST", data=file_data, content_type="multipart/form-data"):
  45. file = request.files["file"]
  46. if not file.filename:
  47. with pytest.raises(FilenameNotExistsError):
  48. raise FilenameNotExistsError
  49. # Test 2: Security - Filename sanitization
  50. def test_should_detect_path_traversal_in_filename(self):
  51. """Test protection against directory traversal attacks"""
  52. dangerous_filenames = [
  53. "../../../etc/passwd",
  54. "..\\..\\windows\\system32\\config\\sam",
  55. "../../../../etc/shadow",
  56. "./../../../sensitive.txt",
  57. ]
  58. for filename in dangerous_filenames:
  59. # Any filename containing .. should be considered dangerous
  60. assert ".." in filename, f"Filename {filename} should be detected as path traversal"
  61. def test_should_detect_null_byte_injection(self):
  62. """Test protection against null byte injection"""
  63. dangerous_filenames = [
  64. "file.jpg\x00.php",
  65. "document.pdf\x00.exe",
  66. "image.png\x00.sh",
  67. ]
  68. for filename in dangerous_filenames:
  69. # Null bytes should be detected
  70. assert "\x00" in filename, f"Filename {filename} should be detected as null byte injection"
  71. def test_should_sanitize_special_characters(self):
  72. """Test that special characters in filenames are handled safely"""
  73. # Characters that could be problematic in various contexts
  74. dangerous_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|", "\x00"]
  75. for char in dangerous_chars:
  76. filename = f"file{char}name.txt"
  77. # These characters should be detected or sanitized
  78. assert any(c in filename for c in dangerous_chars)
  79. # Test 3: Permission validation
  80. def test_should_validate_dataset_permissions(self):
  81. """Test dataset upload permission logic"""
  82. class MockUser:
  83. is_dataset_editor = False
  84. user = MockUser()
  85. source = "datasets"
  86. # Simulate the permission check in FileApi.post()
  87. if source == "datasets" and not user.is_dataset_editor:
  88. with pytest.raises(Forbidden):
  89. raise Forbidden()
  90. def test_should_allow_general_upload_without_permission(self):
  91. """Test general upload doesn't require dataset permission"""
  92. class MockUser:
  93. is_dataset_editor = False
  94. user = MockUser()
  95. source = None # General upload
  96. # This should not raise an exception
  97. if source == "datasets" and not user.is_dataset_editor:
  98. raise Forbidden()
  99. # Test passes if no exception is raised
  100. # Test 4: Service error handling
  101. @patch("services.file_service.FileService.upload_file")
  102. def test_should_handle_file_too_large_error(self, mock_upload):
  103. """Test that service FileTooLargeError is properly converted"""
  104. mock_upload.side_effect = ServiceFileTooLargeError("File too large")
  105. try:
  106. mock_upload(filename="test.txt", content=b"data", mimetype="text/plain", user=None, source=None)
  107. except ServiceFileTooLargeError as e:
  108. # Simulate the error conversion in FileApi.post()
  109. with pytest.raises(FileTooLargeError):
  110. raise FileTooLargeError(e.description)
  111. @patch("services.file_service.FileService.upload_file")
  112. def test_should_handle_unsupported_file_type_error(self, mock_upload):
  113. """Test that service UnsupportedFileTypeError is properly converted"""
  114. mock_upload.side_effect = ServiceUnsupportedFileTypeError()
  115. try:
  116. mock_upload(
  117. filename="test.exe", content=b"data", mimetype="application/octet-stream", user=None, source=None
  118. )
  119. except ServiceUnsupportedFileTypeError:
  120. # Simulate the error conversion in FileApi.post()
  121. with pytest.raises(UnsupportedFileTypeError):
  122. raise UnsupportedFileTypeError()
  123. # Test 5: File type security
  124. def test_should_identify_dangerous_file_extensions(self):
  125. """Test detection of potentially dangerous file extensions"""
  126. dangerous_extensions = [
  127. ".php",
  128. ".PHP",
  129. ".pHp", # PHP files (case variations)
  130. ".exe",
  131. ".EXE", # Executables
  132. ".sh",
  133. ".SH", # Shell scripts
  134. ".bat",
  135. ".BAT", # Batch files
  136. ".cmd",
  137. ".CMD", # Command files
  138. ".ps1",
  139. ".PS1", # PowerShell
  140. ".jar",
  141. ".JAR", # Java archives
  142. ".vbs",
  143. ".VBS", # VBScript
  144. ]
  145. safe_extensions = [".txt", ".pdf", ".jpg", ".png", ".doc", ".docx"]
  146. # Just verify our test data is correct
  147. for ext in dangerous_extensions:
  148. assert ext.lower() in [".php", ".exe", ".sh", ".bat", ".cmd", ".ps1", ".jar", ".vbs"]
  149. for ext in safe_extensions:
  150. assert ext.lower() not in [".php", ".exe", ".sh", ".bat", ".cmd", ".ps1", ".jar", ".vbs"]
  151. def test_should_detect_double_extensions(self):
  152. """Test detection of double extension attacks"""
  153. suspicious_filenames = [
  154. "image.jpg.php",
  155. "document.pdf.exe",
  156. "photo.png.sh",
  157. "file.txt.bat",
  158. ]
  159. for filename in suspicious_filenames:
  160. # Check that these have multiple extensions
  161. parts = filename.split(".")
  162. assert len(parts) > 2, f"Filename {filename} should have multiple extensions"
  163. # Test 6: Configuration validation
  164. def test_upload_configuration_structure(self):
  165. """Test that upload configuration has correct structure"""
  166. # Simulate the configuration returned by FileApi.get()
  167. config = {
  168. "file_size_limit": 15,
  169. "batch_count_limit": 5,
  170. "image_file_size_limit": 10,
  171. "video_file_size_limit": 500,
  172. "audio_file_size_limit": 50,
  173. "workflow_file_upload_limit": 10,
  174. }
  175. # Verify all required fields are present
  176. required_fields = [
  177. "file_size_limit",
  178. "batch_count_limit",
  179. "image_file_size_limit",
  180. "video_file_size_limit",
  181. "audio_file_size_limit",
  182. "workflow_file_upload_limit",
  183. ]
  184. for field in required_fields:
  185. assert field in config, f"Missing required field: {field}"
  186. assert isinstance(config[field], int), f"Field {field} should be an integer"
  187. assert config[field] > 0, f"Field {field} should be positive"
  188. # Test 7: Source parameter handling
  189. def test_source_parameter_normalization(self):
  190. """Test that source parameter is properly normalized"""
  191. test_cases = [
  192. ("datasets", "datasets"),
  193. ("other", None),
  194. ("", None),
  195. (None, None),
  196. ]
  197. for input_source, expected in test_cases:
  198. # Simulate the source normalization in FileApi.post()
  199. source = "datasets" if input_source == "datasets" else None
  200. if source not in ("datasets", None):
  201. source = None
  202. assert source == expected
  203. # Test 8: Boundary conditions
  204. def test_should_handle_edge_case_file_sizes(self):
  205. """Test handling of boundary file sizes"""
  206. test_cases = [
  207. (0, "Empty file"), # 0 bytes
  208. (1, "Single byte"), # 1 byte
  209. (15 * 1024 * 1024 - 1, "Just under limit"), # Just under 15MB
  210. (15 * 1024 * 1024, "At limit"), # Exactly 15MB
  211. (15 * 1024 * 1024 + 1, "Just over limit"), # Just over 15MB
  212. ]
  213. for size, description in test_cases:
  214. # Just verify our test data
  215. assert isinstance(size, int), f"{description}: Size should be integer"
  216. assert size >= 0, f"{description}: Size should be non-negative"
  217. def test_should_handle_special_mime_types(self):
  218. """Test handling of various MIME types"""
  219. mime_type_tests = [
  220. ("application/octet-stream", "Generic binary"),
  221. ("text/plain", "Plain text"),
  222. ("image/jpeg", "JPEG image"),
  223. ("application/pdf", "PDF document"),
  224. ("", "Empty MIME type"),
  225. (None, "None MIME type"),
  226. ]
  227. for mime_type, description in mime_type_tests:
  228. # Verify test data structure
  229. if mime_type is not None:
  230. assert isinstance(mime_type, str), f"{description}: MIME type should be string or None"