Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix bypassing function and class restrictions #2

Merged
merged 9 commits into from
Feb 8, 2024
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,28 @@ CODE;
$runtime = new \PSX\Sandbox\Runtime('my_code');
$runtime->set('my_service', 'foo');
$response = $runtime->run($code);
```

### Advanced configuration
Configurations are set by passing an instance of `\PSX\Sandbox\SecurityManagerConfiguration`
```php
<?php

$config = new \PSX\Sandbox\SecurityManagerConfiguration(
preventGlobalNameSpacePollution: true
);
$securityManager = new \PSX\Sandbox\SecurityManager($securityManagerConfig);
$parser = new \PSX\Sandbox\Parser($securityManager);

$runtime = new \PSX\Sandbox\Runtime('my_code', $parser);
$runtime->set('my_service', 'foo');
$response = $runtime->run( '<php? return $my_service;' );
```
* preventGlobalNameSpacePollution (bool): This will prevent creating functions and constants in the global name space.
* allowedNamespace (null|string): Restricts any namespaced code to be the same or a sub-namespace of the value.

## Requirements
* PHP 8.0+

## Installation
Install with composer `composer require psx/sandbox`
44 changes: 43 additions & 1 deletion src/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,17 @@ protected function pStmt_ClassConst(Stmt\ClassConst $node)

protected function pStmt_Function(Stmt\Function_ $node)
{
$this->securityManager->addAllowedFunction((string) $node->name);
$this->securityManager->defineFunction((string)$node->name);

return parent::pStmt_Function($node);
}

protected function pConst(\PhpParser\Node\Const_ $node)
{
$this->securityManager->checkDefineConstant();
return parent::pConst($node);
}

protected function pStmt_Declare(Stmt\Declare_ $node)
{
throw new SecurityException('Declare is not allowed');
Expand Down Expand Up @@ -175,4 +181,40 @@ protected function pClassCommon(Stmt\Class_ $node, $afterClassToken)
{
throw new SecurityException('Class is not allowed');
}

protected function pStmt_Namespace(Stmt\Namespace_ $node)
{
$this->securityManager->setCurrentNamespace($node->name !== null ? (string)$node->name : null);

return parent::pStmt_Namespace( $node );
}

protected function pStmt_Use(Stmt\Use_ $node)
{
foreach ($node->uses as $use) {
if ($node->type === Stmt\Use_::TYPE_NORMAL) {
$this->securityManager->addClassAlias((string)$use->name, (string)($use->alias ?? $use->name));
}
elseif ($node->type === Stmt\Use_::TYPE_FUNCTION) {
$this->securityManager->addFunctionAlias((string)$use->name, (string)($use->alias ?? $use->name));
}
}

return parent::pStmt_Use( $node );
}

protected function pStmt_GroupUse(Stmt\GroupUse $node)
{
foreach ($node->uses as $use) {
if ($node->type === Stmt\Use_::TYPE_NORMAL) {
$this->securityManager->addClassAlias((string)$use->name, (string)($use->alias ?? $use->name));
}
elseif ($node->type === Stmt\Use_::TYPE_FUNCTION) {
$this->securityManager->addFunctionAlias((string)$use->name, (string)($use->alias ?? $use->name));
}
}

return parent::pStmt_GroupUse( $node );
}

}
164 changes: 157 additions & 7 deletions src/SecurityManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@ class SecurityManager
'addslashes',
'addcslashes',
'rtrim',
'str_contains',
'str_starts_with',
'str_ends_with',
'str_decrement',
'str_increment',
'str_replace',
'str_ireplace',
'str_repeat',
Expand Down Expand Up @@ -483,14 +488,32 @@ class SecurityManager
'Swift_Message',
];

private array $functionAliases = [];
private array $classAliases = [];

private null|string $currentNamespace = null;
private SecurityManagerConfiguration $config;

public function __construct(?SecurityManagerConfiguration $config = null)
{
$this->config = $config ?? new SecurityManagerConfiguration();
}


public function setAllowedFunctions(array $allowedFunctions)
{
$this->allowedFunctions = $allowedFunctions;
}

public function addAllowedFunction(string $functionName)
public function addAllowedFunction(string $functionName): void
{
$this->allowedFunctions[] = $functionName;
if ($this->currentNamespace !== null) {
$functionName = "{$this->currentNamespace}\\{$functionName}";
}

if (!\in_array($functionName, $this->allowedFunctions, true)) {
$this->allowedFunctions[] = $functionName;
}
}

public function setAllowedClasses(array $allowedClasses)
Expand All @@ -503,34 +526,80 @@ public function addAllowedClass(string $className)
$this->allowedClasses[] = $className;
}

/**
* @throws SecurityException
*/
public function setCurrentNamespace(?string $currentNamespace): void
{
if ($currentNamespace !== null) {
$currentNamespace = \ltrim($currentNamespace, '\\');

if (
$this->config->allowedNamespace() !== null
&& !str_starts_with($currentNamespace, \ltrim($this->config->allowedNamespace(), '\\'))
) {
throw new SecurityException("Namespace {$currentNamespace} is outside of allowed namespace {$this->config->allowedNamespace()}" );
}
}

$this->currentNamespace = !empty($currentNamespace) ? $currentNamespace : null;
}

public function currentNamespace(): null|string
{
return $this->currentNamespace;
}

public function addFunctionAlias(string $function, string $alias): void
{
$this->functionAliases[$alias] = $function;
}

public function addClassAlias(string $class, string $alias): void
{
$this->classAliases[$alias] = $class;
}

/**
* @throws SecurityException
*/
public function checkFunctionCall(string $functionName, array $arguments = [])
{
if (isset($this->functionAliases[$functionName])) {
$functionName = $this->functionAliases[$functionName];
}

$functionName = $this->fullyQualifyNamespacedFunction($functionName);

$functionName = ltrim($functionName, '\\');

if (!in_array($functionName, $this->allowedFunctions)) {
throw new SecurityException('Call to a not allowed function ' . $functionName);
}

if ( $functionName === 'define' )
{
$this->checkDefineDefine();
return;
}

// check specific function arguments
if ($functionName === 'array_map') {
$callable = $this->getArgumentAt($arguments, 0);
$callable = $this->getArgumentAt($functionName, $arguments, 0);
if ($callable instanceof Node\Arg) {
$this->checkCallable($callable);
} else {
throw new SecurityException('array_map missing callable at position 0');
}
} elseif ($functionName === 'iterator_apply' || $functionName === 'array_walk' || $functionName === 'array_walk_recursive' || $functionName === 'array_reduce' || $functionName === 'array_filter') {
$callable = $this->getArgumentAt($arguments, 1);
$callable = $this->getArgumentAt($functionName, $arguments, 1);
if ($callable instanceof Node\Arg) {
$this->checkCallable($callable);
} else {
throw new SecurityException($functionName . ' missing callable at position 1');
}
} elseif ($functionName === 'usort' || $functionName === 'uasort' || $functionName === 'uksort') {
$callable = $this->getArgumentAt($arguments, 1);
$callable = $this->getArgumentAt($functionName, $arguments, 1);
if ($callable instanceof Node\Arg) {
$this->checkCallable($callable);
} else {
Expand All @@ -539,11 +608,44 @@ public function checkFunctionCall(string $functionName, array $arguments = [])
}
}

private function fullyQualifyNamespacedFunction(string $functionName): string
{
// check \<function> <= explicit call to global function
if (\preg_match('/^\\\\[\w]+$/', $functionName)) {
return $functionName;
}

// check <function>
if (!\str_contains($functionName, '\\')) {
if ($this->currentNamespace === null) {
return $functionName;
}

// Check if the function has been created/allowed in the current namespace else use global
$_functionName = "{$this->currentNamespace()}\\{$functionName}";
return \in_array($_functionName, $this->allowedFunctions, true) ? $_functionName : $functionName;
}
// check namespace\<function>
elseif ($this->currentNamespace !== null && \str_starts_with($functionName, 'namespace\\')) {
return "{$this->currentNamespace()}\\" . \substr($functionName, \strlen('namespace\\'));
}
// check \foo\<function> or foo\<function>
elseif (\preg_match_all('/^(\\\\?[\w\\\\]+)\\\\(\w+)$/', $functionName, $matches)) {
return \ltrim($matches[1][0], '\\') . "\\{$matches[2][0]}";
}

return $functionName;
}

/**
* @throws SecurityException
*/
public function checkNewCall(string $className)
{
if (isset($this->classAliases[$className])) {
$className = $this->classAliases[$className];
}

$className = ltrim($className, '\\');

if (!in_array($className, $this->allowedClasses)) {
Expand All @@ -565,12 +667,60 @@ private function checkCallable(Node\Arg $callable)
}
}

private function getArgumentAt(array $nodes, int $pos): ?Node\Arg
/**
* @throws SecurityException
*/
public function defineFunction(string $functionName): void
{
$nodes = array_filter($nodes, function($node){
$this->checkDefineFunction();

$this->addAllowedFunction($functionName);
}

/**
* @throws SecurityException
*/
public function checkDefineFunction(): void
{
if ($this->config->preventGlobalNameSpacePollution() && $this->currentNamespace === null) {
throw new SecurityException('Defining functions in global namespace is not allowed');
}
}

/**
* @throws SecurityException
*/
public function checkDefineConstant(): void
{
if ($this->config->preventGlobalNameSpacePollution() && $this->currentNamespace === null) {
throw new SecurityException('Defining constants in global namespace is not allowed');
}
}

public function checkDefineDefine(): void
{
if ($this->config->preventGlobalNameSpacePollution()) {
throw new SecurityException('Defining constants in global namespace is not allowed');
}
}

private function getArgumentAt(string $functionName, array $nodes, int $pos): ?Node\Arg
{
$nodes = array_filter($nodes, function ($node) {
return $node instanceof Node\Arg;
});

/** @var callable-string $functionName */
$reflection = new \ReflectionFunction($functionName);
$name = $reflection->getParameters()[$pos]->getName();

/** @var Node\Arg $node */
foreach ($nodes as $node) {
if ((string)$node->name === $name) {
return $node;
}
}

return $nodes[$pos] ?? null;
}
}
61 changes: 61 additions & 0 deletions src/SecurityManagerConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php


namespace PSX\Sandbox;


class SecurityManagerConfiguration
{
/**
* @param bool $preventGlobalNameSpacePollution This will prevent creating functions and constanta in the global name space.
* @param string|null $allowedNamespace Restricts any namespaced code to be the same or a sub-namespace of the value.
*/
public function __construct(
private bool $preventGlobalNameSpacePollution = false,
private ?string $allowedNamespace = null,
) {
$this->setAllowedNamespace($allowedNamespace);
$this->setPreventGlobalNameSpacePollution($preventGlobalNameSpacePollution);
}

public static function fromArray(array $options): self
{
$self = new self();

if (\array_key_exists('allowedNamespace', $options)) {
$self->setAllowedNamespace($options['allowedNamespace']);
}

if (\array_key_exists('preventGlobalNameSpacePollution', $options)) {
$self->setPreventGlobalNameSpacePollution($options['preventGlobalNameSpacePollution']);
}

return $self;
}

/**
* @psalm-mutation-free
*/
public function preventGlobalNameSpacePollution(): bool
{
return $this->preventGlobalNameSpacePollution;
}

public function setPreventGlobalNameSpacePollution(bool $preventGlobalNameSpacePollution): void
{
$this->preventGlobalNameSpacePollution = $preventGlobalNameSpacePollution;
}

/**
* @psalm-mutation-free
*/
public function allowedNamespace(): ?string
{
return $this->allowedNamespace;
}

public function setAllowedNamespace(?string $allowedNamespace): void
{
$this->allowedNamespace = $allowedNamespace;
}
}
Loading
Loading