From f13ea267c1e0dc46883d64cbc654dc9a8dea9fad Mon Sep 17 00:00:00 2001 From: deacon Date: Sun, 15 Mar 2026 23:07:52 -0400 Subject: [PATCH 1/9] fix(security): block server-side executable file types in payload upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading .py files via POST /api/v2/payloads followed by triggering their execution constitutes CWE-94 Remote Code Execution. Block .py/.pyc/.pyo/.so/.dll uploads in the payload API handler. LOCAL BRANCH ONLY — security disclosure pending --- app/api/v2/handlers/payload_api.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/api/v2/handlers/payload_api.py b/app/api/v2/handlers/payload_api.py index 1b034a332..dd7a2098f 100644 --- a/app/api/v2/handlers/payload_api.py +++ b/app/api/v2/handlers/payload_api.py @@ -12,6 +12,17 @@ from app.api.v2.schemas.payload_schemas import PayloadQuerySchema, PayloadSchema, PayloadCreateRequestSchema, \ PayloadDeleteRequestSchema +# Extensions that could be executed server-side and must never be accepted as +# uploaded payloads (CWE-94 / Remote Code Execution mitigation). +_BLOCKED_EXTENSIONS = {'.py', '.pyc', '.pyo', '.so', '.dll'} + + +def _validate_payload_extension(filename: str) -> None: + """Raise HTTPBadRequest if the file extension is on the server-side executable blocklist.""" + ext = os.path.splitext(filename)[1].lower() + if ext in _BLOCKED_EXTENSIONS: + raise web.HTTPBadRequest(reason=f"File type {ext!r} is not allowed as a payload") + class PayloadApi(BaseApi): def __init__(self, services): @@ -73,6 +84,9 @@ async def post_payloads(self, request: web.Request): # Sanitize the file name to prevent directory traversal sanitized_filename = self.sanitize_filename(file_field.filename) + # Block server-side executable file types (CWE-94 RCE mitigation) + _validate_payload_extension(sanitized_filename) + # Generate the file name and path file_name, file_path = await self.__generate_file_name_and_path(sanitized_filename) From 7a1e64d37067b43d0ce83d3e645f55778633df36 Mon Sep 17 00:00:00 2001 From: deacon Date: Sun, 15 Mar 2026 23:15:18 -0400 Subject: [PATCH 2/9] test(security): add regression tests for payload extension blocking Adds 13 unit tests for _validate_payload_extension() verifying CWE-94 fix: blocked extensions (.py/.pyc/.pyo/.so/.dll), case-insensitivity, double extensions, and allowlist of safe types (.exe, .zip, .go, no-extension). --- .../test_payload_extension_blocking.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/security/test_payload_extension_blocking.py diff --git a/tests/security/test_payload_extension_blocking.py b/tests/security/test_payload_extension_blocking.py new file mode 100644 index 000000000..d0502f946 --- /dev/null +++ b/tests/security/test_payload_extension_blocking.py @@ -0,0 +1,66 @@ +""" +Security regression tests for payload upload extension blocking (CWE-94 fix). + +Verifies that _validate_payload_extension() correctly rejects server-side +executable file types (.py, .pyc, .pyo, .so, .dll). +""" +import pytest +from aiohttp import web + +from app.api.v2.handlers.payload_api import _validate_payload_extension, _BLOCKED_EXTENSIONS + + +class TestPayloadExtensionBlocking: + + def test_py_file_rejected(self): + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension('malicious.py') + + def test_pyc_file_rejected(self): + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension('malicious.pyc') + + def test_pyo_file_rejected(self): + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension('malicious.pyo') + + def test_so_file_rejected(self): + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension('exploit.so') + + def test_dll_file_rejected(self): + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension('evil.dll') + + def test_uppercase_extension_rejected(self): + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension('malicious.PY') + + def test_mixed_case_extension_rejected(self): + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension('malicious.Py') + + def test_exe_allowed(self): + """Agent binaries (.exe) must still be uploadable.""" + _validate_payload_extension('sandcat.exe') # should not raise + + def test_elf_allowed(self): + _validate_payload_extension('sandcat-linux') # no extension — should not raise + + def test_zip_allowed(self): + _validate_payload_extension('payloads.zip') + + def test_go_allowed(self): + _validate_payload_extension('manx.go') + + def test_double_extension_py_blocked(self): + """Files like 'legit.txt.py' must still be rejected.""" + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension('legit.txt.py') + + def test_blocked_extensions_set(self): + """Verify the constant contains exactly the expected types.""" + assert '.py' in _BLOCKED_EXTENSIONS + assert '.pyc' in _BLOCKED_EXTENSIONS + assert '.so' in _BLOCKED_EXTENSIONS + assert '.dll' in _BLOCKED_EXTENSIONS From 77eb9117131a275ad0c3e1b0c50e57cbfac91aea Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 00:35:23 -0400 Subject: [PATCH 3/9] fix: address Copilot review feedback on payload extension blocking - Change _BLOCKED_EXTENSIONS from mutable set to frozenset to prevent accidental runtime modification of this security-sensitive denylist - Expand test_blocked_extensions_set to assert frozenset type and check all members including .pyo (previously omitted), matching the docstring claim of 'exactly the expected types' --- app/api/v2/handlers/payload_api.py | 2 +- tests/security/test_payload_extension_blocking.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/api/v2/handlers/payload_api.py b/app/api/v2/handlers/payload_api.py index dd7a2098f..a178a57c1 100644 --- a/app/api/v2/handlers/payload_api.py +++ b/app/api/v2/handlers/payload_api.py @@ -14,7 +14,7 @@ # Extensions that could be executed server-side and must never be accepted as # uploaded payloads (CWE-94 / Remote Code Execution mitigation). -_BLOCKED_EXTENSIONS = {'.py', '.pyc', '.pyo', '.so', '.dll'} +_BLOCKED_EXTENSIONS = frozenset({'.py', '.pyc', '.pyo', '.so', '.dll'}) def _validate_payload_extension(filename: str) -> None: diff --git a/tests/security/test_payload_extension_blocking.py b/tests/security/test_payload_extension_blocking.py index d0502f946..bb90b77d5 100644 --- a/tests/security/test_payload_extension_blocking.py +++ b/tests/security/test_payload_extension_blocking.py @@ -59,8 +59,10 @@ def test_double_extension_py_blocked(self): _validate_payload_extension('legit.txt.py') def test_blocked_extensions_set(self): - """Verify the constant contains exactly the expected types.""" - assert '.py' in _BLOCKED_EXTENSIONS - assert '.pyc' in _BLOCKED_EXTENSIONS - assert '.so' in _BLOCKED_EXTENSIONS - assert '.dll' in _BLOCKED_EXTENSIONS + """Verify the constant is a frozenset and contains exactly the expected types.""" + from app.api.v2.handlers.payload_api import _BLOCKED_EXTENSIONS as _BLK + assert isinstance(_BLK, frozenset), "_BLOCKED_EXTENSIONS must be a frozenset" + expected = frozenset({'.py', '.pyc', '.pyo', '.so', '.dll'}) + assert _BLK == expected, ( + f"_BLOCKED_EXTENSIONS mismatch: got {_BLK}, expected {expected}" + ) From a42f61670c4a36bef79c2ca0b50945cd644bd671 Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 09:53:59 -0400 Subject: [PATCH 4/9] Address Copilot review: use subset assertion for blocked extensions to avoid brittleness --- tests/security/test_payload_extension_blocking.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/security/test_payload_extension_blocking.py b/tests/security/test_payload_extension_blocking.py index bb90b77d5..e78838a7d 100644 --- a/tests/security/test_payload_extension_blocking.py +++ b/tests/security/test_payload_extension_blocking.py @@ -59,10 +59,10 @@ def test_double_extension_py_blocked(self): _validate_payload_extension('legit.txt.py') def test_blocked_extensions_set(self): - """Verify the constant is a frozenset and contains exactly the expected types.""" + """Verify the constant is a frozenset and contains at least the known dangerous types.""" from app.api.v2.handlers.payload_api import _BLOCKED_EXTENSIONS as _BLK assert isinstance(_BLK, frozenset), "_BLOCKED_EXTENSIONS must be a frozenset" - expected = frozenset({'.py', '.pyc', '.pyo', '.so', '.dll'}) - assert _BLK == expected, ( - f"_BLOCKED_EXTENSIONS mismatch: got {_BLK}, expected {expected}" + expected_minimum = frozenset({'.py', '.pyc', '.pyo', '.so', '.dll'}) + assert _BLK.issuperset(expected_minimum), ( + f"_BLOCKED_EXTENSIONS missing required extensions: {expected_minimum - _BLK}" ) From 913d038dfa481f81c1f098e0a2352addeb276b88 Mon Sep 17 00:00:00 2001 From: Joshua Klosterman Date: Tue, 24 Mar 2026 15:58:36 -0400 Subject: [PATCH 5/9] Removed unused _BLOCKED_EXTENSIONS reference --- tests/security/test_payload_extension_blocking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/security/test_payload_extension_blocking.py b/tests/security/test_payload_extension_blocking.py index e78838a7d..5e53ff5f7 100644 --- a/tests/security/test_payload_extension_blocking.py +++ b/tests/security/test_payload_extension_blocking.py @@ -7,7 +7,7 @@ import pytest from aiohttp import web -from app.api.v2.handlers.payload_api import _validate_payload_extension, _BLOCKED_EXTENSIONS +from app.api.v2.handlers.payload_api import _validate_payload_extension class TestPayloadExtensionBlocking: From 94d059a5ec79b0ddd11f12582bc0e23bb0a29423 Mon Sep 17 00:00:00 2001 From: Joshua Klosterman <1268718+jlklos@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:24:30 -0400 Subject: [PATCH 6/9] Strip trailing periods Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- app/api/v2/handlers/payload_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/api/v2/handlers/payload_api.py b/app/api/v2/handlers/payload_api.py index a178a57c1..9d16cc513 100644 --- a/app/api/v2/handlers/payload_api.py +++ b/app/api/v2/handlers/payload_api.py @@ -19,7 +19,10 @@ def _validate_payload_extension(filename: str) -> None: """Raise HTTPBadRequest if the file extension is on the server-side executable blocklist.""" - ext = os.path.splitext(filename)[1].lower() + # Normalize the filename so the extension check reflects how it will be stored + # on filesystems like Windows that ignore trailing dots and spaces. + normalized = filename.rstrip(". ") + ext = os.path.splitext(normalized)[1].lower() if ext in _BLOCKED_EXTENSIONS: raise web.HTTPBadRequest(reason=f"File type {ext!r} is not allowed as a payload") From cd24ec5e54a8d5b5a6b2a878eae627acaac78084 Mon Sep 17 00:00:00 2001 From: Joshua Klosterman <1268718+jlklos@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:30:48 -0400 Subject: [PATCH 7/9] Parametrize extension validation test Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../test_payload_extension_blocking.py | 49 +++++++------------ 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/tests/security/test_payload_extension_blocking.py b/tests/security/test_payload_extension_blocking.py index 5e53ff5f7..b09850059 100644 --- a/tests/security/test_payload_extension_blocking.py +++ b/tests/security/test_payload_extension_blocking.py @@ -12,33 +12,23 @@ class TestPayloadExtensionBlocking: - def test_py_file_rejected(self): - with pytest.raises(web.HTTPBadRequest): - _validate_payload_extension('malicious.py') - - def test_pyc_file_rejected(self): - with pytest.raises(web.HTTPBadRequest): - _validate_payload_extension('malicious.pyc') - - def test_pyo_file_rejected(self): - with pytest.raises(web.HTTPBadRequest): - _validate_payload_extension('malicious.pyo') - - def test_so_file_rejected(self): - with pytest.raises(web.HTTPBadRequest): - _validate_payload_extension('exploit.so') - - def test_dll_file_rejected(self): - with pytest.raises(web.HTTPBadRequest): - _validate_payload_extension('evil.dll') - - def test_uppercase_extension_rejected(self): - with pytest.raises(web.HTTPBadRequest): - _validate_payload_extension('malicious.PY') - - def test_mixed_case_extension_rejected(self): - with pytest.raises(web.HTTPBadRequest): - _validate_payload_extension('malicious.Py') + @pytest.mark.parametrize( + "filename", + [ + "malicious.py", + "malicious.pyc", + "malicious.pyo", + "exploit.so", + "evil.dll", + "malicious.PY", + "malicious.Py", + # Files with a safe-looking extension followed by a blocked one + "legit.txt.py", + ], + ) + def test_blocked_filenames_rejected(self, filename): + with pytest.raises(web.HTTPBadRequest): + _validate_payload_extension(filename) def test_exe_allowed(self): """Agent binaries (.exe) must still be uploadable.""" @@ -53,11 +43,6 @@ def test_zip_allowed(self): def test_go_allowed(self): _validate_payload_extension('manx.go') - def test_double_extension_py_blocked(self): - """Files like 'legit.txt.py' must still be rejected.""" - with pytest.raises(web.HTTPBadRequest): - _validate_payload_extension('legit.txt.py') - def test_blocked_extensions_set(self): """Verify the constant is a frozenset and contains at least the known dangerous types.""" from app.api.v2.handlers.payload_api import _BLOCKED_EXTENSIONS as _BLK From bb7763189e0cab8a7200cc9b6b3ea8a1ed9d0985 Mon Sep 17 00:00:00 2001 From: Joshua Klosterman <1268718+jlklos@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:13:56 -0400 Subject: [PATCH 8/9] Rerun tests --- tests/security/test_payload_extension_blocking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/security/test_payload_extension_blocking.py b/tests/security/test_payload_extension_blocking.py index b09850059..199fd3757 100644 --- a/tests/security/test_payload_extension_blocking.py +++ b/tests/security/test_payload_extension_blocking.py @@ -1,5 +1,5 @@ """ -Security regression tests for payload upload extension blocking (CWE-94 fix). +Security regression tests for payload upload extension blocking (CWE-94 fix). Verifies that _validate_payload_extension() correctly rejects server-side executable file types (.py, .pyc, .pyo, .so, .dll). From 37e4fe3f82f0ff9fd998f24fd48323ff7a9df6e1 Mon Sep 17 00:00:00 2001 From: Joshua Klosterman <1268718+jlklos@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:21:12 -0400 Subject: [PATCH 9/9] Fix formatting of docstring in test_payload_extension_blocking --- tests/security/test_payload_extension_blocking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/security/test_payload_extension_blocking.py b/tests/security/test_payload_extension_blocking.py index 199fd3757..b09850059 100644 --- a/tests/security/test_payload_extension_blocking.py +++ b/tests/security/test_payload_extension_blocking.py @@ -1,5 +1,5 @@ """ -Security regression tests for payload upload extension blocking (CWE-94 fix). +Security regression tests for payload upload extension blocking (CWE-94 fix). Verifies that _validate_payload_extension() correctly rejects server-side executable file types (.py, .pyc, .pyo, .so, .dll).