-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
Description
⚠️ This issue respects the following points: ⚠️
- This is a bug, not a question or a configuration/webserver/proxy issue.
- This issue is not already reported on Github OR Nextcloud Community Forum (I've searched it).
- Nextcloud Server is up to date. See Maintenance and Release Schedule for supported versions.
- I agree to follow Nextcloud's Code of Conduct.
Bug description
On Nextcloud instances running on PHP 8.0 or newer, the file size check within the Workflow Engine (OCA\WorkflowEngine\Check\FileSize) is vulnerable to a Denial of Service attack.
When a file upload request is sent without a valid Content-Length or OC-Total-Length header, the getFileSizeFromHeader() method incorrectly attempts to process an invalid value. This leads to a call to Util::numericToNumber(false). Due to PHP 8's stricter type system, the operation 0 + '' (which results from casting false to a string) is no longer permitted and throws a fatal TypeError.
This unhandled exception crashes the PHP process handling the request, causing the server to return an HTTP 500 Internal Server Error. An attacker can repeatedly send such malformed requests to trigger these fatal errors, consuming server resources and potentially impacting the availability and stability of the entire Nextcloud instance.
While this "Fail-Closed" behavior prevents a security bypass, the crash itself constitutes a Denial of Service vulnerability.
Steps to reproduce
The issue can be reproduced with a minimal, self-contained PHP script that simulates the environment and triggers the faulty code path. No full Nextcloud installation is required.
Directory Structure
├── FileSize.php
├── Util.php
└── test.php
FileSize.php
<?php
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\WorkflowEngine\Check;
use OCA\WorkflowEngine\Entity\File;
use OCP\IL10N;
use OCP\IRequest;
use OCP\Util;
use OCP\WorkflowEngine\ICheck;
class FileSize implements ICheck {
protected int|float|null $size = null;
public function __construct(
protected readonly IL10N $l,
protected readonly IRequest $request,
) {
}
/**
* @param string $operator
* @param string $value
*/
public function executeCheck($operator, $value): bool {
$size = $this->getFileSizeFromHeader();
if ($size === false) {
return false;
}
$value = Util::computerFileSize($value);
return match ($operator) {
'less' => $size < $value,
'!less' => $size >= $value,
'greater' => $size > $value,
'!greater' => $size <= $value,
default => false,
};
}
/**
* @param string $operator
* @param string $value
* @throws \UnexpectedValueException
*/
public function validateCheck($operator, $value): void {
if (!in_array($operator, ['less', '!less', 'greater', '!greater'])) {
throw new \UnexpectedValueException($this->l->t('The given operator is invalid'), 1);
}
if (!preg_match('/^[0-9]+[ ]?[kmgt]?b$/i', $value)) {
throw new \UnexpectedValueException($this->l->t('The given file size is invalid'), 2);
}
}
protected function getFileSizeFromHeader(): int|float|false {
if ($this->size !== null) {
return $this->size;
}
$size = $this->request->getHeader('OC-Total-Length');
if ($size === '') {
if (in_array($this->request->getMethod(), ['POST', 'PUT'])) {
$size = $this->request->getHeader('Content-Length');
}
}
if ($size === '' || !is_numeric($size)) {
$size = false;
}
$this->size = Util::numericToNumber($size);
return $this->size;
}
public function supportedEntities(): array {
return [ File::class ];
}
public function isAvailableForScope(int $scope): bool {
return true;
}
}
Util.php
<?php
// Util.php
// 模拟 Nextcloud 的 Util 类,只包含我们需要的方法
class Util {
public static function numericToNumber(string|float|int|bool $number): int|float {
/* This is a hack to cast to (int|float) */
return 0 + (string)$number;
}
// 从 Nextcloud 源码中拿到的对应辅助函数,用于将 '1mb' 转换成字节
public static function computerFileSize(string $value): int|float {
if (preg_match('/^([0-9]+) ?(b|k|m|g|t)b?$/i', $value, $matches)) {
$value = (int)$matches[1];
switch (strtolower($matches[2])) {
case 't':
$value *= 1024;
// no break
case 'g':
$value *= 1024;
// no break
case 'm':
$value *= 1024;
// no break
case 'k':
$value *= 1024;
}
}
return self::numericToNumber($value);
}
}
test.php
<?php
// 使用 spl_autoload_register 来自动加载类文件,简化模拟
spl_autoload_register(function ($class_name) {
// 模拟 OCP 命名空间
if (strpos($class_name, 'OCP\\') === 0) {
$full_interface_name = $class_name;
$interface_name = substr($class_name, strlen('OCP\\'));
// 直接定义所需的接口
if ($interface_name === 'IRequest') {
if (!interface_exists($full_interface_name)) {
eval("namespace OCP; interface IRequest { public function getHeader(string \$name): string; public function getMethod(): string; }");
}
} elseif ($interface_name === 'IL10N') {
if (!interface_exists($full_interface_name)) {
eval("namespace OCP; interface IL10N { public function t(string \$text, array \$parameters = []): string; }");
}
} elseif ($interface_name === 'WorkflowEngine\\ICheck') {
if (!interface_exists($full_interface_name)) {
eval("namespace OCP\\WorkflowEngine; interface ICheck { public function executeCheck(\$operator, \$value): bool; public function validateCheck(\$operator, \$value): void; }");
}
} elseif ($interface_name === 'Util') {
// 不需要在这里定义 Util 类,因为它已经在 Util.php 中定义了
return;
} else {
// 定义一个空的接口来满足类型提示
if (!interface_exists($full_interface_name)) {
eval("namespace OCP; interface $interface_name {}");
}
}
return;
}
// 模拟 OCA 命名空间
if (strpos($class_name, 'OCA\\') === 0) {
$path = str_replace(['OCA\\WorkflowEngine\\Check\\', '\\'], ['', DIRECTORY_SEPARATOR], $class_name) . '.php';
if (file_exists($path)) {
require_once $path;
}
}
});
// 我们需要一个 Util 类
if (!class_exists('Util')) {
require_once 'Util.php';
}
// 确保 OCP\Util 类也指向我们的 Util 类
if (!class_exists('OCP\Util')) {
class_alias('Util', 'OCP\Util');
}
// 1. 模拟环境 (Mocking the Environment)
// ----------------------------------------------------
// 模拟 IRequest 接口
class MockRequest implements OCP\IRequest {
private array $headers;
private string $method;
public function __construct(string $method, array $headers = []) {
$this->method = $method;
$this->headers = array_change_key_case($headers, CASE_UPPER);
}
public function getHeader(string $name): string {
return $this->headers[strtoupper($name)] ?? '';
}
public function getMethod(): string {
return $this->method;
}
// 其他 IRequest 方法可以留空,因为我们用不到
}
// 模拟 IL10N 接口
class MockL10N implements OCP\IL10N {
public function t(string $text, array $parameters = []): string {
return $text; // 简单返回原文
}
}
// 2. 构造测试用例 (Building Test Cases)
// ----------------------------------------------------
// 用例配置
$rule_operator = 'less'; // 规则:文件大小必须 "小于"
$rule_value = '1mb'; // 规则值:1 MB
echo "Test Rule: File size must be 'less' than '1mb'.\n";
echo "----------------------------------------------------\n";
// --- 测试用例 1: 攻击场景 - 没有 Content-Length 头 ---
echo "Case 1: Attacker sends a PUT request with NO Content-Length header.\n";
$attacker_request = new MockRequest('PUT', []); // 空的 headers
$fileSizeCheck_attacker = new OCA\WorkflowEngine\Check\FileSize(new MockL10N(), $attacker_request);
$is_allowed_attacker = $fileSizeCheck_attacker->executeCheck($rule_operator, $rule_value);
echo "executeCheck() result: " . ($is_allowed_attacker ? 'true (ALLOWED)' : 'false (DENIED)') . "\n";
if ($is_allowed_attacker) {
echo "=> VULNERABILITY CONFIRMED! The check was bypassed and the file upload is allowed.\n";
} else {
echo "=> The check was not bypassed. The code might be patched.\n";
}
echo "\n";
// --- 测试用例 2: 正常场景 - 文件大小合法 ---
echo "Case 2: Legitimate user sends a file smaller than 1MB (500KB).\n";
$legitimate_request_small = new MockRequest('PUT', ['Content-Length' => '512000']); // 500KB
$fileSizeCheck_legit_small = new OCA\WorkflowEngine\Check\FileSize(new MockL10N(), $legitimate_request_small);
$is_allowed_legit_small = $fileSizeCheck_legit_small->executeCheck($rule_operator, $rule_value);
echo "executeCheck() result: " . ($is_allowed_legit_small ? 'true (ALLOWED)' : 'false (DENIED)') . "\n";
echo "=> CORRECT BEHAVIOR. A small file is correctly allowed.\n";
echo "\n";
// --- 测试用例 3: 正常场景 - 文件大小超限 ---
echo "Case 3: Legitimate user sends a file larger than 1MB (2MB).\n";
$legitimate_request_large = new MockRequest('PUT', ['Content-Length' => '2097152']); // 2MB
$fileSizeCheck_legit_large = new OCA\WorkflowEngine\Check\FileSize(new MockL10N(), $legitimate_request_large);
$is_allowed_legit_large = $fileSizeCheck_legit_large->executeCheck($rule_operator, $rule_value);
echo "executeCheck() result: " . ($is_allowed_legit_large ? 'true (ALLOWED)' : 'false (DENIED)') . "\n";
echo "=> CORRECT BEHAVIOR. A large file is correctly denied.\n";
result:
Test Rule: File size must be 'less' than '1mb'.
----------------------------------------------------
Case 1: Attacker sends a PUT request with NO Content-Length header.
PHP Fatal error: Uncaught TypeError: Unsupported operand types: int + string in /Users/d3do/phptest/Util.php:8
Stack trace:
#0 /Users/d3do/phptest/FileSize.php(76): Util::numericToNumber(false)
#1 /Users/d3do/phptest/FileSize.php(30): OCA\WorkflowEngine\Check\FileSize->getFileSizeFromHeader()
#2 /Users/d3do/phptest/test.php(100): OCA\WorkflowEngine\Check\FileSize->executeCheck('less', '1mb')
#3 {main}
thrown in /Users/d3do/phptest/Util.php on line 8
Fatal error: Uncaught TypeError: Unsupported operand types: int + string in /Users/d3do/phptest/Util.php:8
Stack trace:
#0 /Users/d3do/phptest/FileSize.php(76): Util::numericToNumber(false)
#1 /Users/d3do/phptest/FileSize.php(30): OCA\WorkflowEngine\Check\FileSize->getFileSizeFromHeader()
#2 /Users/d3do/phptest/test.php(100): OCA\WorkflowEngine\Check\FileSize->executeCheck('less', '1mb')
#3 {main}
thrown in /Users/d3do/phptest/Util.php on line 8
Expected behavior
The system should handle malformed requests gracefully and securely, without crashing.
When the file size cannot be determined from the headers, the FileSize check should identify this as a failure condition. Instead of throwing a fatal TypeError, it should return a clear failure signal to the workflow engine. The upload should be properly denied with an appropriate client-side error code, such as 400 Bad Request or 411 Length Required, preserving server stability.
Nextcloud Server version
master
Operating system
Other
PHP engine version
PHP 8.4
Web server
Other
Database engine version
None
Is this bug present after an update or on a fresh install?
None
Are you using the Nextcloud Server Encryption module?
None
What user-backends are you using?
- Default user-backend (database)
- LDAP/ Active Directory
- SSO - SAML
- Other
Configuration report
List of activated Apps
Nextcloud Signing status
Nextcloud Logs
Additional info
No response