Skip to content

Commit b0dd125

Browse files
authored
Cap multipart boundary length at 256 bytes (#282)
1 parent d1b5739 commit b0dd125

3 files changed

Lines changed: 16 additions & 26 deletions

File tree

python_multipart/multipart.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ class MultipartState(IntEnum):
148148
DEFAULT_MAX_HEADER_SIZE = 4096 + 128
149149
"""Default maximum size of a single multipart header line, including syntax overhead."""
150150

151+
MAX_BOUNDARY_LENGTH = 256
152+
"""Maximum allowed length of a multipart boundary.
153+
154+
[RFC 2046 §5.1.1](https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.1)
155+
recommends boundaries be at most 70 bytes. 256 bytes is generous headroom over
156+
every HTTP client.
157+
"""
158+
151159

152160
def parse_options_header(value: str | bytes | None) -> tuple[bytes, dict[bytes, bytes]]:
153161
"""Parses a Content-Type header into a value in the following format: (content_type, {parameters})."""
@@ -1012,6 +1020,8 @@ def __init__(
10121020
# Save our boundary.
10131021
if isinstance(boundary, str): # pragma: no cover
10141022
boundary = boundary.encode("latin-1")
1023+
if len(boundary) > MAX_BOUNDARY_LENGTH:
1024+
raise FormParserError(f"Boundary length {len(boundary)} exceeds maximum of {MAX_BOUNDARY_LENGTH}")
10151025
self.boundary = b"\r\n--" + boundary
10161026

10171027
def write(self, data: bytes) -> int:

tests/test_benchmarks.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -78,20 +78,6 @@ def split(data: bytes, chunk_size: int = CHUNK_SIZE) -> list[bytes]:
7878
WORSTCASE_BCHAR = build_form([build_part(b"file", pattern(b"\r\n", 1024 * 1024), filename=b"file.bin")])
7979
WORSTCASE_BCHAR_CHUNKS = split(WORSTCASE_BCHAR)
8080

81-
LONG_BOUNDARY = b"-" * 16 + b"x" * (16 * 1024 - 16)
82-
LONG_BOUNDARY_BODY = (
83-
b"--"
84-
+ LONG_BOUNDARY
85-
+ b"\r\n"
86-
+ b'Content-Disposition: form-data; name="file"; filename="file.bin"\r\n'
87-
+ b"Content-Type: application/octet-stream\r\n\r\n"
88-
+ pattern(b"abcdefgh", 8 * 1024 * 1024)
89-
+ b"\r\n--"
90-
+ LONG_BOUNDARY
91-
+ b"--\r\n"
92-
)
93-
LONG_BOUNDARY_CHUNKS = split(LONG_BOUNDARY_BODY, 16 * 1024)
94-
9581
URLENCODED_LARGE = b"&".join(f"field{i}={'v' * 64}".encode() for i in range(100))
9682

9783

@@ -102,13 +88,6 @@ def multipart_parser() -> Iterator[MultipartParser]:
10288
parser.finalize()
10389

10490

105-
@pytest.fixture
106-
def long_boundary_parser() -> Iterator[MultipartParser]:
107-
parser = MultipartParser(LONG_BOUNDARY, MULTIPART_CALLBACKS)
108-
yield parser
109-
parser.finalize()
110-
111-
11291
@pytest.fixture
11392
def querystring_parser() -> Iterator[QuerystringParser]:
11493
parser = QuerystringParser(QUERYSTRING_CALLBACKS)
@@ -134,10 +113,5 @@ def test_multipart_worstcase_boundary_chars(multipart_parser: MultipartParser) -
134113
multipart_parser.write(chunk)
135114

136115

137-
def test_multipart_long_boundary(long_boundary_parser: MultipartParser) -> None:
138-
for chunk in LONG_BOUNDARY_CHUNKS:
139-
long_boundary_parser.write(chunk)
140-
141-
142116
def test_querystring_large_form(querystring_parser: QuerystringParser) -> None:
143117
querystring_parser.write(URLENCODED_LARGE)

tests/test_multipart.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,12 @@ def test_invalid_max_size_multipart(self) -> None:
15141514
with self.assertRaises(ValueError):
15151515
MultipartParser(b"bound", max_size="foo") # type: ignore[arg-type]
15161516

1517+
def test_boundary_too_long(self) -> None:
1518+
with self.assertRaisesRegex(FormParserError, "Boundary length 257 exceeds maximum of 256"):
1519+
MultipartParser(b"x" * 257)
1520+
# 256 should be accepted.
1521+
MultipartParser(b"x" * 256)
1522+
15171523
def test_header_begin_callback(self) -> None:
15181524
"""
15191525
This test verifies we call the `on_header_begin` callback.

0 commit comments

Comments
 (0)