Skip to content

Commit fc4ebea

Browse files
authored
Merge pull request #22 from freelamb/codex/analyze-and-resolve-issues
Fix multipart upload parsing edge cases
2 parents 99a0867 + 94d3b5a commit fc4ebea

3 files changed

Lines changed: 151 additions & 25 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
<a name="0.3.4"></a>
2+
## [0.3.4] (2026-06-03)
3+
### Bug Fixes
4+
5+
* preserve uploads that omit an optional part Content-Type header
6+
* avoid truncating uploads whose content contains boundary-like text
7+
* remove partial files when uploads end unexpectedly
8+
19
<a name="0.3.3"></a>
210
## [0.3.3] (2026-06-03)
311
### Security

‎simple_http_server.py‎

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
and HEAD requests in a fairly straightforward manner.
77
"""
88

9-
__version__ = "0.3.3"
9+
__version__ = "0.3.4"
1010
__author__ = "yangyongbao@126.com"
1111
__all__ = ["SimpleHTTPRequestHandler"]
1212

@@ -117,53 +117,82 @@ def deal_post_data(self):
117117
remain_bytes = int(content_length)
118118
except ValueError:
119119
return False, "Invalid content-length header"
120+
if remain_bytes < 0:
121+
return False, "Invalid content-length header"
120122
if remain_bytes > self.max_upload_size:
121123
return False, "Upload exceeds the %d byte limit" % self.max_upload_size
122-
line = self.rfile.readline()
123-
remain_bytes -= len(line)
124-
if boundary not in line:
124+
125+
def read_upload_line():
126+
line = self.rfile.readline()
127+
return line, len(line)
128+
129+
def is_boundary_line(line):
130+
stripped = line.rstrip(b'\r\n')
131+
delimiter = b'--' + boundary
132+
return stripped == delimiter or stripped == delimiter + b'--'
133+
134+
line, line_length = read_upload_line()
135+
remain_bytes -= line_length
136+
if not line or not is_boundary_line(line):
125137
return False, "Content NOT begin with boundary"
126-
line = self.rfile.readline()
127-
remain_bytes -= len(line)
128-
fn = re.findall(r'Content-Disposition.*name="file"; filename="(.*)"', line.decode('utf-8', 'replace'))
138+
139+
part_headers = []
140+
while remain_bytes > 0:
141+
line, line_length = read_upload_line()
142+
remain_bytes -= line_length
143+
if not line:
144+
return False, "Unexpected end of multipart headers"
145+
if line in (b'\r\n', b'\n'):
146+
break
147+
part_headers.append(line.decode('utf-8', 'replace'))
148+
else:
149+
return False, "Unexpected end of multipart headers"
150+
151+
header_text = "".join(part_headers)
152+
fn = re.findall(r'Content-Disposition.*name="file"; filename="([^"]*)"', header_text)
129153
if not fn:
130154
return False, "Can't find out file name..."
131155
fn = sanitize_upload_filename(fn[0])
132156
if not fn:
133157
return False, "Unsafe upload file name"
134158
path = translate_path(self.path)
159+
if not os.path.isdir(path):
160+
return False, "Upload target is not a directory"
135161
target_path = os.path.join(path, fn)
136162
while os.path.exists(target_path):
137163
target_path += "_"
138-
line = self.rfile.readline()
139-
remain_bytes -= len(line)
140-
line = self.rfile.readline()
141-
remain_bytes -= len(line)
142164
try:
143165
out = open(target_path, 'wb')
144166
except IOError:
145167
return False, "Can't create file to write, do you have permission to write?"
146168

169+
success = False
147170
try:
148-
pre_line = self.rfile.readline()
149-
remain_bytes -= len(pre_line)
171+
pre_line = None
150172
while remain_bytes > 0:
151-
line = self.rfile.readline()
152-
remain_bytes -= len(line)
153-
if boundary in line:
154-
pre_line = pre_line[0:-1]
155-
if pre_line.endswith(b'\r'):
173+
line, line_length = read_upload_line()
174+
remain_bytes -= line_length
175+
if not line:
176+
return False, "Unexpected end of data."
177+
if is_boundary_line(line):
178+
if pre_line is not None:
156179
pre_line = pre_line[0:-1]
157-
out.write(pre_line)
158-
out.close()
180+
if pre_line.endswith(b'\r'):
181+
pre_line = pre_line[0:-1]
182+
out.write(pre_line)
183+
success = True
159184
return True, "File '%s' upload success!" % os.path.basename(target_path)
160-
else:
185+
if pre_line is not None:
161186
out.write(pre_line)
162-
pre_line = line
187+
pre_line = line
188+
return False, "Unexpected end of data."
163189
finally:
164-
if not out.closed:
165-
out.close()
166-
return False, "Unexpected end of data."
190+
out.close()
191+
if not success:
192+
try:
193+
os.remove(target_path)
194+
except OSError:
195+
pass
167196

168197
def send_head(self):
169198
"""Common code for GET and HEAD commands.

‎tests/test_simple_http_server.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
from io import BytesIO
23
import shutil
34
import sys
45
import tempfile
@@ -54,6 +55,94 @@ def test_translate_path_stays_under_current_directory(self):
5455
)
5556

5657

58+
class DummyUploadHandler(object):
59+
max_upload_size = simple_http_server.MAX_UPLOAD_SIZE
60+
61+
def __init__(
62+
self,
63+
body,
64+
content_length=None,
65+
content_type='multipart/form-data; boundary=test-boundary',
66+
path='/',
67+
):
68+
self.headers = {
69+
'Content-Type': content_type,
70+
'content-length': str(len(body) if content_length is None else content_length),
71+
}
72+
self.rfile = BytesIO(body)
73+
self.path = path
74+
75+
def deal_post_data(self):
76+
return simple_http_server.SimpleHTTPRequestHandler.deal_post_data(self)
77+
78+
79+
class UploadParsingTests(unittest.TestCase):
80+
boundary = b'test-boundary'
81+
82+
def make_body(self, filename, content, include_content_type=False, close=True):
83+
headers = [
84+
b'--' + self.boundary,
85+
b'Content-Disposition: form-data; name="file"; filename="' + filename + b'"',
86+
]
87+
if include_content_type:
88+
headers.append(b'Content-Type: application/octet-stream')
89+
headers.append(b'')
90+
terminator = b'--' + self.boundary + (b'--' if close else b'')
91+
return b'\r\n'.join(headers) + b'\r\n' + content + b'\r\n' + terminator + b'\r\n'
92+
93+
def run_upload(self, body, content_length=None):
94+
old_cwd = os.getcwd()
95+
temp_dir = tempfile.mkdtemp()
96+
try:
97+
os.chdir(temp_dir)
98+
handler = DummyUploadHandler(body, content_length=content_length)
99+
result = handler.deal_post_data()
100+
files = {}
101+
for name in os.listdir(temp_dir):
102+
with open(os.path.join(temp_dir, name), 'rb') as uploaded:
103+
files[name] = uploaded.read()
104+
return result, files
105+
finally:
106+
os.chdir(old_cwd)
107+
shutil.rmtree(temp_dir)
108+
109+
def test_upload_without_part_content_type_preserves_file_content(self):
110+
body = self.make_body(b'example.txt', b'first line\r\nsecond line')
111+
112+
result, files = self.run_upload(body)
113+
114+
self.assertTrue(result[0], result[1])
115+
self.assertEqual(files, {'example.txt': b'first line\r\nsecond line'})
116+
117+
def test_upload_content_may_contain_boundary_text(self):
118+
body = self.make_body(
119+
b'example.txt',
120+
b'before\r\nnot a delimiter: --test-boundary\r\nafter',
121+
)
122+
123+
result, files = self.run_upload(body)
124+
125+
self.assertTrue(result[0], result[1])
126+
self.assertEqual(
127+
files,
128+
{'example.txt': b'before\r\nnot a delimiter: --test-boundary\r\nafter'},
129+
)
130+
131+
def test_interrupted_upload_fails_and_removes_partial_file(self):
132+
body = b'\r\n'.join([
133+
b'--' + self.boundary,
134+
b'Content-Disposition: form-data; name="file"; filename="partial.txt"',
135+
b'',
136+
b'partial content without closing boundary',
137+
])
138+
139+
result, files = self.run_upload(body, content_length=len(body) + 20)
140+
141+
self.assertFalse(result[0])
142+
self.assertEqual(result[1], 'Unexpected end of data.')
143+
self.assertEqual(files, {})
144+
145+
57146
class ArgumentParserTests(unittest.TestCase):
58147
def test_default_bind_is_localhost(self):
59148
old_argv = sys.argv

0 commit comments

Comments
 (0)