Skip to content

Default max_field_size (8190) is too small for some (ML-DSA-87) post-quantum X.509 certificates #13397

Description

@dralley

Describe the bug

It is common to have a TLS-terminating reverse proxy (Apache, nginx) forward client certificates to backend applications via an HTTP header such as X-CLIENT-CERT or X-SSL-CLIENT-CERT. When the client certificate uses ML-DSA-87, aiohttp's server rejects the request at the HTTP parsing layer:

aiohttp.http_exceptions.LineTooLong: 400, message:
  Got more than 8190 bytes when reading header value

This is because aiohttp's HTTP parser defaults max_field_size to 8190 bytes, and post-quantum X.509 certificates are larger than the algorithms in current widespread use.

An ML-DSA-65 certificate tends to come just under the limit, but an ML-DSA-87 certificate exceeds it (roughly 10k bytes)

Admittedly client certs are not that common, but this limitation could be documented more prominently or the default increased slightly to accommodate the new algorithms.

Otherwise I just wanted to document this for posterity should anyone else run into it.

To Reproduce

Reproducer produced by Claude Opus - yes I tried it - yes it worked.

You do need a very new version of openssl for it to work though. Fedora and CentOS Stream 10 have all of the new PQC stuff enabled, I don't know which other distros would.

import asyncio
import subprocess
import tempfile
from pathlib import Path
from urllib.parse import quote

from aiohttp import web, ClientSession


async def handle(request):
    cert = request.headers.get("X-CLIENT-CERT", "")
    return web.Response(text=f"ok, cert length: {len(cert)}")


def generate_mldsa87_client_cert(workdir: Path) -> str:
    """Generate a self-signed ML-DSA-87 client certificate. Returns PEM string."""
    key_path = workdir / "client.key"
    cert_path = workdir / "client.crt"
    subprocess.run(
        ["openssl", "req", "-x509", "-newkey", "ML-DSA-87", "-days", "1",
         "-subj", "/CN=test-client", "-noenc",
         "-keyout", str(key_path), "-out", str(cert_path)],
        check=True, capture_output=True,
    )
    return cert_path.read_text()


async def main():
    with tempfile.TemporaryDirectory() as td:
        cert_pem = generate_mldsa87_client_cert(Path(td))
        encoded_cert = quote(cert_pem)
        print(f"ML-DSA-87 cert PEM: {len(cert_pem)} bytes")
        print(f"URL-encoded:        {len(encoded_cert)} bytes")
        print(f"aiohttp default:    8190 bytes")
        print()

        # Start aiohttp server with default settings
        app = web.Application()  # max_field_size defaults to 8190
        app.router.add_get("/", handle)
        runner = web.AppRunner(app)
        await runner.setup()
        site = web.TCPSite(runner, "127.0.0.1", 8080)
        await site.start()

        # Simulate what a reverse proxy does: forward the client cert as a header
        async with ClientSession() as session:
            resp = await session.get(
                "http://127.0.0.1:8080/",
                headers={"X-CLIENT-CERT": encoded_cert},
            )
            print(f"Response status: {resp.status}")
            if resp.status == 400:
                print("FAILED: aiohttp rejected the header as too large")
                print("        (LineTooLong: Got more than 8190 bytes)")
            else:
                print(f"OK: {await resp.text()}")

        await runner.cleanup()


asyncio.run(main())

Expected behavior

Request should succeed

It can be worked around by changing the default value of max_field_size:

-app = web.Application()
+# PQC (post-quantum) X.509 certificates can exceed aiohttp's default 8190-byte header
+# limit when forwarded via X-CLIENT-CERT by a reverse proxy.
+app = web.Application(handler_args={"max_field_size": 16 * 1024})

Logs/tracebacks

Traceback (most recent call last):
 File "/usr/lib64/python3.12/site-packages/aiohttp/web_protocol.py", line 415, in data_received
   messages, upgraded, tail = self._request_parser.feed_data(data)
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 File "aiohttp/_http_parser.pyx", line 593, in aiohttp._http_parser.HttpParser.feed_data
 File "aiohttp/_http_parser.pyx", line 769, in aiohttp._http_parser.cb_on_header_value
aiohttp.http_exceptions.LineTooLong: 400, message:
 Got more than 8190 bytes when reading: b'-----BEGIN CERTIFICATE----- MIIYQTCCCz6gAwIBAgIIcNsAKyLFH3owCwYJYIZIAWUDBAMSMEAxCzAJBgNVBAYT AkNaMRA...'.
:1 [16/Jul/2026:21:44:31 +0000] "UNKNOWN / HTTP/1.0" 400 309 "-" "-"

Python Version

3.12

aiohttp Version

Version: 3.14.3

multidict Version

Version: 6.7.1

propcache Version

Version: 0.5.2

yarl Version

Version: 1.24.5

OS

CentOS Stream 10

Related component

Server, Client

Additional context

No response

Code of Conduct

  • I agree to follow the aio-libs Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions