Skip to content

Request bodies exceeding request_body max_size are silently truncated instead of returning 413 (classic and worker) #2781

Open

Description

@ma-escalona

What happened?

Request bodies exceeding request_body max_size are silently truncated instead of returning 413 (classic and worker)

What happened?

With request_body max_size configured, an oversized request body is exposed to
PHP as a truncated string, and PHP can return HTTP 200 instead of the request
being rejected with HTTP 413.

This reproduces with both:

  • HTTP/1.1 Transfer-Encoding: chunked, without Content-Length.
  • A known Content-Length larger than the configured limit.

It occurs in both classic and worker mode. The reproducer below uses plain PHP,
without Symfony or any other framework.

For a configured limit of 1,048,576 bytes (1 MiB), sending 2,097,152 bytes
(2 MiB)
makes file_get_contents('php://input') return a string containing
exactly the first 1,048,576 bytes. A separate diagnostic check confirmed that
the read did not return false and that the SHA-256 matched the prefix of the
original body.

Consequently, an application can process a truncated payload:

  • A valid JSON document followed by whitespace remains valid after truncation
    and can be accepted with HTTP 200.
  • A valid JSON document containing a long string becomes invalid when cut
    inside that string and can cause an application-level HTTP 400.

These application statuses are not the expected HTTP 413 for an oversized body.

Important context: our original application configuration also had an explicit
Content-Length matcher returning 413. That additional rule explains why
Content-Length requests initially appeared unaffected. With only
request_body max_size, the standalone reproducer below truncates both framings.

Expected behaviour

When reading the body exceeds request_body max_size, the request should fail
with HTTP 413, as described in the Caddy request_body documentation.

PHP should not silently treat the truncated prefix as the complete request body.
The minimal script below reads the body before sending its response headers or
body.

Minimal reproducer

Create these files in an otherwise empty directory.

Caddyfile

{
	frankenphp
	auto_https off
}

:80 {
	root * /app/public
	request_body {
		max_size 1048576
	}
	php_server
}

The limit is expressed in bytes to avoid unit ambiguity: 1MB means 1,000,000
bytes, whereas this test uses 1,048,576 bytes.

public/index.php

<?php
$body = file_get_contents('php://input');
header('Content-Type: application/json');
echo json_encode(['received_bytes' => strlen($body)]);

Run

docker run --rm --name frankenphp-body-limit-repro \
  -p 127.0.0.1:8080:80 \
  -v "$PWD/Caddyfile:/etc/frankenphp/Caddyfile:ro" \
  -v "$PWD/public:/app/public:ro" \
  dunglas/frankenphp:1.13.0-php8.5

The image's default configuration path is /etc/frankenphp/Caddyfile.
Mounting the configuration at /etc/caddy/Caddyfile does not load this limit.

client.py

import http.client

for size in (1024, 2 * 1024 * 1024):
    data = b"x" * size
    for chunked in (False, True):
        conn = http.client.HTTPConnection("127.0.0.1", 8080, timeout=30)
        body = (
            (data[i:i + 64 * 1024] for i in range(0, len(data), 64 * 1024))
            if chunked else data
        )
        try:
            try:
                conn.request(
                    "POST", "/", body=body,
                    headers={"Content-Type": "application/octet-stream"},
                    encode_chunked=chunked,
                )
            except (BrokenPipeError, ConnectionResetError):
                pass

            response = conn.getresponse()
            framing = "chunked" if chunked else "Content-Length"
            print(size, framing, response.status, response.read().decode())
        finally:
            conn.close()

Run in another terminal:

python3 client.py

Actual output

1024 Content-Length 200 {"received_bytes":1024}
1024 chunked 200 {"received_bytes":1024}
2097152 Content-Length 200 {"received_bytes":1048576}
2097152 chunked 200 {"received_bytes":1048576}
Request body Framing Expected Actual
1 KiB Content-Length 200, 1,024 bytes 200, 1,024 bytes
1 KiB chunked 200, 1,024 bytes 200, 1,024 bytes
2 MiB Content-Length 413 200, 1,048,576 bytes
2 MiB chunked 413 200, 1,048,576 bytes

Worker mode reproducer

The same results occur with a standalone native worker. Replace the global
Caddyfile options block with:

{
	auto_https off
	frankenphp {
		worker /app/public/index.php 1
	}
}

Keep the site block unchanged and replace public/index.php with:

<?php
$handler = static function (): void {
    $body = file_get_contents('php://input');
    header('Content-Type: application/json');
    echo json_encode(['received_bytes' => strlen($body)]);
};

while (frankenphp_handle_request($handler)) {
}

Restart the container and run the same client.

Possible cause / source investigation

Caddy's request-body middleware wraps the body with http.MaxBytesReader and
wraps its size error as an HTTP 413 handler error:
requestbody.go, Caddy v2.11.7.

In FrankenPHP v1.13.0, the go_read_post callback stops its read loop on an
error but returns only the number of bytes read:
frankenphp.go.

This looks consistent with the error being lost at the body-read/SAPI boundary.
I have not verified the complete error-propagation path or the appropriate fix.

Impact

The byte cap appears to be applied, but the oversized request is not rejected:
the application may act on a prefix of a payload that should have failed with
413. This affects both chunked and Content-Length requests; it is not limited
to clients that stream their bodies.

An explicit Content-Length guard can reject known oversized lengths, but it
does not address the chunked case.

Build Type

Official Docker image, Debian GNU/Linux 13 (Trixie).

Also reproduced with the original Debian GNU/Linux 12 (Bookworm) image:

dunglas/frankenphp:php8.5-bookworm@sha256:84044705caead9598289bd8714b229ac894807f9fff16097f813be2579d2a508

Worker Mode

Both; tested with plain PHP in classic mode and with a standalone
frankenphp_handle_request() worker.

Operating System

GNU/Linux, Docker on WSL2.

CPU Architecture

x86_64.

Versions

FrankenPHP v1.13.0 PHP 8.5.11 Caddy v2.11.7

Tested Trixie image:

dunglas/frankenphp:1.13.0-php8.5
sha256:2a9c6663f73ad1e401909634206bd25e13bed5b94d86eefa3b5ec64c985b4595

PHP's post_max_size was 8M in the standalone test, above the 2 MiB body size.

Relevant log output

Relevant log output

With the default logging configuration, stdout/stderr contained startup messages
and no request-body size error corresponding to these requests. The observable
failure is the HTTP 200 response with a shortened received_bytes value shown
above.

Build Type

Docker (Debian Trixie)

Worker Mode

Yes

Operating System

GNU/Linux

CPU Architecture

x86_64

PHP configuration

phpinfo() output
Configuration

Core
PHP Version | 8.5.11
Directive | Local Value | Master Value
allow_url_fopen | On | On
allow_url_include | Off | Off
arg_separator.input | & | &
arg_separator.output | & | &
auto_append_file | no value | no value
auto_globals_jit | On | On
auto_prepend_file | no value | no value
browscap | no value | no value
default_charset | UTF-8 | UTF-8
default_mimetype | text/html | text/html
disable_functions | no value | no value
display_errors | On | On
display_startup_errors | On | On
doc_root | no value | no value
docref_ext | no value | no value
docref_root | no value | no value
enable_dl | On | On
enable_post_data_reading | On | On
error_append_string | no value | no value
error_log | no value | no value
error_log_mode | 0644 | 0644
error_prepend_string | no value | no value
error_reporting | no value | no value
expose_php | On | On
extension_dir | /usr/local/lib/php/extensions/no-debug-zts-20250925 | /usr/local/lib/php/extensions/no-debug-zts-20250925
fatal_error_backtraces | On | On
fiber.stack_size | no value | no value
file_uploads | On | On
hard_timeout | 2 | 2
highlight.comment | #FF8000 | #FF8000
highlight.default | #0000BB | #0000BB
highlight.html | #000000 | #000000
highlight.keyword | #007700 | #007700
highlight.string | #DD0000 | #DD0000
html_errors | On | On
ignore_repeated_errors | Off | Off
ignore_repeated_source | Off | Off
ignore_user_abort | Off | Off
implicit_flush | Off | Off
include_path | .:/usr/local/lib/php | .:/usr/local/lib/php
input_encoding | no value | no value
internal_encoding | no value | no value
log_errors | Off | Off
mail.add_x_header | Off | Off
mail.cr_lf_mode | crlf | crlf
mail.force_extra_parameters | no value | no value
mail.log | no value | no value
mail.mixed_lf_and_crlf | Off | Off
max_execution_time | 30 | 30
max_file_uploads | 20 | 20
max_input_nesting_level | 64 | 64
max_input_time | -1 | -1
max_input_vars | 1000 | 1000
max_memory_limit | -1 | -1
max_multipart_body_parts | -1 | -1
memory_limit | 128M | 128M
open_basedir | no value | no value
output_buffering | 0 | 0
output_encoding | no value | no value
output_handler | no value | no value
post_max_size | 8M | 8M
precision | 14 | 14
realpath_cache_size | 4096K | 4096K
realpath_cache_ttl | 120 | 120
register_argc_argv | Off | Off
report_memleaks | On | On
report_zend_debug | Off | Off
request_order | no value | no value
sendmail_from | no value | no value
sendmail_path | /usr/sbin/sendmail -t -i | /usr/sbin/sendmail -t -i
serialize_precision | -1 | -1
short_open_tag | On | On
SMTP | localhost | localhost
smtp_port | 25 | 25
sys_temp_dir | no value | no value
syslog.facility | LOG_USER | LOG_USER
syslog.filter | no-ctrl | no-ctrl
syslog.ident | php | php
unserialize_callback_func | no value | no value
upload_max_filesize | 2M | 2M
upload_tmp_dir | no value | no value
user_dir | no value | no value
user_ini.cache_ttl | 300 | 300
user_ini.filename | .user.ini | .user.ini
variables_order | EGPCS | EGPCS
xmlrpc_error_number | 0 | 0
xmlrpc_errors | Off | Off
zend.assertions | 1 | 1
zend.detect_unicode | On | On
zend.enable_gc | On | On
zend.exception_ignore_args | Off | Off
zend.exception_string_param_max_len | 15 | 15
zend.max_allowed_stack_size | 0 | 0
zend.multibyte | Off | Off
zend.reserved_stack_size | 0 | 0
zend.script_encoding | no value | no value

Relevant log output

No request-body size error was logged with the default configuration.

Reproducer output, identical in classic and worker mode:
1024 Content-Length 200 {"received_bytes":1024}
1024 chunked 200 {"received_bytes":1024}
2097152 Content-Length 200 {"received_bytes":1048576}
2097152 chunked 200 {"received_bytes":1048576}

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions