feat: add setup check for request buffering on FPM

Context: Using `Transfer-Encoding: chunked` of HTTP 1.1

PHP-FPM has a bug[1] where the request body is not passed to PHP
if the `Content-Length` header is missing, while FastCGI in general
allows this (I could reproduce that FastCGI passed the request stream
from NGinx) PHP-FPM does not forward this to the PHP application.

This means when using PHP-FPM we get an empty request body and thus
every `PUT` will be an empty file.

I tested that `mod_php` is not affected, while it also has no
`Content-Length` header, it correctly passed the stream and thus also
works without buffering the request.

Only PHP-FPM needs buffering of the request so that a `Content-Length`
header can be generated.

To enable this on Apache set: `SetEnvIfNoCase Transfer-Encoding "chunked" proxy-sendcl=1`
On NGinx: `fastcgi_request_buffering on;`.

[1]: https://github.com/php/php-src/issues/9441
ref: https://github.com/nextcloud/server/issues/7995

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2025-09-05 01:06:13 +02:00
parent 15fa1094ac
commit ccc4d2eb9f
No known key found for this signature in database
GPG key ID: 45FAE7268762B400
6 changed files with 121 additions and 4 deletions

View file

@ -44,10 +44,13 @@ return [
['name' => 'Users#usersListByGroup', 'url' => '/settings/users/{group}', 'verb' => 'GET', 'requirements' => ['group' => '.+'] , 'root' => ''],
['name' => 'Users#setPreference', 'url' => '/settings/users/preferences/{key}', 'verb' => 'POST' , 'root' => ''],
['name' => 'LogSettings#download', 'url' => '/settings/admin/log/download', 'verb' => 'GET' , 'root' => ''],
['name' => 'CheckSetup#setupCheckManager', 'url' => '/settings/setupcheck', 'verb' => 'GET' , 'root' => ''],
['name' => 'CheckSetup#check', 'url' => '/settings/ajax/checksetup', 'verb' => 'GET' , 'root' => ''],
['name' => 'CheckSetup#checkContentLengthHeader', 'url' => '/settings/setupcheck/test-content-length', 'verb' => 'PUT' , 'root' => ''],
['name' => 'CheckSetup#getFailedIntegrityCheckFiles', 'url' => '/settings/integrity/failed', 'verb' => 'GET' , 'root' => ''],
['name' => 'CheckSetup#rescanFailedIntegrityCheck', 'url' => '/settings/integrity/rescan', 'verb' => 'GET' , 'root' => ''],
['name' => 'PersonalSettings#index', 'url' => '/settings/user/{section}', 'verb' => 'GET', 'defaults' => ['section' => 'personal-info'] , 'root' => ''],
['name' => 'AdminSettings#index', 'url' => '/settings/admin/{section}', 'verb' => 'GET', 'defaults' => ['section' => 'server'] , 'root' => ''],
['name' => 'AdminSettings#form', 'url' => '/settings/admin/{section}', 'verb' => 'GET' , 'root' => ''],

View file

@ -128,6 +128,7 @@ return array(
'OCA\\Settings\\SetupChecks\\PushService' => $baseDir . '/../lib/SetupChecks/PushService.php',
'OCA\\Settings\\SetupChecks\\RandomnessSecure' => $baseDir . '/../lib/SetupChecks/RandomnessSecure.php',
'OCA\\Settings\\SetupChecks\\ReadOnlyConfig' => $baseDir . '/../lib/SetupChecks/ReadOnlyConfig.php',
'OCA\\Settings\\SetupChecks\\RequestBuffering' => $baseDir . '/../lib/SetupChecks/RequestBuffering.php',
'OCA\\Settings\\SetupChecks\\SchedulingTableSize' => $baseDir . '/../lib/SetupChecks/SchedulingTableSize.php',
'OCA\\Settings\\SetupChecks\\SecurityHeaders' => $baseDir . '/../lib/SetupChecks/SecurityHeaders.php',
'OCA\\Settings\\SetupChecks\\SupportedDatabase' => $baseDir . '/../lib/SetupChecks/SupportedDatabase.php',

View file

@ -143,6 +143,7 @@ class ComposerStaticInitSettings
'OCA\\Settings\\SetupChecks\\PushService' => __DIR__ . '/..' . '/../lib/SetupChecks/PushService.php',
'OCA\\Settings\\SetupChecks\\RandomnessSecure' => __DIR__ . '/..' . '/../lib/SetupChecks/RandomnessSecure.php',
'OCA\\Settings\\SetupChecks\\ReadOnlyConfig' => __DIR__ . '/..' . '/../lib/SetupChecks/ReadOnlyConfig.php',
'OCA\\Settings\\SetupChecks\\RequestBuffering' => __DIR__ . '/..' . '/../lib/SetupChecks/RequestBuffering.php',
'OCA\\Settings\\SetupChecks\\SchedulingTableSize' => __DIR__ . '/..' . '/../lib/SetupChecks/SchedulingTableSize.php',
'OCA\\Settings\\SetupChecks\\SecurityHeaders' => __DIR__ . '/..' . '/../lib/SetupChecks/SecurityHeaders.php',
'OCA\\Settings\\SetupChecks\\SupportedDatabase' => __DIR__ . '/..' . '/../lib/SetupChecks/SupportedDatabase.php',

View file

@ -66,6 +66,7 @@ use OCA\Settings\SetupChecks\PhpOutputBuffering;
use OCA\Settings\SetupChecks\PushService;
use OCA\Settings\SetupChecks\RandomnessSecure;
use OCA\Settings\SetupChecks\ReadOnlyConfig;
use OCA\Settings\SetupChecks\RequestBuffering;
use OCA\Settings\SetupChecks\SchedulingTableSize;
use OCA\Settings\SetupChecks\SecurityHeaders;
use OCA\Settings\SetupChecks\SupportedDatabase;
@ -203,6 +204,7 @@ class Application extends App implements IBootstrap {
$context->registerSetupCheck(PhpOutputBuffering::class);
$context->registerSetupCheck(RandomnessSecure::class);
$context->registerSetupCheck(ReadOnlyConfig::class);
$context->registerSetupCheck(RequestBuffering::class);
$context->registerSetupCheck(SecurityHeaders::class);
$context->registerSetupCheck(SchedulingTableSize::class);
$context->registerSetupCheck(SupportedDatabase::class);

View file

@ -11,10 +11,12 @@ use OC\AppFramework\Http;
use OC\IntegrityCheck\Checker;
use OCA\Settings\Settings\Admin\Overview;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse;
@ -53,6 +55,14 @@ class CheckSetupController extends Controller {
return new DataResponse($this->setupCheckManager->runAll());
}
/**
* @return DataResponse
*/
#[AuthorizedAdminSetting(settings: Overview::class)]
public function check() {
return new DataResponse($this->setupCheckManager->runAll());
}
/**
* @return RedirectResponse
*/
@ -125,10 +135,14 @@ Raw output
}
/**
* @return DataResponse
* Used for setup checks to verify the server has request caching.
* @NoSubAdminRequired
*/
#[AuthorizedAdminSetting(settings: Overview::class)]
public function check() {
return new DataResponse($this->setupCheckManager->runAll());
#[PublicPage()]
#[NoCSRFRequired()]
#[AnonRateLimit(limit: 10, period: 600)]
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
public function checkContentLengthHeader(): DataResponse {
return new DataResponse(($this->request->getHeader('Content-Length')));
}
}

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Settings\SetupChecks;
use GuzzleHttp\Psr7;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\SetupCheck\CheckServerResponseTrait;
use OCP\SetupCheck\ISetupCheck;
use OCP\SetupCheck\SetupResult;
use Psr\Log\LoggerInterface;
class RequestBuffering implements ISetupCheck {
use CheckServerResponseTrait;
public function __construct(
protected IL10N $l10n,
protected IConfig $config,
protected IURLGenerator $urlGenerator,
protected IClientService $clientService,
protected LoggerInterface $logger,
) {
}
public function getCategory(): string {
return 'network';
}
public function getName(): string {
return $this->l10n->t('Request buffering');
}
public function run(): SetupResult {
$usingFPM = function_exists('fastcgi_finish_request');
if (!$usingFPM && !\OC::$CLI) {
return SetupResult::success(
$this->l10n->t('Not using PHP-FPM.')
);
}
$works = null;
$stream = Psr7\Utils::streamFor(str_repeat('x', 1337));
$options = [
'body' => $stream,
'headers' => [
'Transfer-Encoding' => 'chunked',
],
];
$url = $this->urlGenerator->linkToRoute('settings.CheckSetup.checkContentLengthHeader');
foreach ($this->runRequest('PUT', $url, ['ignoreSSL' => true, 'options' => $options]) as $response) {
$contentType = $response->getHeader('Content-Type');
if (!str_contains(strtolower($contentType), 'application/json')) {
continue;
}
$body = $response->getBody();
$body = is_resource($body) ? stream_get_contents($body) : $body;
$works = $works || $body === '"1337"';
}
if ($works === null) {
return SetupResult::info(
$this->l10n->t('Could not check that your web server has configured request buffering. Please check manually.'),
);
} elseif ($works === true) {
return SetupResult::success(
$this->l10n->t('Your web server seems to have request buffering configured correctly.'),
);
} else {
if ($usingFPM) {
return SetupResult::error(
$this->l10n->t('Your web server is not configured for request buffering, this will cause broken uploads with some clients.')
. ' '
. $this->l10n->t('Due to a limitation of PHP-FPM chunked requests will not be passed to Nextcloud if the server does not buffer such requests.'),
);
} else {
// Not using FPM but we are on CLI so we do not know if FPM is used
return SetupResult::warning(
$this->l10n->t('Your web server is not configured for request buffering, if you are running PHP-FPM this will cause broken uploads with some clients.')
. ' '
. $this->l10n->t('Due to a limitation of PHP-FPM chunked requests will not be passed to Nextcloud if the server does not buffer such requests.'),
);
}
}
}
}