From d0d84d4c5195fb0a68ea7725424fda63b85cd831 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Tue, 4 Mar 2025 20:44:01 +0000 Subject: [PATCH] New rule: explode() limit parameter must be set --- build/dump-version-info.php | 2 +- phpstan.neon.dist | 1 + src/PocketMine.php | 2 +- src/block/tile/Sign.php | 3 +- src/block/utils/SignText.php | 2 +- src/command/Command.php | 3 +- src/command/defaults/HelpCommand.php | 3 +- src/command/defaults/ParticleCommand.php | 6 +- src/console/ConsoleCommandSender.php | 2 +- src/item/LegacyStringToItemParser.php | 3 +- src/lang/Language.php | 2 +- src/network/mcpe/JwtUtils.php | 6 +- src/permission/BanEntry.php | 4 +- src/utils/Config.php | 9 +- src/utils/Internet.php | 6 +- src/utils/Utils.php | 2 +- src/world/generator/FlatGeneratorOptions.php | 11 ++- tests/phpstan/rules/ExplodeLimitRule.php | 92 ++++++++++++++++++++ tools/generate-bedrock-data-from-packets.php | 2 +- 19 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 tests/phpstan/rules/ExplodeLimitRule.php diff --git a/build/dump-version-info.php b/build/dump-version-info.php index 166264d98..e13696f3d 100644 --- a/build/dump-version-info.php +++ b/build/dump-version-info.php @@ -36,7 +36,7 @@ require dirname(__DIR__) . '/vendor/autoload.php'; */ $options = [ "base_version" => VersionInfo::BASE_VERSION, - "major_version" => fn() => explode(".", VersionInfo::BASE_VERSION)[0], + "major_version" => fn() => explode(".", VersionInfo::BASE_VERSION, limit: 2)[0], "mcpe_version" => ProtocolInfo::MINECRAFT_VERSION_NETWORK, "is_dev" => VersionInfo::IS_DEVELOPMENT_BUILD, "changelog_file_name" => function() : string{ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5a816f81c..48bfd4a78 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -13,6 +13,7 @@ rules: - pocketmine\phpstan\rules\DeprecatedLegacyEnumAccessRule - pocketmine\phpstan\rules\DisallowEnumComparisonRule - pocketmine\phpstan\rules\DisallowForeachByReferenceRule + - pocketmine\phpstan\rules\ExplodeLimitRule - pocketmine\phpstan\rules\UnsafeForeachArrayOfStringRule # - pocketmine\phpstan\rules\ThreadedSupportedTypesRule diff --git a/src/PocketMine.php b/src/PocketMine.php index ffcfd91db..a71c9768d 100644 --- a/src/PocketMine.php +++ b/src/PocketMine.php @@ -264,7 +264,7 @@ JIT_WARNING $composerGitHash = InstalledVersions::getReference('pocketmine/pocketmine-mp'); if($composerGitHash !== null){ //we can't verify dependency versions if we were installed without using git - $currentGitHash = explode("-", VersionInfo::GIT_HASH())[0]; + $currentGitHash = explode("-", VersionInfo::GIT_HASH(), 2)[0]; if($currentGitHash !== $composerGitHash){ critical_error("Composer dependencies and/or autoloader are out of sync."); critical_error("- Current revision is $currentGitHash"); diff --git a/src/block/tile/Sign.php b/src/block/tile/Sign.php index 2ced414ff..0bb21a6d3 100644 --- a/src/block/tile/Sign.php +++ b/src/block/tile/Sign.php @@ -62,9 +62,10 @@ class Sign extends Spawnable{ /** * @return string[] + * @deprecated */ public static function fixTextBlob(string $blob) : array{ - return array_slice(array_pad(explode("\n", $blob), 4, ""), 0, 4); + return array_slice(array_pad(explode("\n", $blob, limit: 5), 4, ""), 0, 4); } protected SignText $text; diff --git a/src/block/utils/SignText.php b/src/block/utils/SignText.php index a7e8759b8..219899761 100644 --- a/src/block/utils/SignText.php +++ b/src/block/utils/SignText.php @@ -79,7 +79,7 @@ class SignText{ * @throws \InvalidArgumentException if the text is not valid UTF-8 */ public static function fromBlob(string $blob, ?Color $baseColor = null, bool $glowing = false) : SignText{ - return new self(array_slice(array_pad(explode("\n", $blob), self::LINE_COUNT, ""), 0, self::LINE_COUNT), $baseColor, $glowing); + return new self(array_slice(array_pad(explode("\n", $blob, limit: self::LINE_COUNT + 1), self::LINE_COUNT, ""), 0, self::LINE_COUNT), $baseColor, $glowing); } /** diff --git a/src/command/Command.php b/src/command/Command.php index aea57e6a2..54822d80e 100644 --- a/src/command/Command.php +++ b/src/command/Command.php @@ -37,6 +37,7 @@ use function array_values; use function explode; use function implode; use function str_replace; +use const PHP_INT_MAX; abstract class Command{ @@ -113,7 +114,7 @@ abstract class Command{ } public function setPermission(?string $permission) : void{ - $this->setPermissions($permission === null ? [] : explode(";", $permission)); + $this->setPermissions($permission === null ? [] : explode(";", $permission, limit: PHP_INT_MAX)); } public function testPermission(CommandSender $target, ?string $permission = null) : bool{ diff --git a/src/command/defaults/HelpCommand.php b/src/command/defaults/HelpCommand.php index 487c915f2..054585455 100644 --- a/src/command/defaults/HelpCommand.php +++ b/src/command/defaults/HelpCommand.php @@ -39,6 +39,7 @@ use function ksort; use function min; use function sort; use function strtolower; +use const PHP_INT_MAX; use const SORT_FLAG_CASE; use const SORT_NATURAL; @@ -108,7 +109,7 @@ class HelpCommand extends VanillaCommand{ $usage = $cmd->getUsage(); $usageString = $usage instanceof Translatable ? $lang->translate($usage) : $usage; - $sender->sendMessage(KnownTranslationFactory::pocketmine_command_help_specificCommand_usage(TextFormat::RESET . implode("\n" . TextFormat::RESET, explode("\n", $usageString))) + $sender->sendMessage(KnownTranslationFactory::pocketmine_command_help_specificCommand_usage(TextFormat::RESET . implode("\n" . TextFormat::RESET, explode("\n", $usageString, limit: PHP_INT_MAX))) ->prefix(TextFormat::GOLD)); $aliases = $cmd->getAliases(); diff --git a/src/command/defaults/ParticleCommand.php b/src/command/defaults/ParticleCommand.php index f20d47ccc..4867e3eb5 100644 --- a/src/command/defaults/ParticleCommand.php +++ b/src/command/defaults/ParticleCommand.php @@ -219,7 +219,11 @@ class ParticleCommand extends VanillaCommand{ break; case "blockdust": if($data !== null){ - $d = explode("_", $data); + //to preserve the old unlimited explode behaviour, allow this to split into at most 5 parts + //this allows the 4th argument to be processed normally if given without forcing it to also consume + //any unexpected parts + //we probably ought to error in this case, but this will do for now + $d = explode("_", $data, limit: 5); if(count($d) >= 3){ return new DustParticle(new Color( ((int) $d[0]) & 0xff, diff --git a/src/console/ConsoleCommandSender.php b/src/console/ConsoleCommandSender.php index aa7ea6e69..a0c1c5200 100644 --- a/src/console/ConsoleCommandSender.php +++ b/src/console/ConsoleCommandSender.php @@ -62,7 +62,7 @@ class ConsoleCommandSender implements CommandSender{ $message = $this->getLanguage()->translate($message); } - foreach(explode("\n", trim($message)) as $line){ + foreach(explode("\n", trim($message), limit: PHP_INT_MAX) as $line){ Terminal::writeLine(TextFormat::GREEN . "Command output | " . TextFormat::addBase(TextFormat::WHITE, $line)); } } diff --git a/src/item/LegacyStringToItemParser.php b/src/item/LegacyStringToItemParser.php index 6969190d5..19a6d1f6c 100644 --- a/src/item/LegacyStringToItemParser.php +++ b/src/item/LegacyStringToItemParser.php @@ -111,7 +111,8 @@ final class LegacyStringToItemParser{ */ public function parse(string $input) : Item{ $key = $this->reprocess($input); - $b = explode(":", $key); + //TODO: this should be limited to 2 parts, but 3 preserves old behaviour when given a string like 351:4:1 + $b = explode(":", $key, limit: 3); if(!isset($b[1])){ $meta = 0; diff --git a/src/lang/Language.php b/src/lang/Language.php index 29f28917d..59a309524 100644 --- a/src/lang/Language.php +++ b/src/lang/Language.php @@ -71,7 +71,7 @@ class Language{ foreach($files as $file){ try{ - $code = explode(".", $file)[0]; + $code = explode(".", $file, limit: 2)[0]; $strings = self::loadLang($path, $code); if(isset($strings[KnownTranslationKeys::LANGUAGE_NAME])){ $result[$code] = $strings[KnownTranslationKeys::LANGUAGE_NAME]; diff --git a/src/network/mcpe/JwtUtils.php b/src/network/mcpe/JwtUtils.php index 259a602d4..987ed6e61 100644 --- a/src/network/mcpe/JwtUtils.php +++ b/src/network/mcpe/JwtUtils.php @@ -72,9 +72,11 @@ final class JwtUtils{ * @throws JwtException */ public static function split(string $jwt) : array{ - $v = explode(".", $jwt); + //limit of 4 allows us to detect too many parts without having to split the string up into a potentially large + //number of parts + $v = explode(".", $jwt, limit: 4); if(count($v) !== 3){ - throw new JwtException("Expected exactly 3 JWT parts, got " . count($v)); + throw new JwtException("Expected exactly 3 JWT parts delimited by a period"); } return [$v[0], $v[1], $v[2]]; //workaround phpstan bug } diff --git a/src/permission/BanEntry.php b/src/permission/BanEntry.php index 5f235f1a9..a766a6fcc 100644 --- a/src/permission/BanEntry.php +++ b/src/permission/BanEntry.php @@ -148,7 +148,9 @@ class BanEntry{ return null; } - $parts = explode("|", trim($str)); + //we expect at most 5 parts, but accept 6 in case of an extra unexpected delimiter + //we don't want to include unexpected data into the ban reason + $parts = explode("|", trim($str), limit: 6); $entry = new BanEntry(trim(array_shift($parts))); if(count($parts) > 0){ $entry->setCreated(self::parseDate(array_shift($parts))); diff --git a/src/utils/Config.php b/src/utils/Config.php index 7b6da6389..7d0501935 100644 --- a/src/utils/Config.php +++ b/src/utils/Config.php @@ -54,6 +54,7 @@ use const CASE_LOWER; use const JSON_BIGINT_AS_STRING; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; +use const PHP_INT_MAX; use const YAML_UTF8_ENCODING; /** @@ -339,7 +340,7 @@ class Config{ } public function setNested(string $key, mixed $value) : void{ - $vars = explode(".", $key); + $vars = explode(".", $key, limit: PHP_INT_MAX); $base = array_shift($vars); if(!isset($this->config[$base])){ @@ -366,7 +367,7 @@ class Config{ return $this->nestedCache[$key]; } - $vars = explode(".", $key); + $vars = explode(".", $key, limit: PHP_INT_MAX); $base = array_shift($vars); if(isset($this->config[$base])){ $base = $this->config[$base]; @@ -390,7 +391,7 @@ class Config{ $this->nestedCache = []; $this->changed = true; - $vars = explode(".", $key); + $vars = explode(".", $key, limit: PHP_INT_MAX); $currentNode = &$this->config; while(count($vars) > 0){ @@ -495,7 +496,7 @@ class Config{ */ public static function parseList(string $content) : array{ $result = []; - foreach(explode("\n", trim(str_replace("\r\n", "\n", $content))) as $v){ + foreach(explode("\n", trim(str_replace("\r\n", "\n", $content)), limit: PHP_INT_MAX) as $v){ $v = trim($v); if($v === ""){ continue; diff --git a/src/utils/Internet.php b/src/utils/Internet.php index 89af2a77e..4b0e00f4a 100644 --- a/src/utils/Internet.php +++ b/src/utils/Internet.php @@ -60,6 +60,7 @@ use const CURLOPT_RETURNTRANSFER; use const CURLOPT_SSL_VERIFYHOST; use const CURLOPT_SSL_VERIFYPEER; use const CURLOPT_TIMEOUT_MS; +use const PHP_INT_MAX; use const SOCK_DGRAM; use const SOL_UDP; @@ -227,9 +228,10 @@ class Internet{ $rawHeaders = substr($raw, 0, $headerSize); $body = substr($raw, $headerSize); $headers = []; - foreach(explode("\r\n\r\n", $rawHeaders) as $rawHeaderGroup){ + //TODO: explore if we can set these limits lower + foreach(explode("\r\n\r\n", $rawHeaders, limit: PHP_INT_MAX) as $rawHeaderGroup){ $headerGroup = []; - foreach(explode("\r\n", $rawHeaderGroup) as $line){ + foreach(explode("\r\n", $rawHeaderGroup, limit: PHP_INT_MAX) as $line){ $nameValue = explode(":", $line, 2); if(isset($nameValue[1])){ $headerGroup[trim(strtolower($nameValue[0]))] = trim($nameValue[1]); diff --git a/src/utils/Utils.php b/src/utils/Utils.php index 2a9dd65da..046296cf4 100644 --- a/src/utils/Utils.php +++ b/src/utils/Utils.php @@ -369,7 +369,7 @@ final class Utils{ debug_zval_dump($value); $contents = ob_get_contents(); if($contents === false) throw new AssumptionFailedError("ob_get_contents() should never return false here"); - $ret = explode("\n", $contents); + $ret = explode("\n", $contents, limit: 2); ob_end_clean(); if(preg_match('/^.* refcount\\(([0-9]+)\\)\\{$/', trim($ret[0]), $m) > 0){ diff --git a/src/world/generator/FlatGeneratorOptions.php b/src/world/generator/FlatGeneratorOptions.php index 563297b00..8271ebcbf 100644 --- a/src/world/generator/FlatGeneratorOptions.php +++ b/src/world/generator/FlatGeneratorOptions.php @@ -26,10 +26,12 @@ namespace pocketmine\world\generator; use pocketmine\data\bedrock\BiomeIds; use pocketmine\item\LegacyStringToItemParser; use pocketmine\item\LegacyStringToItemParserException; +use pocketmine\world\World; use function array_map; use function explode; use function preg_match; use function preg_match_all; +use const PHP_INT_MAX; /** * @internal @@ -70,7 +72,7 @@ final class FlatGeneratorOptions{ */ public static function parseLayers(string $layers) : array{ $result = []; - $split = array_map('\trim', explode(',', $layers)); + $split = array_map('\trim', explode(',', $layers, limit: World::Y_MAX - World::Y_MIN)); $y = 0; $itemParser = LegacyStringToItemParser::getInstance(); foreach($split as $line){ @@ -96,7 +98,7 @@ final class FlatGeneratorOptions{ * @throws InvalidGeneratorOptionsException */ public static function parsePreset(string $presetString) : self{ - $preset = explode(";", $presetString); + $preset = explode(";", $presetString, limit: 4); $blocks = $preset[1] ?? ""; $biomeId = (int) ($preset[2] ?? BiomeIds::PLAINS); $optionsString = $preset[3] ?? ""; @@ -109,9 +111,10 @@ final class FlatGeneratorOptions{ $params = true; if($matches[3][$i] !== ""){ $params = []; - $p = explode(" ", $matches[3][$i]); + $p = explode(" ", $matches[3][$i], limit: PHP_INT_MAX); foreach($p as $k){ - $k = explode("=", $k); + //TODO: this should be limited to 2 parts, but 3 preserves old behaviour when given e.g. treecount=20=1 + $k = explode("=", $k, limit: 3); if(isset($k[1])){ $params[$k[0]] = $k[1]; } diff --git a/tests/phpstan/rules/ExplodeLimitRule.php b/tests/phpstan/rules/ExplodeLimitRule.php new file mode 100644 index 000000000..4e8a341ad --- /dev/null +++ b/tests/phpstan/rules/ExplodeLimitRule.php @@ -0,0 +1,92 @@ + + */ +final class ExplodeLimitRule implements Rule{ + private ReflectionProvider $reflectionProvider; + + public function __construct( + ReflectionProvider $reflectionProvider + ){ + $this->reflectionProvider = $reflectionProvider; + } + + public function getNodeType() : string{ + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope) : array{ + if(!$node->name instanceof Name){ + return []; + } + + if(!$this->reflectionProvider->hasFunction($node->name, $scope)){ + return []; + } + + $functionReflection = $this->reflectionProvider->getFunction($node->name, $scope); + + if($functionReflection->getName() !== 'explode'){ + return []; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $functionReflection->getVariants(), + $functionReflection->getNamedArgumentsVariants(), + ); + + $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); + + if($normalizedFuncCall === null){ + return []; + } + + $count = count($normalizedFuncCall->getArgs()); + if($count !== 3){ + return [ + RuleErrorBuilder::message('The $limit parameter of explode() must be set to prevent malicious client data wasting resources.') + ->identifier("pocketmine.explode.limit") + ->build() + ]; + } + + return []; + } +} diff --git a/tools/generate-bedrock-data-from-packets.php b/tools/generate-bedrock-data-from-packets.php index c48a4f017..50639f51d 100644 --- a/tools/generate-bedrock-data-from-packets.php +++ b/tools/generate-bedrock-data-from-packets.php @@ -624,7 +624,7 @@ function main(array $argv) : int{ } foreach($packets as $lineNum => $line){ - $parts = explode(':', $line); + $parts = explode(':', $line, limit: 3); if(count($parts) !== 2){ fwrite(STDERR, 'Wrong packet format at line ' . ($lineNum + 1) . ', expected read:base64 or write:base64'); return 1;