Skip to content

[Bug]: Malformed request to FileSize check causes TypeError, leading to Denial of Service #55276

@d3do-23

Description

@d3do-23

⚠️ This issue respects the following points: ⚠️

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

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions