Skip to content

Commit

Permalink
added sandbox mode
Browse files Browse the repository at this point in the history
  • Loading branch information
dg committed Feb 12, 2020
1 parent 2eee071 commit 90165f2
Show file tree
Hide file tree
Showing 18 changed files with 1,001 additions and 12 deletions.
20 changes: 20 additions & 0 deletions src/Latte/Compiler/Compiler.php
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ class Compiler
/** @var array of [name => serialized value] */ /** @var array of [name => serialized value] */
private $properties = []; private $properties = [];


/** @var Policy|null */
private $policy;



/** /**
* Adds new macro with Macro flags. * Adds new macro with Macro flags.
Expand Down Expand Up @@ -204,6 +207,20 @@ public function compile(array $tokens, string $className): string
} }




/** @return static */
public function setPolicy(?Policy $policy)
{
$this->policy = $policy;
return $this;
}


public function getPolicy(): ?Policy
{
return $this->policy;
}


/** @return static */ /** @return static */
public function setContentType(string $type) public function setContentType(string $type)
{ {
Expand Down Expand Up @@ -707,6 +724,9 @@ public function expandMacro(string $name, string $args, string $modifiers = null
$hint = (($t = Helpers::getSuggestion(array_keys($this->macros), $name)) ? ", did you mean {{$t}}?" : '') $hint = (($t = Helpers::getSuggestion(array_keys($this->macros), $name)) ? ", did you mean {{$t}}?" : '')
. (in_array($this->context, [self::CONTEXT_HTML_JS, self::CONTEXT_HTML_CSS], true) ? ' (in JavaScript or CSS, try to put a space after bracket or use n:syntax=off)' : ''); . (in_array($this->context, [self::CONTEXT_HTML_JS, self::CONTEXT_HTML_CSS], true) ? ' (in JavaScript or CSS, try to put a space after bracket or use n:syntax=off)' : '');
throw new CompileException("Unknown macro {{$name}}$hint"); throw new CompileException("Unknown macro {{$name}}$hint");

} elseif ($this->policy && !$this->policy->isMacroAllowed($name)) {
throw new SecurityViolation("Macro {{$name}} is not allowed.");
} }


$modifiers = (string) $modifiers; $modifiers = (string) $modifiers;
Expand Down
114 changes: 113 additions & 1 deletion src/Latte/Compiler/PhpWriter.php
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class PhpWriter
/** @var array|null */ /** @var array|null */
private $context; private $context;


/** @var Policy|null */
private $policy;

/** @var array */ /** @var array */
private $functions = []; private $functions = [];


Expand All @@ -35,6 +38,7 @@ public static function using(MacroNode $node, Compiler $compiler = null): self
$me = new static($node->tokenizer, null, $node->context); $me = new static($node->tokenizer, null, $node->context);
$me->modifiers = &$node->modifiers; $me->modifiers = &$node->modifiers;
$me->functions = $compiler ? $compiler->getFunctions() : []; $me->functions = $compiler ? $compiler->getFunctions() : [];
$me->policy = $compiler ? $compiler->getPolicy() : null;
return $me; return $me;
} }


Expand Down Expand Up @@ -165,6 +169,7 @@ public function preprocess(MacroTokens $tokens = null): MacroTokens
$tokens = $this->removeCommentsPass($tokens); $tokens = $this->removeCommentsPass($tokens);
$tokens = $this->replaceFunctionsPass($tokens); $tokens = $this->replaceFunctionsPass($tokens);
$tokens = $this->optionalChainingPass($tokens); $tokens = $this->optionalChainingPass($tokens);
$tokens = $this->sandboxPass($tokens);
$tokens = $this->shortTernaryPass($tokens); $tokens = $this->shortTernaryPass($tokens);
$tokens = $this->inlineModifierPass($tokens); $tokens = $this->inlineModifierPass($tokens);
$tokens = $this->inOperatorPass($tokens); $tokens = $this->inOperatorPass($tokens);
Expand Down Expand Up @@ -432,6 +437,109 @@ public function inOperatorPass(MacroTokens $tokens): MacroTokens
} }




/**
* Applies sandbox policy.
*/
public function sandboxPass(MacroTokens $tokens): MacroTokens
{
static $keywords = [
'array' => 1, 'catch' => 1, 'clone' => 1, 'empty' => 1, 'for' => 1,
'foreach' => 1, 'function' => 1, 'if' => 1, 'elseif', 'isset' => 1, 'list' => 1, 'unset' => 1,
];

if (!$this->policy) {
return $tokens;
}

$startDepth = $tokens->depth;
$res = new MacroTokens;

while ($tokens->depth >= $startDepth && $tokens->nextToken()) {
$symbol = false;
if ($tokens->isCurrent('[', '(')) {
$expr = new MacroTokens(array_merge([$tokens->currentToken()], $this->sandboxPass($tokens)->tokens));

} elseif ($tokens->isCurrent($tokens::T_SYMBOL, '\\') && empty($keywords[$tokens->currentValue()])) {
$expr = new MacroTokens(array_merge([$tokens->currentToken()], $tokens->nextAll($tokens::T_SYMBOL, '\\')));
$symbol = true;
} elseif ($tokens->isCurrent($tokens::T_VARIABLE, $tokens::T_STRING)) {
$expr = new MacroTokens([$tokens->currentToken()]);
} elseif ($tokens->isCurrent('$')) {
$expr = new MacroTokens(array_merge([$tokens->currentToken()], $tokens->nextAll($tokens::T_VARIABLE, '$')));
} else {
$res->append($tokens->currentToken());
continue;
}

do {
if ($tokens->nextToken('(')) {
if ($symbol) {
$name = $expr->joinAll();
if (!$this->policy->isFunctionAllowed($name)) {
throw new SecurityViolation("Function $name() is not allowed.");
}
$symbol = false;
$expr->append('(');
} else {
$expr->prepend('$this->call(');
$expr->append(')(');
}

} elseif ($tokens->nextToken('->', '::')) {
$op = $tokens->currentValue();
if ($symbol) {
$expr->append('::class');
$symbol = false;
}
$expr->append(', ');

if ($tokens->nextToken('{')) {
$member = array_merge([$tokens->currentToken()], $this->sandboxPass($tokens)->tokens);
$expr->append('(string) ');
$expr->tokens = array_merge($expr->tokens, array_slice($member, 1, -1));
} elseif ($tokens->nextToken($tokens::T_SYMBOL)) {
$member = [$tokens->currentToken()];
$expr->append(var_export($tokens->currentValue(), true));
} elseif ($tokens->nextToken($tokens::T_VARIABLE)) {
$member = [$tokens->currentToken()];
if ($op === '::' && !$tokens->isNext('(')) {
$expr->append(var_export(substr($tokens->currentValue(), 1), true));
} else {
$expr->append($tokens->currentValue());
}
} else {
$member = $tokens->nextAll($tokens::T_VARIABLE, '$');
if ($op === '::' && !$tokens->isNext('(')) {
$expr->tokens = array_merge($expr->tokens, array_slice($member, 1));
} else {
$expr->tokens = array_merge($expr->tokens, $member);
}
}

if ($tokens->nextToken('(')) {
$expr->prepend('$this->call([');
$expr->append('])(');
} else {
$expr->prepend('$this->prop(');
$expr->append(')' . $op);
$expr->tokens = array_merge($expr->tokens, $member);
}

} elseif ($tokens->nextToken('[', '{')) {
$expr->tokens = array_merge($expr->tokens, [$tokens->currentToken()], $this->sandboxPass($tokens)->tokens);

} else {
break;
}
} while (true);

$res->tokens = array_merge($res->tokens, $expr->tokens);
}

return $res;
}


/** /**
* Process inline filters ($var|filter) * Process inline filters ($var|filter)
*/ */
Expand Down Expand Up @@ -531,7 +639,11 @@ public function modifierPass(MacroTokens $tokens, $var, bool $isContent = false)
$res->prepend('LR\Filters::safeUrl('); $res->prepend('LR\Filters::safeUrl(');
$inside = true; $inside = true;
} else { } else {
$name = strtolower($tokens->currentValue()); $name = $tokens->currentValue();
if ($this->policy && !$this->policy->isFilterAllowed($name)) {
throw new SecurityViolation("Filter |$name is not allowed.");
}
$name = strtolower($name);
$res->prepend($isContent $res->prepend($isContent
? '$this->filters->filterContent(' . var_export($name, true) . ', $_fi, ' ? '$this->filters->filterContent(' . var_export($name, true) . ', $_fi, '
: '($this->filters->' . $name . ')(' : '($this->filters->' . $name . ')('
Expand Down
29 changes: 26 additions & 3 deletions src/Latte/Engine.php
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ class Engine
/** @var bool */ /** @var bool */
private $strictTypes = false; private $strictTypes = false;


/** @var Policy|null */
private $policy;

/** @var bool */
private $sandboxed = false;



public function __construct() public function __construct()
{ {
Expand Down Expand Up @@ -104,7 +110,7 @@ public function createTemplate(string $name, array $params = []): Runtime\Templa
$this->loadTemplate($name); $this->loadTemplate($name);
} }
$this->providers['_fn'] = $this->functions; $this->providers['_fn'] = $this->functions;
return new $class($this, $params, $this->filters, $this->providers, $name); return new $class($this, $params, $this->filters, $this->providers, $name, $this->sandboxed ? $this->policy : null);
} }




Expand All @@ -128,11 +134,12 @@ public function compile(string $name): string
$code = $this->getCompiler() $code = $this->getCompiler()
->setContentType($this->contentType) ->setContentType($this->contentType)
->setFunctions(array_keys((array) $this->functions)) ->setFunctions(array_keys((array) $this->functions))
->setPolicy($this->sandboxed ? $this->policy : null)
->compile($tokens, $this->getTemplateClass($name)); ->compile($tokens, $this->getTemplateClass($name));


} catch (\Exception $e) { } catch (\Exception $e) {
if (!$e instanceof CompileException) { if (!$e instanceof CompileException) {
$e = new CompileException("Thrown exception '{$e->getMessage()}'", 0, $e); $e = new CompileException($e instanceof SecurityViolation ? $e->getMessage() : "Thrown exception '{$e->getMessage()}'", 0, $e);
} }
$line = isset($tokens) ? $this->getCompiler()->getLine() : $this->getParser()->getLine(); $line = isset($tokens) ? $this->getCompiler()->getLine() : $this->getParser()->getLine();
throw $e->setSource($source, $line, $name); throw $e->setSource($source, $line, $name);
Expand Down Expand Up @@ -232,7 +239,7 @@ public function getCacheFile(string $name): string


public function getTemplateClass(string $name): string public function getTemplateClass(string $name): string
{ {
$key = $this->getLoader()->getUniqueId($name) . "\00" . self::VERSION; $key = $this->getLoader()->getUniqueId($name) . "\00" . self::VERSION . "\00" . (int) $this->sandboxed;
return 'Template' . substr(md5($key), 0, 10); return 'Template' . substr(md5($key), 0, 10);
} }


Expand Down Expand Up @@ -310,6 +317,22 @@ public function getProviders(): array
} }




/** @return static */
public function setPolicy(?Policy $policy)
{
$this->policy = $policy;
return $this;
}


/** @return static */
public function setSandboxMode(bool $on = true)
{
$this->sandboxed = $on;
return $this;
}


/** @return static */ /** @return static */
public function setContentType(string $type) public function setContentType(string $type)
{ {
Expand Down
5 changes: 4 additions & 1 deletion src/Latte/Macros/CoreMacros.php
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static function install(Latte\Compiler $compiler): void
$me->addMacro('capture', [$me, 'macroCapture'], [$me, 'macroCaptureEnd']); $me->addMacro('capture', [$me, 'macroCapture'], [$me, 'macroCaptureEnd']);
$me->addMacro('spaceless', [$me, 'macroSpaceless'], [$me, 'macroSpaceless']); $me->addMacro('spaceless', [$me, 'macroSpaceless'], [$me, 'macroSpaceless']);
$me->addMacro('include', [$me, 'macroInclude']); $me->addMacro('include', [$me, 'macroInclude']);
$me->addMacro('sandbox', [$me, 'macroInclude']);
$me->addMacro('contentType', [$me, 'macroContentType'], null, null, self::ALLOWED_IN_HEAD); $me->addMacro('contentType', [$me, 'macroContentType'], null, null, self::ALLOWED_IN_HEAD);
$me->addMacro('php', [$me, 'macroExpr']); $me->addMacro('php', [$me, 'macroExpr']);


Expand Down Expand Up @@ -212,6 +213,7 @@ public function macroTranslate(MacroNode $node, PhpWriter $writer)


/** /**
* {include "file" [,] [params]} * {include "file" [,] [params]}
* {sandbox "file" [,] [params]}
*/ */
public function macroInclude(MacroNode $node, PhpWriter $writer) public function macroInclude(MacroNode $node, PhpWriter $writer)
{ {
Expand All @@ -225,7 +227,8 @@ public function macroInclude(MacroNode $node, PhpWriter $writer)
} }
return $writer->write( return $writer->write(
'/* line ' . $node->startLine . ' */ '/* line ' . $node->startLine . ' */
$this->createTemplate(%node.word, %node.array? + $this->params, "include")->renderToContentType(%raw);', $this->createTemplate(%node.word, %node.array? + $this->params, %var)->renderToContentType(%raw);',
$node->name,
$node->modifiers $node->modifiers
? $writer->write('function ($s, $type) { $_fi = new LR\FilterInfo($type); return %modifyContent($s); }') ? $writer->write('function ($s, $type) { $_fi = new LR\FilterInfo($type); return %modifyContent($s); }')
: var_export($noEscape ? null : implode($node->context), true) : var_export($noEscape ? null : implode($node->context), true)
Expand Down
24 changes: 24 additions & 0 deletions src/Latte/Policy.php
Original file line number Original file line Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/**
* This file is part of the Latte (https://latte.nette.org)
* Copyright (c) 2008 David Grudl (https://davidgrudl.com)
*/

declare(strict_types=1);

namespace Latte;


interface Policy
{
function isMacroAllowed(string $macro): bool;

function isFilterAllowed(string $filter): bool;

function isFunctionAllowed(string $function): bool;

function isMethodAllowed(string $class, string $method): bool;

function isPropertyAllowed(string $class, string $property): bool;
}
50 changes: 48 additions & 2 deletions src/Latte/Runtime/Template.php
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@


use Latte; use Latte;
use Latte\Engine; use Latte\Engine;
use Latte\Policy;




/** /**
Expand Down Expand Up @@ -50,19 +51,23 @@ class Template
/** @var string */ /** @var string */
private $name; private $name;


/** @var Policy|null */
private $policy;

/** @var Template|null @internal */ /** @var Template|null @internal */
private $referringTemplate; private $referringTemplate;


/** @var string|null @internal */ /** @var string|null @internal */
private $referenceType; private $referenceType;




public function __construct(Engine $engine, array $params, FilterExecutor $filters, array $providers, string $name) public function __construct(Engine $engine, array $params, FilterExecutor $filters, array $providers, string $name, ?Policy $policy)
{ {
$this->engine = $engine; $this->engine = $engine;
$this->params = $params; $this->params = $params;
$this->filters = $filters; $this->filters = $filters;
$this->name = $name; $this->name = $name;
$this->policy = $policy;
$this->global = (object) $providers; $this->global = (object) $providers;
foreach ($this->blocks as $nm => $method) { foreach ($this->blocks as $nm => $method) {
$this->blockQueue[$nm][] = [$this, $method]; $this->blockQueue[$nm][] = [$this, $method];
Expand Down Expand Up @@ -187,7 +192,11 @@ public function render(): void
public function createTemplate(string $name, array $params, string $referenceType): self public function createTemplate(string $name, array $params, string $referenceType): self
{ {
$name = $this->engine->getLoader()->getReferredName($name, $this->name); $name = $this->engine->getLoader()->getReferredName($name, $this->name);
$child = $this->engine->createTemplate($name, $params); if ($referenceType === 'sandbox') {
$child = (clone $this->engine)->setSandboxMode()->createTemplate($name, $params);
} else {
$child = $this->engine->createTemplate($name, $params);
}
$child->referringTemplate = $this; $child->referringTemplate = $this;
$child->referenceType = $referenceType; $child->referenceType = $referenceType;
$child->global = $this->global; $child->global = $this->global;
Expand Down Expand Up @@ -303,4 +312,41 @@ public function capture(callable $function): string
$this->global->coreCaptured = false; $this->global->coreCaptured = false;
} }
} }


/** @internal */
public function call($callable)
{
if (is_string($callable)) {
$parts = explode('::', $callable);
$allowed = count($parts) === 1
? $this->policy->isFunctionAllowed($parts[0])
: $this->policy->isMethodAllowed(...$parts);
} elseif (is_array($callable)) {
$allowed = $this->policy->isMethodAllowed(is_object($callable[0]) ? get_class($callable[0]) : $callable[0], $callable[1]);
} elseif (is_object($callable)) {
$allowed = $callable instanceof \Closure
? true
: $this->policy->isMethodAllowed(get_class($callable), '__invoke');
} else {
$allowed = false;
}

if (!$allowed) {
is_callable($callable, false, $text);
throw new Latte\SecurityViolation("Calling $text() is not allowed.");
}
return $callable;
}


/** @internal */
public function prop($obj, $prop)
{
$class = is_object($obj) ? get_class($obj) : $obj;
if (is_string($class) && !$this->policy->isPropertyAllowed($class, $prop)) {
throw new Latte\SecurityViolation("Access to '$prop' property on a $class object is not allowed.");
}
return $obj;
}
} }
Loading

0 comments on commit 90165f2

Please sign in to comment.