Skip to content

Commit 973ed61

Browse files
authored
fix: validate upload filename character set in file_svc (#3267)
* fix: validate upload filename character set in file_svc Reject filenames with path traversal, null bytes, or special characters. * fix: validate field.filename before os.path.split() to prevent traversal bypass; precompile regex; convert tests to pytest * fix: flake8 style fixes * fix: reject '.' as a filename and add test coverage for dot-only names A single '.' passes the safe-character regex but is not a valid upload filename. Add an explicit check and a parametrized test case. * fix: consume rejected multipart part before continue to prevent reader stall * fix: return 400 Bad Request on invalid upload filename instead of silently skipping * fix: re-raise HTTPException so HTTPBadRequest propagates; add HTTP-level test for invalid filename
1 parent e534004 commit 973ed61

File tree

3 files changed

+88
-1
lines changed

3 files changed

+88
-1
lines changed

app/service/file_svc.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from app.utility.base_service import BaseService
2020
from app.utility.payload_encoder import xor_file, xor_bytes
2121

22+
_SAFE_FILENAME_RE = re.compile(r'^[a-zA-Z0-9._\-]+$')
2223
FILE_ENCRYPTION_FLAG = '%encrypted%'
2324
URL_SANITIZATION_REGEX = re.compile(r'^[\w\-\.:%+/]+$')
2425
ALLOWED_DEFAULT_LDFLAG_REGEX = re.compile(r'^[\w\-\.]+$')
@@ -92,6 +93,21 @@ async def create_exfil_operation_directory(self, dir_name, agent_name):
9293
os.makedirs(path)
9394
return path
9495

96+
@staticmethod
97+
def _validate_filename(name):
98+
"""Validate that a filename contains only safe characters.
99+
Returns True if valid, False otherwise.
100+
"""
101+
if not name or len(name) > 255:
102+
return False
103+
if '\x00' in name:
104+
return False
105+
if name == '.' or '..' in name or '/' in name or '\\' in name:
106+
return False
107+
if not _SAFE_FILENAME_RE.fullmatch(name):
108+
return False
109+
return True
110+
95111
async def save_multipart_file_upload(self, request, target_dir, encrypt=True):
96112
try:
97113
reader = await request.multipart()
@@ -100,11 +116,16 @@ async def save_multipart_file_upload(self, request, target_dir, encrypt=True):
100116
field = await reader.next()
101117
if not field:
102118
break
119+
if not self._validate_filename(field.filename):
120+
self.log.warning('Invalid filename rejected: %r', field.filename)
121+
raise web.HTTPBadRequest(reason='Invalid filename: disallowed characters or path traversal')
103122
_, filename = os.path.split(field.filename)
104123
await self.save_file(filename, bytes(await field.read()), target_dir,
105124
encrypt=encrypt, encoding=headers.get('x-file-encoding'))
106125
self.log.debug('Uploaded file %s/%s' % (target_dir, filename))
107126
return web.Response()
127+
except web.HTTPException:
128+
raise
108129
except Exception as e:
109130
self.log.debug('Exception uploading file: %s' % e)
110131

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import pytest
2+
from app.service.file_svc import FileSvc
3+
4+
5+
@pytest.mark.parametrize('name', [
6+
'test.ps1',
7+
'my-payload_v2.exe',
8+
'agent.bin',
9+
'a' * 255,
10+
])
11+
def test_valid_filenames(name):
12+
assert FileSvc._validate_filename(name)
13+
14+
15+
@pytest.mark.parametrize('name', [
16+
'../../../etc/passwd',
17+
'..\\windows\\system32',
18+
'test\x00.ps1',
19+
'test file.ps1',
20+
'test;rm -rf /.ps1',
21+
'<script>.js',
22+
'',
23+
'a' * 256,
24+
'.',
25+
])
26+
def test_invalid_filenames(name):
27+
assert not FileSvc._validate_filename(name)
28+
29+
30+
def test_path_traversal_not_bypassed_by_split():
31+
"""Validate that traversal sequences in the full field.filename are rejected
32+
BEFORE os.path.split() strips them — the critical fix for the path traversal bypass.
33+
os.path.split('../../../etc/passwd') returns ('../../..', 'passwd');
34+
validating only 'passwd' would incorrectly pass."""
35+
traversal_filenames = [
36+
'../../../etc/passwd',
37+
'../../secret.yml',
38+
'uploads/../../../etc/shadow',
39+
]
40+
for raw_name in traversal_filenames:
41+
assert not FileSvc._validate_filename(raw_name), \
42+
f'Expected {raw_name!r} to be rejected by _validate_filename (traversal bypass risk)'

tests/services/test_file_svc.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import pytest
55
import yaml
66

7+
from aiohttp import web
78
from base64 import b64encode
89
from asyncio import Future
9-
from unittest.mock import AsyncMock
10+
from unittest.mock import AsyncMock, MagicMock
1011

1112
from app.data_encoders.base64_basic import Base64Encoder
1213
from app.data_encoders.plain_text import PlainTextEncoder
@@ -175,6 +176,29 @@ def test_upload_file(self, event_loop, file_svc):
175176
os.remove(uploaded_file_path)
176177
os.rmdir(upload_dir)
177178

179+
def test_multipart_upload_rejects_invalid_filename(self, event_loop, file_svc, tmp_path):
180+
"""Regression test for #3267: save_multipart_file_upload must raise HTTPBadRequest
181+
when a field filename fails _validate_filename (e.g. path traversal attempt '../evil.txt').
182+
"""
183+
# Build a fake multipart field with a path-traversal filename
184+
fake_field = MagicMock()
185+
fake_field.filename = '../evil.txt'
186+
fake_field.read = AsyncMock(return_value=b'evil content')
187+
188+
# Build a fake multipart reader whose next() returns the bad field then None
189+
fake_reader = MagicMock()
190+
fake_reader.next = AsyncMock(side_effect=[fake_field, None])
191+
192+
# Build a fake request whose multipart() returns the reader
193+
fake_request = MagicMock()
194+
fake_request.multipart = AsyncMock(return_value=fake_reader)
195+
fake_request.headers = {}
196+
197+
with pytest.raises(web.HTTPBadRequest):
198+
event_loop.run_until_complete(
199+
file_svc.save_multipart_file_upload(fake_request, str(tmp_path))
200+
)
201+
178202
def test_encrypt_upload(self, event_loop, file_svc):
179203
upload_dir = event_loop.run_until_complete(file_svc.create_exfil_sub_directory('test-encrypted-upload'))
180204
upload_filename = 'encryptedupload.txt'

0 commit comments

Comments
 (0)