diff --git a/CHANGELOG.md b/CHANGELOG.md
index 793fb95..91c3c32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+
+## [0.3.4] (2026-06-03)
+### Bug Fixes
+
+* preserve uploads that omit an optional part Content-Type header
+* avoid truncating uploads whose content contains boundary-like text
+* remove partial files when uploads end unexpectedly
+
## [0.3.3] (2026-06-03)
### Security
diff --git a/simple_http_server.py b/simple_http_server.py
index a8feeee..6b231fe 100644
--- a/simple_http_server.py
+++ b/simple_http_server.py
@@ -6,7 +6,7 @@
and HEAD requests in a fairly straightforward manner.
"""
-__version__ = "0.3.3"
+__version__ = "0.3.4"
__author__ = "yangyongbao@126.com"
__all__ = ["SimpleHTTPRequestHandler"]
@@ -117,53 +117,82 @@ def deal_post_data(self):
remain_bytes = int(content_length)
except ValueError:
return False, "Invalid content-length header"
+ if remain_bytes < 0:
+ return False, "Invalid content-length header"
if remain_bytes > self.max_upload_size:
return False, "Upload exceeds the %d byte limit" % self.max_upload_size
- line = self.rfile.readline()
- remain_bytes -= len(line)
- if boundary not in line:
+
+ def read_upload_line():
+ line = self.rfile.readline()
+ return line, len(line)
+
+ def is_boundary_line(line):
+ stripped = line.rstrip(b'\r\n')
+ delimiter = b'--' + boundary
+ return stripped == delimiter or stripped == delimiter + b'--'
+
+ line, line_length = read_upload_line()
+ remain_bytes -= line_length
+ if not line or not is_boundary_line(line):
return False, "Content NOT begin with boundary"
- line = self.rfile.readline()
- remain_bytes -= len(line)
- fn = re.findall(r'Content-Disposition.*name="file"; filename="(.*)"', line.decode('utf-8', 'replace'))
+
+ part_headers = []
+ while remain_bytes > 0:
+ line, line_length = read_upload_line()
+ remain_bytes -= line_length
+ if not line:
+ return False, "Unexpected end of multipart headers"
+ if line in (b'\r\n', b'\n'):
+ break
+ part_headers.append(line.decode('utf-8', 'replace'))
+ else:
+ return False, "Unexpected end of multipart headers"
+
+ header_text = "".join(part_headers)
+ fn = re.findall(r'Content-Disposition.*name="file"; filename="([^"]*)"', header_text)
if not fn:
return False, "Can't find out file name..."
fn = sanitize_upload_filename(fn[0])
if not fn:
return False, "Unsafe upload file name"
path = translate_path(self.path)
+ if not os.path.isdir(path):
+ return False, "Upload target is not a directory"
target_path = os.path.join(path, fn)
while os.path.exists(target_path):
target_path += "_"
- line = self.rfile.readline()
- remain_bytes -= len(line)
- line = self.rfile.readline()
- remain_bytes -= len(line)
try:
out = open(target_path, 'wb')
except IOError:
return False, "Can't create file to write, do you have permission to write?"
+ success = False
try:
- pre_line = self.rfile.readline()
- remain_bytes -= len(pre_line)
+ pre_line = None
while remain_bytes > 0:
- line = self.rfile.readline()
- remain_bytes -= len(line)
- if boundary in line:
- pre_line = pre_line[0:-1]
- if pre_line.endswith(b'\r'):
+ line, line_length = read_upload_line()
+ remain_bytes -= line_length
+ if not line:
+ return False, "Unexpected end of data."
+ if is_boundary_line(line):
+ if pre_line is not None:
pre_line = pre_line[0:-1]
- out.write(pre_line)
- out.close()
+ if pre_line.endswith(b'\r'):
+ pre_line = pre_line[0:-1]
+ out.write(pre_line)
+ success = True
return True, "File '%s' upload success!" % os.path.basename(target_path)
- else:
+ if pre_line is not None:
out.write(pre_line)
- pre_line = line
+ pre_line = line
+ return False, "Unexpected end of data."
finally:
- if not out.closed:
- out.close()
- return False, "Unexpected end of data."
+ out.close()
+ if not success:
+ try:
+ os.remove(target_path)
+ except OSError:
+ pass
def send_head(self):
"""Common code for GET and HEAD commands.
diff --git a/tests/test_simple_http_server.py b/tests/test_simple_http_server.py
index 9f93166..260a3ae 100644
--- a/tests/test_simple_http_server.py
+++ b/tests/test_simple_http_server.py
@@ -1,4 +1,5 @@
import os
+from io import BytesIO
import shutil
import sys
import tempfile
@@ -54,6 +55,94 @@ def test_translate_path_stays_under_current_directory(self):
)
+class DummyUploadHandler(object):
+ max_upload_size = simple_http_server.MAX_UPLOAD_SIZE
+
+ def __init__(
+ self,
+ body,
+ content_length=None,
+ content_type='multipart/form-data; boundary=test-boundary',
+ path='/',
+ ):
+ self.headers = {
+ 'Content-Type': content_type,
+ 'content-length': str(len(body) if content_length is None else content_length),
+ }
+ self.rfile = BytesIO(body)
+ self.path = path
+
+ def deal_post_data(self):
+ return simple_http_server.SimpleHTTPRequestHandler.deal_post_data(self)
+
+
+class UploadParsingTests(unittest.TestCase):
+ boundary = b'test-boundary'
+
+ def make_body(self, filename, content, include_content_type=False, close=True):
+ headers = [
+ b'--' + self.boundary,
+ b'Content-Disposition: form-data; name="file"; filename="' + filename + b'"',
+ ]
+ if include_content_type:
+ headers.append(b'Content-Type: application/octet-stream')
+ headers.append(b'')
+ terminator = b'--' + self.boundary + (b'--' if close else b'')
+ return b'\r\n'.join(headers) + b'\r\n' + content + b'\r\n' + terminator + b'\r\n'
+
+ def run_upload(self, body, content_length=None):
+ old_cwd = os.getcwd()
+ temp_dir = tempfile.mkdtemp()
+ try:
+ os.chdir(temp_dir)
+ handler = DummyUploadHandler(body, content_length=content_length)
+ result = handler.deal_post_data()
+ files = {}
+ for name in os.listdir(temp_dir):
+ with open(os.path.join(temp_dir, name), 'rb') as uploaded:
+ files[name] = uploaded.read()
+ return result, files
+ finally:
+ os.chdir(old_cwd)
+ shutil.rmtree(temp_dir)
+
+ def test_upload_without_part_content_type_preserves_file_content(self):
+ body = self.make_body(b'example.txt', b'first line\r\nsecond line')
+
+ result, files = self.run_upload(body)
+
+ self.assertTrue(result[0], result[1])
+ self.assertEqual(files, {'example.txt': b'first line\r\nsecond line'})
+
+ def test_upload_content_may_contain_boundary_text(self):
+ body = self.make_body(
+ b'example.txt',
+ b'before\r\nnot a delimiter: --test-boundary\r\nafter',
+ )
+
+ result, files = self.run_upload(body)
+
+ self.assertTrue(result[0], result[1])
+ self.assertEqual(
+ files,
+ {'example.txt': b'before\r\nnot a delimiter: --test-boundary\r\nafter'},
+ )
+
+ def test_interrupted_upload_fails_and_removes_partial_file(self):
+ body = b'\r\n'.join([
+ b'--' + self.boundary,
+ b'Content-Disposition: form-data; name="file"; filename="partial.txt"',
+ b'',
+ b'partial content without closing boundary',
+ ])
+
+ result, files = self.run_upload(body, content_length=len(body) + 20)
+
+ self.assertFalse(result[0])
+ self.assertEqual(result[1], 'Unexpected end of data.')
+ self.assertEqual(files, {})
+
+
class ArgumentParserTests(unittest.TestCase):
def test_default_bind_is_localhost(self):
old_argv = sys.argv