Skip to content

Consume the boundary after a multipart _charset_ part - #13640

Open
2sumtech wants to merge 3 commits into
aio-libs:masterfrom
2sumtech:fix/multipart-charset-part-boundary
Open

2sumtech wants to merge 3 commits into
aio-libs:masterfrom
2sumtech:fix/multipart-charset-part-boundary

Conversation

@2sumtech

@2sumtech 2sumtech commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What do these changes do?

MultipartReader.next() has a special case for the RFC 7578 §4.6 _charset_
field: when the first part of a multipart/form-data body is named _charset_,
its value is read as the default charset and the next part is returned instead.
That branch read the _charset_ part's body and then called fetch_next_part()
directly, so the delimiter line that terminates the _charset_ part was never
consumed. fetch_next_part() therefore fed the boundary line into
HeadersParser, which raised InvalidHeader. These changes release the
_charset_ part and read its delimiter with _read_boundary() before fetching
the next part, and return None when that delimiter turns out to be the closing
one.

Are there changes in behavior for the user?

Yes — a multipart/form-data body containing a _charset_ field is now parsed
instead of raising. Previously the boundary line was consumed as a header, so
await request.post() raised InvalidHeader and the server answered
500 Internal Server Error for every boundary that does not itself parse as a
name: value pair — i.e. for every realistic boundary. The existing coverage
only passed because it uses the boundary :, which makes the delimiter line
--: parse as a (bogus) header named --, silently attached to the following
part's headers. That stray header is gone now too. No API changes.

Is it a substantial burden for the maintainers to support this?

No. It is five lines inside the existing _charset_ branch, reusing
BodyPartReader.release() and MultipartReader._read_boundary() — the same two
steps the ordinary next() path already performs between parts. No new helpers,
no new public surface.

Related issue number

None — found while probing the multipart header/boundary seams. Happy to file one
if you prefer an issue on record.

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes — N/A (no API or documented-behaviour change)
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt — already listed
  • Add a new news fragment into the CHANGES/ folder

Reproducer

import asyncio, aiohttp
from aiohttp import web

B = "----WebKitFormBoundaryABC"
BODY = (
    f"--{B}\r\n"
    'Content-Disposition: form-data; name="_charset_"\r\n\r\n'
    "utf-8\r\n"
    f"--{B}\r\n"
    'Content-Disposition: form-data; name="field1"\r\n\r\n'
    "foo\r\n"
    f"--{B}--\r\n"
).encode()

async def handler(request):
    return web.json_response(dict(await request.post()))

async def main():
    app = web.Application()
    app.router.add_post("/", handler)
    runner = web.AppRunner(app); await runner.setup()
    site = web.TCPSite(runner, "127.0.0.1", 0); await site.start()
    port = runner.addresses[0][1]
    async with aiohttp.ClientSession() as s:
        async with s.post(f"http://127.0.0.1:{port}/", data=BODY,
                          headers={"Content-Type": f"multipart/form-data; boundary={B}"}) as r:
            print(r.status, await r.text())
    await runner.cleanup()

asyncio.run(main())

Before: 500 500 Internal Server Error with
aiohttp.http_exceptions.InvalidHeader: Invalid HTTP header: b'------WebKitFormBoundaryABC'
raised from multipart.py:832 fetch_next_part → multipart.py:935 _read_headers.
After: 200 {"field1": "foo"}.

Agent run output — tests before and after

Environment: pure-Python mode, AIOHTTP_NO_EXTENSIONS=1 PYTHONPATH=. python -m pytest
(CPython 3.11.15). The C extension only substitutes HttpRequestParser /
HttpResponseParser / RawRequestMessage / RawResponseMessage
(http_parser.py:1241-1248); MultipartReader._read_headers() always uses the pure-Python
HeadersParser, so this change has no Cython-dependent behaviour.

1. New tests fail on the unpatched tree (aiohttp/multipart.py restored from
origin/master, new tests present):

$ AIOHTTP_NO_EXTENSIONS=1 PYTHONPATH=. python -m pytest tests/test_multipart.py -k default_encoding -q
tests/test_multipart.py .FFF.                                            [100%]
=================================== FAILURES ===================================
E           AssertionError: assert ['--', 'Content-Disposition'] == ['Content-Disposition']
E             At index 0 diff: '--' != 'Content-Disposition'
E             Left contains one more item: 'Content-Disposition'
tests/test_multipart.py:1188: AssertionError
tests/test_multipart.py:1215:
E               aiohttp.http_exceptions.InvalidHeader: 400, message:
E                 Invalid HTTP header: b'--WebKitFormBoundary'
tests/test_multipart.py:1235:
E               aiohttp.http_exceptions.InvalidHeader: 400, message:
E                 Invalid HTTP header: b'--WebKitFormBoundary--'
=========================== short test summary info ============================
FAILED tests/test_multipart.py::TestMultipartReader::test_read_form_default_encoding
FAILED tests/test_multipart.py::TestMultipartReader::test_read_form_default_encoding_boundary_without_colon
FAILED tests/test_multipart.py::TestMultipartReader::test_read_form_default_encoding_as_last_part

2. Whole multipart suite passes with the fix:

$ AIOHTTP_NO_EXTENSIONS=1 PYTHONPATH=. python -m pytest tests/test_multipart_helpers.py tests/test_multipart.py -q
tests/test_multipart_helpers.py ................s....................... [ 14%]
tests/test_multipart.py ................................................ [ 64%]
======================== 268 passed, 7 skipped in 0.37s ========================

3. No regressions in the server request path:

$ AIOHTTP_NO_EXTENSIONS=1 PYTHONPATH=. python -m pytest tests/test_web_request.py tests/test_web_functional.py -q
======================= 292 passed, 14 skipped in 1.78s ========================

4. Toolchain on the touched files:

$ python -m black --check aiohttp/multipart.py tests/test_multipart.py
All done! 2 files would be left unchanged.
$ python -m isort --check-only aiohttp/multipart.py tests/test_multipart.py   # clean
$ python -m flake8 aiohttp/multipart.py tests/test_multipart.py               # clean
$ python -m mypy aiohttp/multipart.py
Success: no issues found in 1 source file

Drafted with Claude Code (Claude Opus 5 and Fable 5.1); human review by @2sumtech pending before this leaves draft.


@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Sep 5, 2026
@codecov

codecov Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.03%. Comparing base (25f3057) to head (4ebf605).
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #13640   +/-   ##
=======================================
  Coverage   99.03%   99.03%           
=======================================
  Files         135      135           
  Lines       51399    51419   +20     
  Branches     2694     2695    +1     
=======================================
+ Hits        50904    50924   +20     
  Misses        372      372           
  Partials      123      123           
Flag Coverage Δ
Autobahn 21.91% <10.00%> (-0.01%) ⬇️
CI-GHA 98.86% <100.00%> (+<0.01%) ⬆️
OS-Linux 98.64% <100.00%> (+<0.01%) ⬆️
OS-Windows 97.27% <100.00%> (+<0.01%) ⬆️
OS-macOS 98.14% <100.00%> (+<0.01%) ⬆️
Py-3.10 98.08% <100.00%> (+<0.01%) ⬆️
Py-3.11 98.30% <100.00%> (+<0.01%) ⬆️
Py-3.12 98.39% <100.00%> (+<0.01%) ⬆️
Py-3.13 98.38% <100.00%> (+0.35%) ⬆️
Py-3.14 98.41% <100.00%> (+<0.01%) ⬆️
Py-3.15 98.41% <100.00%> (+<0.01%) ⬆️
Py-3.15t 97.79% <100.00%> (+<0.01%) ⬆️
Py-pypy-3.11 97.36% <100.00%> (-0.01%) ⬇️
VM-macos 98.14% <100.00%> (+<0.01%) ⬆️
VM-ubuntu 98.64% <100.00%> (+<0.01%) ⬆️
VM-windows 97.27% <100.00%> (+<0.01%) ⬆️
cython-coverage 83.21% <0.00%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codspeed

codspeed Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 97 untouched benchmarks
⏩ 83 skipped benchmarks1


Comparing 2sumtech:fix/multipart-charset-part-boundary (4ebf605) with master (25f3057)

Open in CodSpeed

Footnotes

  1. 83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@Dreamsorcerer
Dreamsorcerer marked this pull request as ready for review September 27, 2026 19:35
@Dreamsorcerer Dreamsorcerer added backport-3.14 Trigger automatic backporting to the 3.14 release branch by Patchback robot backport-3.15 Trigger automatic backporting to the 3.15 release branch by Patchback robot labels Sep 27, 2026
@greptile-apps

greptile-apps Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Fixes multipart form parsing when charset field appears first.

The confirmed nested-field issue is non-blocking; the change is safe to merge with this concern noted.

Reviews (1) · Last reviewed commit: "Update 13640.bugfix.rst"

Comment thread aiohttp/multipart.py
self._default_charset = charset.strip().decode()
await part.release()
await self._read_boundary()
if self._at_eof:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Nested field name disappears

When a nested form-data part contains only _charset_ and its closing boundary is followed directly by the outer boundary, the next outer field loses its Content-Disposition header. Its content remains readable, but its name becomes None, so consumers cannot identify the field. This is a non-blocking concern in the newly enabled parsing path.

Artifacts

Executed nested multipart reproduction source

  • The authored Python script constructs a charset-only inner multipart and asserts that the following outer sibling retains its name and content.

Base run without an epilogue

  • The script ran against base and failed while parsing the inner closing boundary, before reaching the outer sibling.

PR run without an epilogue

  • The script ran against PR head and read the sibling content, but its missing field name caused exit code 1.

Base run with an epilogue

  • The script ran against base with an epilogue line and failed while parsing the inner closing boundary.

PR run with an epilogue

  • The script ran against PR head with an epilogue line and verified the sibling name, content, and outer EOF with exit code 0.

View artifacts

T-Rex Ran code and verified through T-Rex

@greptile-apps

greptile-apps Bot commented Sep 27, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings sit on lines the diff does not cover, so they could not be posted inline. Each one leaves this list once its file changes.

  • P2 Outer sibling loses its disposition after a charset-only nested part without an epilogue ▶

    • Bug
      • When the inner closing boundary is followed directly by the outer boundary, the PR returns an outer sibling with the correct body but no Content-Disposition header or field name. Base could not parse the charset-only inner part at all, so this is a defect in the newly enabled path rather than a demonstrated loss of previously working behavior.
    • Cause
      • The new early _read_boundary() and EOF return leave the parent's boundary and sibling-header lines in the nested reader's unread buffer. The parent takes its boundary from that buffer, but header parsing reads directly from the stream, bypassing the buffered sibling header.
    • Fix
      • Ensure lines handed back to the parent are consumed by its header parser, including when the inner multipart has no epilogue line.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agentscan:automated-account backport-3.14 Trigger automatic backporting to the 3.14 release branch by Patchback robot backport-3.15 Trigger automatic backporting to the 3.15 release branch by Patchback robot bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants