Skip to content

Commit

Permalink
Use the php tokenizer to validate converter conditions
Browse files Browse the repository at this point in the history
  • Loading branch information
guvra committed Feb 27, 2024
1 parent 8afbd75 commit 7ba44b4
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 74 deletions.
148 changes: 77 additions & 71 deletions src/Converter/ConditionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,13 @@

use RuntimeException;
use TheSeer\Tokenizer\Token;
use TheSeer\Tokenizer\TokenCollection;
use TheSeer\Tokenizer\Tokenizer;

class ConditionBuilder
{
private Tokenizer $tokenizer;

/**
* @var string[]
*/
private array $statementBlacklist = ['<?php', '<?', '?>'];

/**
* @var string[]
*/
Expand Down Expand Up @@ -49,9 +45,6 @@ public function build(string $condition): string
// Sanitize the condition
$condition = $this->sanitizeCondition($condition);

// Validate the condition
$this->validateCondition($this->removeQuotedValues($condition));

// Parse the condition and return the result
return $this->parseCondition($condition);
}
Expand All @@ -77,47 +70,6 @@ private function sanitizeCondition(string $condition): string
return $condition;
}

/**
* Validate the condition.
*
* @throws RuntimeException
*/
private function validateCondition(string $condition): void
{
// Prevent usage of "=" operator
if (preg_match('/[^=!]=[^=]/', $condition)) {
throw new RuntimeException('The operator "=" is not allowed in converter conditions.');
}

// Prevent usage of "$" character
if (preg_match('/\$/', $condition)) {
throw new RuntimeException('The character "$" is not allowed in converter conditions.');
}

// Prevent the use of some statements
foreach ($this->statementBlacklist as $statement) {
if (str_contains($condition, $statement)) {
$message = sprintf('The statement "%s" is not allowed in converter conditions.', $statement);
throw new RuntimeException($message);
}
}

// Prevent the use of static functions
if (preg_match('/::(\w+) *\(/', $condition)) {
throw new RuntimeException('Static functions are not allowed in converter conditions.');
}

// Allow only specific functions
if (preg_match_all('/(\w+) *\(/', $condition, $matches)) {
foreach ($matches[1] as $function) {
if (!$this->isFunctionAllowed($function)) {
$message = sprintf('The function "%s" is not allowed in converter conditions.', $function);
throw new RuntimeException($message);
}
}
}
}

/**
* Parse the tokens that represent the condition, and return the parsed condition.
*/
Expand All @@ -132,6 +84,8 @@ private function parseCondition(string $condition): string
foreach ($tokens as $token) {
++$index;

$this->validateToken($token, $tokens, $index, $tokenCount);

// Skip characters representing a variable
if ($this->isVariableToken($token)) {
continue;
Expand Down Expand Up @@ -163,24 +117,6 @@ private function parseCondition(string $condition): string
return $this->removePhpTags($result);
}

/**
* Remove quoted values from a variable,
* e.g. `$s = 'value'` is converted to `$s = ''`.
*/
private function removeQuotedValues(string $input): string
{
// Split the condition into PHP tokens
$tokens = $this->tokenizer->parse('<?php ' . $input . ' ?>');
$result = '';

foreach ($tokens as $token) {
// Remove quoted values
$result .= $token->getName() === 'T_CONSTANT_ENCAPSED_STRING' ? "''" : $token->getValue();
}

return $this->removePhpTags($result);
}

/**
* Remove opening and closing PHP tags from a string.
*/
Expand All @@ -190,13 +126,57 @@ private function removePhpTags(string $input): string
}

/**
* Check if the token represents a variable.
* Assert that the token is allowed.
*
* @throws RuntimeException
*/
private function isVariableToken(Token $token): bool
private function validateToken(Token $token, TokenCollection $tokens, int $index, int $tokenCount): void
{
$name = $token->getName();
if ($index > 0 && $token->getName() === 'T_OPEN_TAG') {
throw new RuntimeException('PHP opening tags are not allowed in converter conditions.');
}

return $name === 'T_OPEN_CURLY' || $name === 'T_CLOSE_CURLY' || $name === 'T_AT';
if ($index < $tokenCount - 1 && $token->getName() === 'T_CLOSE_TAG') {
throw new RuntimeException('PHP closing tags are not allowed in converter conditions.');
}

if ($token->getName() === 'T_EQUAL') {
throw new RuntimeException('The operator "=" is not allowed in converter conditions.');
}

if ($token->getName() === 'T_VARIABLE') {
throw new RuntimeException('The character "$" is not allowed in converter conditions.');
}

if ($token->getName() === 'T_OPEN_BRACKET') {
// Search for forbidden functions and static calls
$previousTokenPos = $this->getPreviousTokenPos($tokens, $index);
if ($previousTokenPos !== null) {
$previousToken = $tokens[$previousTokenPos];

if (
$previousToken->getName() === 'T_STRING' // `die()`
|| $previousToken->getName() === 'T_CONSTANT_ENCAPSED_STRING' // `'die'()`
|| $previousToken->getName() === 'T_VARIABLE' // `$func()`
) {
// Function detected, check if it is allowed
$function = $previousToken->getValue();
if (!$this->isFunctionAllowed($function)) {
$message = sprintf('The function "%s" is not allowed in converter conditions.', $function);
throw new RuntimeException($message);
}

// If the previous token is `::`, then it's a static call
$previousTokenPos = $this->getPreviousTokenPos($tokens, $previousTokenPos);
if ($previousTokenPos !== null) {
$previousToken = $tokens[$previousTokenPos];
if ($previousToken->getName() === 'T_DOUBLE_COLON') {
throw new RuntimeException('Static functions are not allowed in converter conditions.');
}
}
}
}
}
}

/**
Expand All @@ -215,4 +195,30 @@ private function isFunctionAllowed(string $function): bool

return $allowed;
}

/**
* Search for the previous non-whitespace token.
*/
private function getPreviousTokenPos(TokenCollection $tokens, int $currentIndex): ?int
{
--$currentIndex;
while ($currentIndex > 0) {
if ($tokens[$currentIndex]->getName() !== 'T_WHITESPACE') {
return $currentIndex;
}
--$currentIndex;
}

return null;
}

/**
* Check if the token represents a variable.
*/
private function isVariableToken(Token $token): bool
{
$name = $token->getName();

return $name === 'T_OPEN_CURLY' || $name === 'T_CLOSE_CURLY' || $name === 'T_AT';
}
}
36 changes: 33 additions & 3 deletions tests/unit/Converter/ConditionBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ public function testConditionBuilder(): void
$this->assertSame('return \'{{2}}\' === "@2";', $condition);
}

/**
* Assert that using an allowed function does not result in a thrown exception.
*/
public function testAllowedFunctions(): void
{
$this->expectNotToPerformAssertions();
$builder = new ConditionBuilder();
$builder->build('strpos({{email}}, "@acme.fr") !== false');
}

/**
* Assert that an exception is thrown when an empty condition is specified.
*/
Expand Down Expand Up @@ -66,6 +76,26 @@ public function testErrorOnPhpTag(): void
$builder->build('<?php {{id}} === 1 ?>');
}

/**
* Assert that an exception is thrown when the condition contains a forbidden function.
*/
public function testErrorOnForbiddenFunction(): void
{
$builder = new ConditionBuilder();
$this->expectException(RuntimeException::class);
$builder->build('usleep(1000)');
}

/**
* Assert that an exception is thrown when the condition contains a forbidden function enclosed in quotes.
*/
public function testErrorOnForbiddenStringFunction(): void
{
$builder = new ConditionBuilder();
$this->expectException(RuntimeException::class);
$builder->build('\'usleep\'(1000)');
}

/**
* Assert that an exception is thrown when the condition contains a static function call.
*/
Expand All @@ -77,12 +107,12 @@ public function testErrorOnStaticFunction(): void
}

/**
* Assert that an exception is thrown when the condition contains a forbidden function.
* Assert that an exception is thrown when the condition contains a static function call enclosed in quotes.
*/
public function testErrorOnBlacklistedFunction(): void
public function testErrorOnStringStaticFunction(): void
{
$builder = new ConditionBuilder();
$this->expectException(RuntimeException::class);
$builder->build('usleep(1000)');
$builder->build('\'ArrayHelper\'::getPath(\'id\') === 1');
}
}

0 comments on commit 7ba44b4

Please sign in to comment.