Skip to content
/ yaml Public

Commit 5a351ff

Browse files
[Yaml] Bound collection-alias resolution in the parser
Track the number of resolved collection aliases (arrays, `stdClass`, unwrapped `TaggedValue`) in the shared `ParserState`, with a default limit of 128 — following the SnakeYAML model. Scalar aliases remain unrestricted since they cannot drive exponential growth. Crafted YAML documents with recursive aliases pointing at collections that themselves contain aliases ("Billion Laughs") otherwise expand exponentially during resolution. Also adds `Yaml::PARSE_EXCEPTION_ON_ALIAS` to reject all aliases when parsing untrusted input.
1 parent b02ba66 commit 5a351ff

7 files changed

Lines changed: 168 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ CHANGELOG
55
---
66

77
* Add a `$maxNestingLevel` argument to `Parser::__construct()`, `Yaml::parse()` and `Yaml::parseFile()` to bound recursion depth (default 128)
8+
* Add a `$maxAliasesForCollections` argument to `Parser::__construct()`, `Yaml::parse()` and `Yaml::parseFile()` to bound alias expansion of collection values (default 128)
9+
* Add `Yaml::PARSE_EXCEPTION_ON_ALIAS` to reject YAML aliases while parsing untrusted input
810
* Add new `lint:yaml dirname --exclude=/dirname/foo.yaml --exclude=/dirname/bar.yaml`
911
option to exclude one or more specific files from multiple file list
1012
* Allow negatable for the parse tags option with `--no-parse-tags`

Inline.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,8 @@ private static function evaluateScalar(ParserState $state, string $scalar, int $
606606
throw new ParseException(sprintf('Reference "%s" does not exist.', $value), self::$parsedLineNumber + 1, $value, self::$parsedFilename);
607607
}
608608

609+
$state->countAlias($references[$value], self::$parsedLineNumber + 1, null, self::$parsedFilename);
610+
609611
return $references[$value];
610612
}
611613

Parser.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Parser
2727
public const BLOCK_SCALAR_HEADER_PATTERN = '(?P<separator>\||>)(?P<modifiers>\+|\-|\d+|\+\d+|\-\d+|\d+\+|\d+\-)?(?P<comments> +#.*)?';
2828
public const REFERENCE_PATTERN = '#^&(?P<ref>[^ ]++) *+(?P<value>.*)#u';
2929
public const DEFAULT_MAX_NESTING_LEVEL = 128;
30+
public const DEFAULT_MAX_ALIASES_FOR_COLLECTIONS = 128;
3031

3132
private $filename;
3233
private $offset = 0;
@@ -41,13 +42,18 @@ class Parser
4142
private $refsBeingParsed = [];
4243
private $state;
4344

44-
public function __construct(int $maxNestingLevel = self::DEFAULT_MAX_NESTING_LEVEL)
45+
public function __construct(int $maxNestingLevel = self::DEFAULT_MAX_NESTING_LEVEL, int $maxAliasesForCollections = self::DEFAULT_MAX_ALIASES_FOR_COLLECTIONS)
4546
{
4647
if ($maxNestingLevel < 1) {
4748
throw new \InvalidArgumentException('The maximum nesting depth must be greater than 0.');
4849
}
4950

51+
if ($maxAliasesForCollections < 0) {
52+
throw new \InvalidArgumentException('The maximum number of collection aliases must be greater than or equal to 0.');
53+
}
54+
5055
$this->getState()->maxNestingLevel = $maxNestingLevel;
56+
$this->getState()->maxAliasesForCollections = $maxAliasesForCollections;
5157
}
5258

5359
/**
@@ -98,6 +104,7 @@ public function parse(string $value, int $flags = 0)
98104
$this->refs = [];
99105
$state = $this->getState();
100106
$state->reset();
107+
$state->aliasesEnabled = 0 === (Yaml::PARSE_EXCEPTION_ON_ALIAS & $flags);
101108

102109
$mbEncoding = null;
103110

@@ -274,6 +281,8 @@ private function doParse(string $value, int $flags)
274281

275282
$refValue = $this->refs[$refName];
276283

284+
$this->getState()->countAlias($refValue, $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
285+
277286
if (Yaml::PARSE_OBJECT_FOR_MAP & $flags && $refValue instanceof \stdClass) {
278287
$refValue = (array) $refValue;
279288
}
@@ -760,6 +769,8 @@ private function parseValue(string $value, int $flags, string $context)
760769
throw new ParseException(sprintf('Reference "%s" does not exist.', $value), $this->currentLineNb + 1, $this->currentLine, $this->filename);
761770
}
762771

772+
$this->getState()->countAlias($this->refs[$value], $this->getRealCurrentLineNb() + 1, $this->currentLine, $this->filename);
773+
763774
return $this->refs[$value];
764775
}
765776

ParserState.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\Yaml;
1313

1414
use Symfony\Component\Yaml\Exception\ParseException;
15+
use Symfony\Component\Yaml\Tag\TaggedValue;
1516

1617
/**
1718
* @internal
@@ -20,10 +21,15 @@ final class ParserState
2021
{
2122
public $maxNestingLevel = Parser::DEFAULT_MAX_NESTING_LEVEL;
2223
public $currentNestingLevel = 0;
24+
public $maxAliasesForCollections = Parser::DEFAULT_MAX_ALIASES_FOR_COLLECTIONS;
25+
public $collectionAliasCount = 0;
26+
public $aliasesEnabled = true;
2327

2428
public function reset(): void
2529
{
2630
$this->currentNestingLevel = 0;
31+
$this->collectionAliasCount = 0;
32+
$this->aliasesEnabled = true;
2733
}
2834

2935
public function enterNestingLevel(int $line, ?string $snippet, ?string $filename): void
@@ -41,4 +47,26 @@ public function leaveNestingLevel(): void
4147
--$this->currentNestingLevel;
4248
}
4349
}
50+
51+
/**
52+
* @param mixed $refValue
53+
*/
54+
public function countAlias($refValue, int $line, ?string $snippet, ?string $filename): void
55+
{
56+
if (!$this->aliasesEnabled) {
57+
throw new ParseException('Aliases are disabled.', $line, $snippet, $filename);
58+
}
59+
60+
if ($refValue instanceof TaggedValue) {
61+
$refValue = $refValue->getValue();
62+
}
63+
64+
if (!\is_array($refValue) && !$refValue instanceof \stdClass) {
65+
return;
66+
}
67+
68+
if (++$this->collectionAliasCount > $this->maxAliasesForCollections) {
69+
throw new ParseException(sprintf('Maximum number of collection aliases (%d) exceeded. This limit can be increased via the Parser constructor.', $this->maxAliasesForCollections), $line, $snippet, $filename);
70+
}
71+
}
4472
}

Tests/ParserTest.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2967,6 +2967,96 @@ public function testParseRejectsDocumentsThatExceedTheDefaultNestingDepth()
29672967
$this->parser->parse($yaml);
29682968
}
29692969

2970+
public function testParseRejectsDocumentsThatExceedTheConfiguredCollectionAliasLimit()
2971+
{
2972+
$parser = new Parser(Parser::DEFAULT_MAX_NESTING_LEVEL, 5);
2973+
2974+
$this->expectException(ParseException::class);
2975+
$this->expectExceptionMessage('Maximum number of collection aliases');
2976+
2977+
$yaml = <<<YAML
2978+
a0: &a0 [foo]
2979+
a1: &a1 [*a0, *a0, *a0]
2980+
payload: [*a1, *a1, *a1]
2981+
YAML;
2982+
2983+
$parser->parse($yaml);
2984+
}
2985+
2986+
public function testParseCountsTaggedValueCollectionAliases()
2987+
{
2988+
$parser = new Parser(Parser::DEFAULT_MAX_NESTING_LEVEL, 2);
2989+
2990+
$this->expectException(ParseException::class);
2991+
$this->expectExceptionMessage('Maximum number of collection aliases');
2992+
2993+
$yaml = <<<YAML
2994+
a: &a !my_tag [foo, bar]
2995+
b: *a
2996+
c: *a
2997+
d: *a
2998+
YAML;
2999+
3000+
$parser->parse($yaml, Yaml::PARSE_CUSTOM_TAGS);
3001+
}
3002+
3003+
public function testParseAcceptsScalarAliasesWithoutCountingThem()
3004+
{
3005+
$parser = new Parser(Parser::DEFAULT_MAX_NESTING_LEVEL, 1);
3006+
3007+
$yaml = <<<YAML
3008+
anchor: &val scalar_value
3009+
a: *val
3010+
b: *val
3011+
c: *val
3012+
d: *val
3013+
YAML;
3014+
3015+
$result = $parser->parse($yaml);
3016+
3017+
$this->assertSame('scalar_value', $result['a']);
3018+
$this->assertSame('scalar_value', $result['d']);
3019+
}
3020+
3021+
public function testParseAcceptsLargeCollectionAliasedOnce()
3022+
{
3023+
$items = implode(', ', array_map(function ($i) { return "item$i"; }, range(1, 500)));
3024+
$yaml = <<<YAML
3025+
defaults: &defaults [$items]
3026+
a: *defaults
3027+
YAML;
3028+
3029+
$result = $this->parser->parse($yaml);
3030+
3031+
$this->assertCount(500, $result['a']);
3032+
}
3033+
3034+
public function testParseRejectsBlockAliasesWhenDisabled()
3035+
{
3036+
$this->expectException(ParseException::class);
3037+
$this->expectExceptionMessage('Aliases are disabled');
3038+
3039+
$yaml = <<<YAML
3040+
defaults: &defaults [foo, bar]
3041+
a: *defaults
3042+
YAML;
3043+
3044+
$this->parser->parse($yaml, Yaml::PARSE_EXCEPTION_ON_ALIAS);
3045+
}
3046+
3047+
public function testParseRejectsInlineAliasesWhenDisabled()
3048+
{
3049+
$this->expectException(ParseException::class);
3050+
$this->expectExceptionMessage('Aliases are disabled');
3051+
3052+
$yaml = <<<YAML
3053+
defaults: &defaults [foo, bar]
3054+
a: [*defaults]
3055+
YAML;
3056+
3057+
$this->parser->parse($yaml, Yaml::PARSE_EXCEPTION_ON_ALIAS);
3058+
}
3059+
29703060
private function assertSameData($expected, $actual)
29713061
{
29723062
$this->assertEquals($expected, $actual);

Tests/YamlTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\Yaml\Exception\ParseException;
16+
use Symfony\Component\Yaml\Parser;
1617
use Symfony\Component\Yaml\Yaml;
1718

1819
class YamlTest extends TestCase
@@ -48,4 +49,24 @@ public function testParseAllowsConfiguringTheMaximumNestingLevel()
4849

4950
Yaml::parse($yaml, 0, 2);
5051
}
52+
53+
public function testParseFileAllowsConfiguringTheMaximumCollectionAliasCount()
54+
{
55+
$file = tempnam(sys_get_temp_dir(), 'yaml_');
56+
57+
file_put_contents($file, <<<YAML
58+
defaults: &defaults [foo, bar]
59+
copy: *defaults
60+
YAML
61+
);
62+
63+
$this->expectException(ParseException::class);
64+
$this->expectExceptionMessage('Maximum number of collection aliases (0) exceeded.');
65+
66+
try {
67+
Yaml::parseFile($file, 0, Parser::DEFAULT_MAX_NESTING_LEVEL, 0);
68+
} finally {
69+
unlink($file);
70+
}
71+
}
5172
}

Yaml.php

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class Yaml
3434
public const PARSE_CUSTOM_TAGS = 512;
3535
public const DUMP_EMPTY_ARRAY_AS_SEQUENCE = 1024;
3636
public const DUMP_NULL_AS_TILDE = 2048;
37+
public const PARSE_EXCEPTION_ON_ALIAS = 8192;
3738

3839
/**
3940
* Parses a YAML file into a PHP value.
@@ -43,17 +44,18 @@ class Yaml
4344
* $array = Yaml::parseFile('config.yml');
4445
* print_r($array);
4546
*
46-
* @param string $filename The path to the YAML file to be parsed
47-
* @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior
48-
* @param int $maxNestingLevel The maximum nesting depth for nested YAML blocks
47+
* @param string $filename The path to the YAML file to be parsed
48+
* @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior
49+
* @param int $maxNestingLevel The maximum nesting depth for nested YAML blocks
50+
* @param int $maxAliasesForCollections The maximum number of collection aliases to resolve
4951
*
5052
* @return mixed
5153
*
5254
* @throws ParseException If the file could not be read or the YAML is not valid
5355
*/
54-
public static function parseFile(string $filename, int $flags = 0, int $maxNestingLevel = Parser::DEFAULT_MAX_NESTING_LEVEL)
56+
public static function parseFile(string $filename, int $flags = 0, int $maxNestingLevel = Parser::DEFAULT_MAX_NESTING_LEVEL, int $maxAliasesForCollections = Parser::DEFAULT_MAX_ALIASES_FOR_COLLECTIONS)
5557
{
56-
$yaml = new Parser($maxNestingLevel);
58+
$yaml = new Parser($maxNestingLevel, $maxAliasesForCollections);
5759

5860
return $yaml->parseFile($filename, $flags);
5961
}
@@ -67,17 +69,18 @@ public static function parseFile(string $filename, int $flags = 0, int $maxNesti
6769
* print_r($array);
6870
* </code>
6971
*
70-
* @param string $input A string containing YAML
71-
* @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior
72-
* @param int $maxNestingLevel The maximum nesting depth for nested YAML blocks
72+
* @param string $input A string containing YAML
73+
* @param int $flags A bit field of PARSE_* constants to customize the YAML parser behavior
74+
* @param int $maxNestingLevel The maximum nesting depth for nested YAML blocks
75+
* @param int $maxAliasesForCollections The maximum number of collection aliases to resolve
7376
*
7477
* @return mixed
7578
*
7679
* @throws ParseException If the YAML is not valid
7780
*/
78-
public static function parse(string $input, int $flags = 0, int $maxNestingLevel = Parser::DEFAULT_MAX_NESTING_LEVEL)
81+
public static function parse(string $input, int $flags = 0, int $maxNestingLevel = Parser::DEFAULT_MAX_NESTING_LEVEL, int $maxAliasesForCollections = Parser::DEFAULT_MAX_ALIASES_FOR_COLLECTIONS)
7982
{
80-
$yaml = new Parser($maxNestingLevel);
83+
$yaml = new Parser($maxNestingLevel, $maxAliasesForCollections);
8184

8285
return $yaml->parse($input, $flags);
8386
}

0 commit comments

Comments
 (0)