Skip to content

Commit 32a189a

Browse files
committed
perf: Optimized partial boundary detection on chunk borders
When parsing segment body chunks, we have to deal with partial boundaries at chunk borders. Currently we always keep `len(boundary)-1` bytes in the buffer 'just in case' and merge it with the next chunk, which is quite expensive. We can avoid this overhead in most cases by checking for an actual partial match. Benchmarks show a 10-37% throughput increase for typical file uploads and slight speedups in most of the other tested scenarios. feat: Boundary delimiters inside segment bodies are now detected and reported as fatal errors in most cases (see RFC-7578 4.1).
1 parent 3add691 commit 32a189a

3 files changed

Lines changed: 85 additions & 26 deletions

File tree

CHANGELOG.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ Release 2.0 (not released yet)
1919
:cls:`bytearray` for part body chunks. Update your `isinstance` checks
2020
if you have any.
2121
* change: Enforce `part_limit` (128 by default) for url-encoded data in
22-
`parse_form_data()`. This is consistent with the handling of multipart
23-
and an important safeguard against denial of service.
22+
`parse_form_data()`. This is consistent with multipart handling and an
23+
important safeguard against denial of service attacks.
2424
* change: New strict-mode check to reject extremely large boundaries.
2525
* change: Raise more helpful :exc:`ParserStateError` instead of implicit
2626
:exc:`AssertionError` or :exc:`TypeError` when the parser is used
2727
incorrectly.
28+
* change: The boundary delimiter must not appear inside a segment body
29+
(RFC-7578 4.1). This triggers a fatal error now.
2830
* feat: Hardened (and faster) header validation.
31+
* perf: Up to 37% faster parsing for typical file uploads and large
32+
text fields.
2933
* build: Change default branch to `main`.
3034

3135

multipart.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -476,8 +476,11 @@ def parse(self, chunk: t_ByteString) -> Generator[t_ParserEvent, None, None]:
476476
bufferlen = len(buffer)
477477
offset = 0
478478

479-
while True:
479+
while offset < bufferlen:
480480
if self._state is _PREAMBLE:
481+
if bufferlen < d_len:
482+
break # Not enough data to find the initial delimiter
483+
481484
# Scan for first delimiter (CRLF prefix is optional here)
482485
index = buffer.find(delimiter[2:], offset)
483486

@@ -502,16 +505,21 @@ def parse(self, chunk: t_ByteString) -> Generator[t_ParserEvent, None, None]:
502505
break # parsing complete
503506
elif tail[0:1] == b"\n": # Broken client or legacy test case
504507
raise ParserError("Invalid line break after first boundary")
505-
elif len(tail) == 2:
508+
elif next_start <= bufferlen:
506509
raise ParserError("Unexpected byte after first boundary")
510+
else: # 2-byte tail not in buffer
511+
offset = max(0, index - 2)
512+
break # wait for more data
507513

508514
elif self.strict and bufferlen >= d_len:
509515
# No boundary in first chunk -> Fail fast in strict mode
510516
# and do not waste time consuming a legacy preamble.
511517
raise StrictParserError("Boundary not found in first chunk")
512518

513-
# Delimiter not found, skip data until we find one
514-
offset = max(0, bufferlen - (d_len + 1))
519+
# Boundary not found. Skip the preamble, but keep any bytes that may
520+
# belong to a partial boundary at the end of the buffer.
521+
index = buffer.rfind(b"\r", bufferlen - (d_len - 1))
522+
offset = bufferlen if index == -1 else index
515523
break # wait for more data
516524

517525
elif self._state is _HEADER:
@@ -538,34 +546,52 @@ def parse(self, chunk: t_ByteString) -> Generator[t_ParserEvent, None, None]:
538546
break # wait for more data
539547

540548
elif self._state is _BODY:
541-
# Scan for delimiter: CRLF + boundary + (CRLF or '--')
549+
# Scan for next boundary: CRLF + boundary
542550
index = buffer.find(delimiter, offset)
543551
if index > -1:
552+
# Emit everything up to the boundary
553+
if index > offset:
554+
yield self._on_segment_payload(buffer[offset:index])
555+
offset = index
556+
544557
next_start = index + d_len + 2
545558
tail = buffer[next_start - 2 : next_start]
546559

547-
if tail == b"\r\n" or tail == b"--":
548-
if index > offset:
549-
yield self._on_segment_payload(buffer[offset:index])
550-
551-
offset = next_start
560+
if tail == b"\r\n":
561+
# Normal boundary: CRLF + boundary + CRLF
562+
self._on_segment_complete()
563+
yield None # end of segment
564+
offset += d_len + 2
565+
self._on_segment_start()
566+
self._state = _HEADER
567+
continue
568+
elif tail == b"--":
569+
# Final boundary: CRLF + boundary + '--'
552570
self._on_segment_complete()
553571
yield None # end of segment
572+
offset += d_len + 2
573+
self._state = _COMPLETE
574+
break
575+
elif next_start > bufferlen: # 2-byte tail not in buffer
576+
break # wait for more data
577+
else:
578+
raise ParserError("Unexpected bytes after boundary")
579+
580+
# Boundary not found. Emit as much data as we can, but keep any bytes
581+
# that may belong to a partial boundary at the end of the buffer.
582+
index = buffer.rfind(b"\r", max(offset, bufferlen - (d_len - 1)))
583+
if index == -1 or not delimiter.startswith(buffer[index:]):
584+
# No partail boundary found, emit everything
585+
# This is a huge deal because it avoids buffer stitching next round
586+
yield self._on_segment_payload(
587+
buffer[offset:] if offset else buffer
588+
)
589+
offset = bufferlen
590+
elif index > offset:
591+
# Potential partial boundary found. Emit data up to that point
592+
yield self._on_segment_payload(buffer[offset:index])
593+
offset = index
554594

555-
if tail == b"--": # Last delimiter
556-
self._state = _COMPLETE
557-
break
558-
else: # Normal delimiter
559-
self._on_segment_start()
560-
self._state = _HEADER
561-
continue
562-
563-
# Only consume bytes that cannot be part of a partial delimiter at
564-
# the end of the buffer.
565-
flush_until = bufferlen - (d_len + 1)
566-
if flush_until > offset:
567-
yield self._on_segment_payload(buffer[offset:flush_until])
568-
offset = flush_until
569595
break # wait for more data
570596

571597
else: # pragma: no cover

test/test_push_parser.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,35 @@ def test_reject_boundary_in_preamble(self):
513513
"--boundary--",
514514
)
515515

516+
def test_reject_boundary_in_payload(self):
517+
"""The boundary delimiter (e.g. "\r\n--boundary") must not appear in segment
518+
bodies."""
519+
520+
def assert_bad_content(*content):
521+
self.reset()
522+
with self.assertParseError(
523+
"Unexpected bytes after boundary"
524+
):
525+
print(
526+
self.parse(
527+
"--boundary\r\n",
528+
'Content-Disposition: form-data; name="bad"\r\n',
529+
"\r\n",
530+
*content,
531+
"\r\n--boundary--",
532+
"", # trigger close
533+
)
534+
)
535+
536+
assert_bad_content("\r\n--boundary...")
537+
assert_bad_content("\r\n--boundary\r")
538+
assert_bad_content("\r\n--boundary-")
539+
540+
# This fake-boundary merges with the next real boundary into a
541+
# valid boundary, which is still detected as an error, but with
542+
# a different error message.
543+
# assert_bad_content("\r\n--boundary")
544+
516545
def test_accept_crln_before_start_boundary(self):
517546
"""While uncommon, a single \\r\\n before and after the first and last
518547
boundary should be accepted even in strict mode."""

0 commit comments

Comments
 (0)