feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
- Base: wintercms/winter branch 1.2 (full framework) - Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS - Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication - Partials: hero (offline-first), features, modes (offline/nube toggle), screenshots, pricing (3 planes), comparison, FAQ, CTA - Plugin VivesPOS.Site with ContactForm - Dockerfile: PHP 8.2 Apache, port 80, healthcheck - Added winter/wn-pages, blog, sitemap, seo plugins - Active theme set to vivespos
This commit is contained in:
40
modules/system/twig/Engine.php
Normal file
40
modules/system/twig/Engine.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use System\Twig\Loader as TwigLoader;
|
||||
use Twig\Environment as TwigEnvironment;
|
||||
use Illuminate\Contracts\View\Engine as EngineInterface;
|
||||
|
||||
/**
|
||||
* View engine used by the system, used for converting .htm files to twig.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Engine implements EngineInterface
|
||||
{
|
||||
/**
|
||||
* @var TwigEnvironment
|
||||
*/
|
||||
protected $environment;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(TwigEnvironment $environment)
|
||||
{
|
||||
$this->environment = $environment;
|
||||
}
|
||||
|
||||
public function get($path, array $vars = [])
|
||||
{
|
||||
$previousAllow = TwigLoader::$allowInclude;
|
||||
|
||||
TwigLoader::$allowInclude = true;
|
||||
|
||||
$template = $this->environment->load($path);
|
||||
|
||||
TwigLoader::$allowInclude = $previousAllow;
|
||||
|
||||
return $template->render($vars);
|
||||
}
|
||||
}
|
||||
175
modules/system/twig/Extension.php
Normal file
175
modules/system/twig/Extension.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use Url;
|
||||
use System\Classes\ImageResizer;
|
||||
use System\Classes\MediaLibrary;
|
||||
use System\Classes\MarkupManager;
|
||||
use Twig\TwigFilter as TwigSimpleFilter;
|
||||
use Twig\TwigFunction as TwigSimpleFunction;
|
||||
use Twig\Extension\AbstractExtension as TwigExtension;
|
||||
|
||||
/**
|
||||
* The System Twig extension class implements common Twig functions and filters.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Extension extends TwigExtension
|
||||
{
|
||||
/**
|
||||
* @var \System\Classes\MarkupManager A reference to the markup manager instance.
|
||||
*/
|
||||
protected $markupManager;
|
||||
|
||||
/**
|
||||
* Creates the extension instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->markupManager = MarkupManager::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of functions to add to the existing list.
|
||||
*
|
||||
* @return array An array of functions
|
||||
*/
|
||||
public function getFunctions()
|
||||
{
|
||||
$functions = [
|
||||
new TwigSimpleFunction('app', [$this, 'appFilter']),
|
||||
new TwigSimpleFunction('media', [$this, 'mediaFilter']),
|
||||
new TwigSimpleFunction('asset', [$this, 'assetFilter']),
|
||||
new TwigSimpleFunction('resize', [$this, 'resizeFilter']),
|
||||
new TwigSimpleFunction('imageWidth', [$this, 'imageWidthFilter']),
|
||||
new TwigSimpleFunction('imageHeight', [$this, 'imageHeightFilter']),
|
||||
];
|
||||
|
||||
/*
|
||||
* Include extensions provided by plugins
|
||||
*/
|
||||
$functions = $this->markupManager->makeTwigFunctions($functions);
|
||||
|
||||
return $functions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of filters this extensions provides.
|
||||
*
|
||||
* @return array An array of filters
|
||||
*/
|
||||
public function getFilters()
|
||||
{
|
||||
$filters = [
|
||||
new TwigSimpleFilter('app', [$this, 'appFilter']),
|
||||
new TwigSimpleFilter('media', [$this, 'mediaFilter']),
|
||||
new TwigSimpleFilter('asset', [$this, 'assetFilter']),
|
||||
new TwigSimpleFilter('resize', [$this, 'resizeFilter']),
|
||||
new TwigSimpleFilter('imageWidth', [$this, 'imageWidthFilter']),
|
||||
new TwigSimpleFilter('imageHeight', [$this, 'imageHeightFilter']),
|
||||
];
|
||||
|
||||
/*
|
||||
* Include extensions provided by plugins
|
||||
*/
|
||||
$filters = $this->markupManager->makeTwigFilters($filters);
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of token parsers this extensions provides.
|
||||
*
|
||||
* @return array An array of token parsers
|
||||
*/
|
||||
public function getTokenParsers()
|
||||
{
|
||||
$parsers = [
|
||||
new SpacelessTokenParser,
|
||||
new FilterTokenParser,
|
||||
];
|
||||
|
||||
/*
|
||||
* Include extensions provided by plugins
|
||||
*/
|
||||
$parsers = $this->markupManager->makeTwigTokenParsers($parsers);
|
||||
|
||||
return $parsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts supplied URL to one relative to the website root.
|
||||
* @param mixed $url Specifies the application-relative URL
|
||||
* @return string
|
||||
*/
|
||||
public function appFilter($url)
|
||||
{
|
||||
return Url::to($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts supplied file to a URL relative to the media library.
|
||||
* @param string $file Specifies the media-relative file
|
||||
* @return string
|
||||
*/
|
||||
public function mediaFilter($file)
|
||||
{
|
||||
return MediaLibrary::url($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts supplied file to a URL relative to the `app.asset_url` config.
|
||||
* @param string $file Specifies the asset-relative file
|
||||
* @return string
|
||||
*/
|
||||
public function assetFilter($file)
|
||||
{
|
||||
return Url::asset($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts supplied input into a URL that will return the desired resized image
|
||||
*
|
||||
* @param mixed $image Supported values below:
|
||||
* ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
|
||||
* instance of Winter\Storm\Database\Attach\File,
|
||||
* string containing URL or path accessible to the application's filesystem manager
|
||||
* @param integer|bool|null $width Desired width of the resized image
|
||||
* @param integer|bool|null $height Desired height of the resized image
|
||||
* @param array|null $options Array of options to pass to the resizer
|
||||
* @throws Exception If the provided image was unable to be processed
|
||||
* @return string
|
||||
*/
|
||||
public function resizeFilter($image, $width = null, $height = null, $options = [])
|
||||
{
|
||||
return ImageResizer::filterGetUrl($image, $width, $height, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the width in pixels of the provided image source
|
||||
*
|
||||
* @param mixed $image Supported values below:
|
||||
* ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
|
||||
* instance of Winter\Storm\Database\Attach\File,
|
||||
* string containing URL or path accessible to the application's filesystem manager
|
||||
* @return int
|
||||
*/
|
||||
public function imageWidthFilter($image)
|
||||
{
|
||||
return @ImageResizer::filterGetDimensions($image)['width'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the height in pixels of the provided image source
|
||||
*
|
||||
* @param mixed $image Supported values below:
|
||||
* ['disk' => Illuminate\Filesystem\FilesystemAdapter, 'path' => string, 'source' => string, 'fileModel' => FileModel|void],
|
||||
* instance of Winter\Storm\Database\Attach\File,
|
||||
* string containing URL or path accessible to the application's filesystem manager
|
||||
* @return int
|
||||
*/
|
||||
public function imageHeightFilter($image)
|
||||
{
|
||||
return @ImageResizer::filterGetDimensions($image)['height'];
|
||||
}
|
||||
}
|
||||
49
modules/system/twig/FilterTokenParser.php
Normal file
49
modules/system/twig/FilterTokenParser.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use Twig\Node\BlockNode;
|
||||
use Twig\Node\Expression\BlockReferenceExpression;
|
||||
use Twig\Node\Expression\ConstantExpression;
|
||||
use Twig\Node\PrintNode;
|
||||
use Twig\Token;
|
||||
use Twig\TokenParser\AbstractTokenParser;
|
||||
|
||||
/**
|
||||
* Filters a section of a template by applying filters.
|
||||
*
|
||||
* {% filter upper %}
|
||||
* This text becomes uppercase
|
||||
* {% endfilter %}
|
||||
*
|
||||
* @deprecated since Twig 2.9, to be removed in 3.0 (use the "apply" tag instead)
|
||||
*/
|
||||
final class FilterTokenParser extends AbstractTokenParser
|
||||
{
|
||||
public function parse(Token $token)
|
||||
{
|
||||
@trigger_error('The "filter" tag is deprecated since Twig 2.9, use the "apply" tag instead.', E_USER_DEPRECATED);
|
||||
|
||||
$name = $this->parser->getVarName();
|
||||
$ref = new BlockReferenceExpression(new ConstantExpression($name, $token->getLine()), null, $token->getLine(), $this->getTag());
|
||||
|
||||
$filter = $this->parser->getExpressionParser()->parseFilterExpressionRaw($ref, $this->getTag());
|
||||
$this->parser->getStream()->expect(/* Token::BLOCK_END_TYPE */ 3);
|
||||
|
||||
$body = $this->parser->subparse([$this, 'decideBlockEnd'], true);
|
||||
$this->parser->getStream()->expect(/* Token::BLOCK_END_TYPE */ 3);
|
||||
|
||||
$block = new BlockNode($name, $body, $token->getLine());
|
||||
$this->parser->setBlock($name, $block);
|
||||
|
||||
return new PrintNode($filter, $token->getLine(), $this->getTag());
|
||||
}
|
||||
|
||||
public function decideBlockEnd(Token $token)
|
||||
{
|
||||
return $token->test('endfilter');
|
||||
}
|
||||
|
||||
public function getTag()
|
||||
{
|
||||
return 'filter';
|
||||
}
|
||||
}
|
||||
70
modules/system/twig/GetAttrAdjuster.php
Normal file
70
modules/system/twig/GetAttrAdjuster.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use System\Twig\Node\GetAttrNode;
|
||||
use Twig\Environment;
|
||||
use Twig\Node\Expression\GetAttrExpression;
|
||||
use Twig\Node\Node;
|
||||
use Twig\NodeVisitor\NodeVisitorInterface;
|
||||
|
||||
/**
|
||||
* GetAttrAdjuster swaps every attribute-access node (GetAttrExpression) for the custom
|
||||
* GetAttrNode, so that method calls in sandbox mode route through
|
||||
* SecurityPolicy::castMethodObjectToSafeObject. This is what enables the SafeCollection /
|
||||
* SafePaginator protections. Only exact GetAttrExpression instances are swapped (not the
|
||||
* replacement GetAttrNode), so the traversal never re-wraps.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
*/
|
||||
class GetAttrAdjuster implements NodeVisitorInterface
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function enterNode(Node $node, Environment $env): Node
|
||||
{
|
||||
if (get_class($node) !== GetAttrExpression::class) {
|
||||
return $node;
|
||||
}
|
||||
|
||||
$nodes = [
|
||||
'node' => $node->getNode('node'),
|
||||
'attribute' => $node->getNode('attribute'),
|
||||
];
|
||||
|
||||
if ($node->hasNode('arguments')) {
|
||||
$nodes['arguments'] = $node->getNode('arguments');
|
||||
}
|
||||
|
||||
$isDefinedTest = $node->isDefinedTestEnabled();
|
||||
|
||||
$attributes = [
|
||||
'type' => $node->getAttribute('type'),
|
||||
'ignore_strict_check' => $node->getAttribute('ignore_strict_check'),
|
||||
'optimizable' => $node->getAttribute('optimizable'),
|
||||
];
|
||||
|
||||
$getAttrNode = new GetAttrNode($nodes, $attributes, $node->getTemplateLine());
|
||||
|
||||
if ($isDefinedTest) {
|
||||
$getAttrNode->enableDefinedTest();
|
||||
}
|
||||
|
||||
return $getAttrNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function leaveNode(Node $node, Environment $env): ?Node
|
||||
{
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPriority()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
103
modules/system/twig/Loader.php
Normal file
103
modules/system/twig/Loader.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use App;
|
||||
use Exception;
|
||||
use File;
|
||||
use InvalidArgumentException;
|
||||
use Twig\Source as TwigSource;
|
||||
use Twig\Error\LoaderError;
|
||||
use Twig\Loader\LoaderInterface as TwigLoaderInterface;
|
||||
use Winter\Storm\Support\Str;
|
||||
|
||||
/**
|
||||
* This class implements a Twig template loader for the core system and backend.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Loader implements TwigLoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var bool Allow any local file
|
||||
*/
|
||||
public static $allowInclude = false;
|
||||
|
||||
/**
|
||||
* @var array Cache
|
||||
*/
|
||||
protected $cache = [];
|
||||
|
||||
/**
|
||||
* Gets the path of a view file.
|
||||
*/
|
||||
protected function findTemplate(string $name): string
|
||||
{
|
||||
$finder = App::make('view')->getFinder();
|
||||
|
||||
if (isset($this->cache[$name])) {
|
||||
return $this->cache[$name];
|
||||
}
|
||||
|
||||
if (static::$allowInclude === true && File::isFile($name)) {
|
||||
return $this->cache[$name] = $name;
|
||||
}
|
||||
|
||||
try {
|
||||
$path = $finder->find($name);
|
||||
} catch (InvalidArgumentException $ex) {
|
||||
if (Str::contains($ex->getMessage(), 'not found')) {
|
||||
throw new LoaderError($ex->getMessage());
|
||||
}
|
||||
throw $ex;
|
||||
}
|
||||
|
||||
return $this->cache[$name] = $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Twig content string.
|
||||
* This step is cached internally by Twig.
|
||||
*/
|
||||
public function getSourceContext(string $name): TwigSource
|
||||
{
|
||||
return new TwigSource(File::get($this->findTemplate($name)), $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Twig cache key.
|
||||
*/
|
||||
public function getCacheKey(string $name): string
|
||||
{
|
||||
return $this->findTemplate($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the content is fresh.
|
||||
*/
|
||||
public function isFresh(string $name, int $time): bool
|
||||
{
|
||||
return File::lastModified($this->findTemplate($name)) <= $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name of the loaded template.
|
||||
*/
|
||||
public function getFilename(string $name): string
|
||||
{
|
||||
return $this->findTemplate($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the template exists.
|
||||
*/
|
||||
public function exists(string $name): bool
|
||||
{
|
||||
try {
|
||||
$this->findTemplate($name);
|
||||
return true;
|
||||
}
|
||||
catch (Exception $exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
modules/system/twig/MailPartialNode.php
Normal file
59
modules/system/twig/MailPartialNode.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use Twig\Node\Node as TwigNode;
|
||||
use Twig\Compiler as TwigCompiler;
|
||||
|
||||
/**
|
||||
* Represents a partial node
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class MailPartialNode extends TwigNode
|
||||
{
|
||||
public function __construct(TwigNode $nodes, $paramNames, $body, $lineno, $tag = 'partial')
|
||||
{
|
||||
$nodes = ['nodes' => $nodes];
|
||||
|
||||
if ($body) {
|
||||
$nodes['body'] = $body;
|
||||
}
|
||||
|
||||
parent::__construct($nodes, ['names' => $paramNames], $lineno, $tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the node to PHP.
|
||||
*
|
||||
* @param TwigCompiler $compiler A TwigCompiler instance
|
||||
*/
|
||||
public function compile(TwigCompiler $compiler)
|
||||
{
|
||||
$compiler->addDebugInfo($this);
|
||||
|
||||
$compiler->write("\$context['__system_partial_params'] = [];\n");
|
||||
|
||||
if ($this->hasNode('body')) {
|
||||
$compiler
|
||||
->addDebugInfo($this)
|
||||
->write('ob_start();')
|
||||
->subcompile($this->getNode('body'))
|
||||
->write("\$context['__system_partial_params']['body'] = ob_get_clean();");
|
||||
}
|
||||
|
||||
for ($i = 1; $i < count($this->getNode('nodes')); $i++) {
|
||||
$compiler->write("\$context['__system_partial_params']['".$this->getAttribute('names')[$i-1]."'] = ");
|
||||
$compiler->subcompile($this->getNode('nodes')->getNode($i));
|
||||
$compiler->write(";\n");
|
||||
}
|
||||
|
||||
$compiler
|
||||
->write("echo \System\Classes\MailManager::instance()->renderPartial(")
|
||||
->subcompile($this->getNode('nodes')->getNode(0))
|
||||
->write(", \$context['__system_partial_params']")
|
||||
->write(");\n")
|
||||
;
|
||||
|
||||
$compiler->write("unset(\$context['__system_partial_params']);\n");
|
||||
}
|
||||
}
|
||||
94
modules/system/twig/MailPartialTokenParser.php
Normal file
94
modules/system/twig/MailPartialTokenParser.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use Twig\Node\Node as TwigNode;
|
||||
use Twig\Token as TwigToken;
|
||||
use Twig\TokenParser\AbstractTokenParser as TwigTokenParser;
|
||||
use Twig\Error\SyntaxError as TwigErrorSyntax;
|
||||
|
||||
/**
|
||||
* Parser for the `{% partial %}` Twig tag.
|
||||
*
|
||||
* {% partial "sidebar" %}
|
||||
*
|
||||
* {% partial "sidebar" name='John' %}
|
||||
*
|
||||
* {% partial "sidebar" name='John', year=2013 %}
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class MailPartialTokenParser extends TwigTokenParser
|
||||
{
|
||||
/**
|
||||
* Parses a token and returns a node.
|
||||
*
|
||||
* @param TwigToken $token A TwigToken instance
|
||||
* @return TwigNode A TwigNode instance
|
||||
*/
|
||||
public function parse(TwigToken $token)
|
||||
{
|
||||
$lineno = $token->getLine();
|
||||
$stream = $this->parser->getStream();
|
||||
|
||||
$name = $this->parser->getExpressionParser()->parseExpression();
|
||||
$paramNames = [];
|
||||
$nodes = [$name];
|
||||
$hasBody = false;
|
||||
$body = null;
|
||||
|
||||
$end = false;
|
||||
while (!$end) {
|
||||
$current = $stream->next();
|
||||
|
||||
if (
|
||||
$current->test(TwigToken::NAME_TYPE, 'body') &&
|
||||
!$stream->test(TwigToken::OPERATOR_TYPE, '=')
|
||||
) {
|
||||
$hasBody = true;
|
||||
$current = $stream->next();
|
||||
}
|
||||
|
||||
switch ($current->getType()) {
|
||||
case TwigToken::NAME_TYPE:
|
||||
$paramNames[] = $current->getValue();
|
||||
$stream->expect(TwigToken::OPERATOR_TYPE, '=');
|
||||
$nodes[] = $this->parser->getExpressionParser()->parseExpression();
|
||||
break;
|
||||
|
||||
case TwigToken::BLOCK_END_TYPE:
|
||||
$end = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new TwigErrorSyntax(
|
||||
sprintf('Invalid syntax in the partial tag. Line %s', $lineno),
|
||||
$stream->getCurrent()->getLine(),
|
||||
$stream->getSourceContext()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasBody) {
|
||||
$body = $this->parser->subparse([$this, 'decidePartialEnd'], true);
|
||||
$stream->expect(TwigToken::BLOCK_END_TYPE);
|
||||
}
|
||||
|
||||
return new MailPartialNode(new TwigNode($nodes), $paramNames, $body, $token->getLine(), $this->getTag());
|
||||
}
|
||||
|
||||
public function decidePartialEnd(TwigToken $token)
|
||||
{
|
||||
return $token->test('endpartial');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tag name associated with this token parser.
|
||||
*
|
||||
* @return string The tag name
|
||||
*/
|
||||
public function getTag()
|
||||
{
|
||||
return 'partial';
|
||||
}
|
||||
}
|
||||
491
modules/system/twig/SecurityPolicy.php
Normal file
491
modules/system/twig/SecurityPolicy.php
Normal file
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
|
||||
namespace System\Twig;
|
||||
|
||||
use Cms\Classes\Controller;
|
||||
use Cms\Classes\Theme;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\ConnectionResolverInterface;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Eloquent\Model as DbModel;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Pagination\AbstractCursorPaginator;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Session\SessionManager;
|
||||
use Illuminate\Support\Enumerable;
|
||||
use System\Twig\SecurityPolicy\SafeCollection;
|
||||
use System\Twig\SecurityPolicy\SafePaginator;
|
||||
use Twig\Markup;
|
||||
use Twig\Sandbox\SecurityNotAllowedFunctionError;
|
||||
use Twig\Sandbox\SecurityNotAllowedMethodError;
|
||||
use Twig\Sandbox\SecurityNotAllowedPropertyError;
|
||||
use Twig\Sandbox\SecurityPolicyInterface;
|
||||
use Twig\Template;
|
||||
use Winter\Storm\Halcyon\Builder as HalcyonBuilder;
|
||||
use Winter\Storm\Halcyon\Datasource\DatasourceInterface;
|
||||
use Winter\Storm\Halcyon\Model as HalcyonModel;
|
||||
|
||||
/**
|
||||
* SecurityPolicy globally blocks accessibility of certain methods and properties.
|
||||
*
|
||||
* The policy is a blocklist, but it models the real PHP forwarding behaviour of the
|
||||
* database layer via $blockedForwarders: because `Model::__call` transparently forwards
|
||||
* to the Eloquent Builder, which forwards to the Query Builder, a method blocked on the
|
||||
* Query Builder is also blocked when reached through a Model, Eloquent Builder or Relation.
|
||||
* This is what makes the blocklist complete instead of a game of whack-a-mole.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
* @author Alexey Bobkov, Samuel Georges, Luke Towers, Ben Thomson
|
||||
*/
|
||||
final class SecurityPolicy implements SecurityPolicyInterface
|
||||
{
|
||||
/**
|
||||
* @var array<string, string[]> List of forbidden methods, grouped by applicable instance.
|
||||
*/
|
||||
protected $blockedMethods = [
|
||||
'*' => [
|
||||
// Prevent accessing Twig itself
|
||||
'getTwig',
|
||||
|
||||
// Prevent extensions of any objects
|
||||
'addDynamicMethod',
|
||||
'addDynamicProperty',
|
||||
'extendClassWith',
|
||||
'implementClassWith',
|
||||
'getClassExtension',
|
||||
'extendableSet',
|
||||
|
||||
// Prevent directly invoking the magic/extension call machinery
|
||||
'extend',
|
||||
'extendableCall',
|
||||
'extendableCallStatic',
|
||||
'extendableExtendCallback',
|
||||
'extensionExtendCallback',
|
||||
'__call',
|
||||
'__callStatic',
|
||||
'__invoke',
|
||||
|
||||
// Prevent Laravel Macroable injection
|
||||
'macro',
|
||||
'mixin',
|
||||
|
||||
// Prevent binding to, or firing, events
|
||||
'bindEvent',
|
||||
'bindEventOnce',
|
||||
'fireEvent',
|
||||
'fireSystemEvent',
|
||||
],
|
||||
|
||||
// Prevent some controller methods. The controller is a fixed, known object; these
|
||||
// methods run nested page cycles, render arbitrary partials, or read files.
|
||||
Controller::class => [
|
||||
'runPage',
|
||||
'renderPage',
|
||||
'getLoader',
|
||||
'run',
|
||||
'combineAssets',
|
||||
'renderPartial',
|
||||
'renderContent',
|
||||
],
|
||||
|
||||
// Prevent model data modification. Methods that forward to the query layer
|
||||
// (increment, decrement, touch, getConnection, ...) are covered transitively by
|
||||
// $blockedForwarders; only methods that physically live on the Model are listed here.
|
||||
DbModel::class => [
|
||||
'fill',
|
||||
'forceFill',
|
||||
'setAttribute',
|
||||
'setRawAttributes',
|
||||
'save',
|
||||
'saveQuietly',
|
||||
'saveOrFail',
|
||||
'push',
|
||||
'pushQuietly',
|
||||
'update',
|
||||
'updateQuietly',
|
||||
'updateOrFail',
|
||||
'delete',
|
||||
'deleteQuietly',
|
||||
'deleteOrFail',
|
||||
'forceDelete',
|
||||
'destroy',
|
||||
'forceDestroy',
|
||||
'restore',
|
||||
'restoreQuietly',
|
||||
'getQuery',
|
||||
// Re-pointing the table/connection would allow reading arbitrary tables/databases
|
||||
'setTable',
|
||||
'setConnection',
|
||||
'on',
|
||||
'onWriteConnection',
|
||||
'setKeyName',
|
||||
'setKeyType',
|
||||
'setIncrementing',
|
||||
'setPerPage',
|
||||
'setDateFormat',
|
||||
'offsetSet',
|
||||
'offsetUnset',
|
||||
// Disabling mass-assignment / event protection, or executing callbacks
|
||||
'unguard',
|
||||
'reguard',
|
||||
'unguarded',
|
||||
'withoutEvents',
|
||||
'withoutTouching',
|
||||
'withoutTouchingOn',
|
||||
// getConnectionResolver returns the DatabaseManager, whose __call proxies raw SQL
|
||||
'getConnectionResolver',
|
||||
'setConnectionResolver',
|
||||
'unsetConnectionResolver',
|
||||
'flushEventListeners',
|
||||
'getEventDispatcher',
|
||||
'setEventDispatcher',
|
||||
'unsetEventDispatcher',
|
||||
],
|
||||
|
||||
EloquentBuilder::class => [
|
||||
'forceDelete',
|
||||
'create',
|
||||
'createQuietly',
|
||||
'forceCreate',
|
||||
'forceCreateQuietly',
|
||||
'firstOrCreate',
|
||||
'createOrFirst',
|
||||
'updateOrCreate',
|
||||
'incrementOrCreate',
|
||||
'fillAndInsert',
|
||||
'fillAndInsertOrIgnore',
|
||||
'fillAndInsertGetId',
|
||||
'touch',
|
||||
'update',
|
||||
'delete',
|
||||
'upsert',
|
||||
],
|
||||
|
||||
QueryBuilder::class => [
|
||||
'insert',
|
||||
'insertOrIgnore',
|
||||
'insertGetId',
|
||||
'insertUsing',
|
||||
'insertOrIgnoreUsing',
|
||||
'update',
|
||||
'updateFrom',
|
||||
'updateOrInsert',
|
||||
'upsert',
|
||||
'delete',
|
||||
'truncate',
|
||||
'increment',
|
||||
'incrementEach',
|
||||
'incrementQuietly',
|
||||
'decrement',
|
||||
'decrementEach',
|
||||
'decrementQuietly',
|
||||
// Re-pointing the table
|
||||
'from',
|
||||
'fromRaw',
|
||||
'fromSub',
|
||||
// Connection / raw SQL
|
||||
'getConnection',
|
||||
'toRawSql',
|
||||
'selectRaw',
|
||||
'whereRaw',
|
||||
'orWhereRaw',
|
||||
'havingRaw',
|
||||
'orHavingRaw',
|
||||
'orderByRaw',
|
||||
'groupByRaw',
|
||||
'joinSub',
|
||||
'leftJoinSub',
|
||||
'rightJoinSub',
|
||||
'crossJoinSub',
|
||||
'raw',
|
||||
'rawValue',
|
||||
'dd',
|
||||
'dump',
|
||||
'ddRawSql',
|
||||
// callable-typed executors (string callables would execute)
|
||||
'when',
|
||||
'unless',
|
||||
'each',
|
||||
'eachById',
|
||||
'chunk',
|
||||
'chunkById',
|
||||
'chunkByIdDesc',
|
||||
'chunkMap',
|
||||
'tap',
|
||||
'pipe',
|
||||
],
|
||||
|
||||
Relation::class => [
|
||||
'attach',
|
||||
'detach',
|
||||
'sync',
|
||||
'syncWithPivotValues',
|
||||
'syncWithoutDetaching',
|
||||
'toggle',
|
||||
'updateExistingPivot',
|
||||
'save',
|
||||
'saveQuietly',
|
||||
'saveMany',
|
||||
'saveManyQuietly',
|
||||
'create',
|
||||
'createQuietly',
|
||||
'createMany',
|
||||
'createManyQuietly',
|
||||
'forceCreate',
|
||||
'forceCreateQuietly',
|
||||
'push',
|
||||
'update',
|
||||
'updateOrCreate',
|
||||
'firstOrCreate',
|
||||
'firstOrNew',
|
||||
'createOrFirst',
|
||||
'delete',
|
||||
'forceDelete',
|
||||
'associate',
|
||||
'dissociate',
|
||||
'make',
|
||||
'makeMany',
|
||||
],
|
||||
|
||||
HalcyonModel::class => [
|
||||
'fill',
|
||||
'setAttribute',
|
||||
'setRawAttributes',
|
||||
'setSettingsAttribute',
|
||||
'setFileNameAttribute',
|
||||
'save',
|
||||
'push',
|
||||
'update',
|
||||
'delete',
|
||||
'forceDelete',
|
||||
'getQuery',
|
||||
'getDatasource',
|
||||
],
|
||||
|
||||
HalcyonBuilder::class => [
|
||||
'insert',
|
||||
'update',
|
||||
'delete',
|
||||
'forceDelete',
|
||||
'truncate',
|
||||
],
|
||||
|
||||
DatasourceInterface::class => [
|
||||
'insert',
|
||||
'update',
|
||||
'delete',
|
||||
'forceDelete',
|
||||
'write',
|
||||
'usingSource',
|
||||
'pushToSource',
|
||||
'removeFromSource',
|
||||
'select',
|
||||
'selectOne',
|
||||
],
|
||||
|
||||
Theme::class => [
|
||||
'setDirName',
|
||||
'registerHalcyonDatasource',
|
||||
'getDatasource',
|
||||
'writeConfig',
|
||||
'removeCustomData',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string> Maps a class to the class its __call forwards to, so the
|
||||
* sandbox enforces the destination's blocklist for a method reached through the source.
|
||||
* The chain is walked transitively (Model -> Eloquent Builder -> Query Builder).
|
||||
*/
|
||||
protected $blockedForwarders = [
|
||||
EloquentBuilder::class => QueryBuilder::class,
|
||||
DbModel::class => EloquentBuilder::class,
|
||||
Relation::class => EloquentBuilder::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string[]> List of allowed methods, grouped by applicable instance.
|
||||
* An empty list denies every method on that type (deny-all lock).
|
||||
*/
|
||||
protected $allowedMethods = [
|
||||
SessionManager::class => [
|
||||
'put',
|
||||
'get',
|
||||
'has',
|
||||
'forget',
|
||||
'flush',
|
||||
'pull',
|
||||
],
|
||||
// Locked down entirely: no template legitimately calls raw database or event objects.
|
||||
ConnectionInterface::class => [],
|
||||
ConnectionResolverInterface::class => [],
|
||||
Dispatcher::class => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string[]> List of forbidden properties, grouped by applicable instance.
|
||||
*/
|
||||
protected $blockedProperties = [
|
||||
Theme::class => [
|
||||
'datasource',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[] Twig functions that are not allowed (info-disclosure surface).
|
||||
*/
|
||||
protected $blockedFunctions = [
|
||||
'source',
|
||||
'constant',
|
||||
'enum_cases',
|
||||
];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$properties = [
|
||||
'blockedMethods',
|
||||
'allowedMethods',
|
||||
'blockedProperties',
|
||||
];
|
||||
|
||||
foreach ($properties as $property) {
|
||||
foreach ($this->{$property} as $type => $values) {
|
||||
$this->{$property}[$type] = array_map('strtolower', $values);
|
||||
}
|
||||
}
|
||||
|
||||
$this->blockedFunctions = array_map('strtolower', $this->blockedFunctions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the provided arguments against this security policy
|
||||
*
|
||||
* @param array $tags Array of tags to be checked against the policy ['tag', 'tag2', 'etc']
|
||||
* @param array $filters Array of filters to be checked against the policy ['filter', 'filter2', 'etc']
|
||||
* @param array $functions Array of funtions to be checked against the policy ['function', 'function2', 'etc']
|
||||
* @throws SecurityNotAllowedFunctionError if a given function is not allowed
|
||||
*/
|
||||
public function checkSecurity($tags, $filters, $functions): void
|
||||
{
|
||||
foreach ($functions as $function) {
|
||||
if (in_array(strtolower($function), $this->blockedFunctions)) {
|
||||
throw new SecurityNotAllowedFunctionError(sprintf('Function "%s" is not allowed.', $function), $function);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given property is permitted to be accessed on a given object
|
||||
*
|
||||
* @param object $obj
|
||||
* @param string $property
|
||||
* @throws SecurityNotAllowedPropertyError
|
||||
*/
|
||||
public function checkPropertyAllowed($obj, $property): void
|
||||
{
|
||||
// No need to check Twig internal objects
|
||||
if ($obj instanceof Template || $obj instanceof Markup) {
|
||||
return;
|
||||
}
|
||||
|
||||
$property = strtolower($property);
|
||||
|
||||
foreach ($this->blockedProperties as $type => $properties) {
|
||||
if ($obj instanceof $type && in_array($property, $properties)) {
|
||||
$class = get_class($obj);
|
||||
throw new SecurityNotAllowedPropertyError(sprintf('Getting "%s" property in a "%s" object is blocked.', $property, $class), $class, $property);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given method is allowed to be called on a given object
|
||||
*
|
||||
* @param object $obj
|
||||
* @param string $method
|
||||
* @throws SecurityNotAllowedMethodError
|
||||
*/
|
||||
public function checkMethodAllowed($obj, $method): void
|
||||
{
|
||||
// No need to check Twig internal objects
|
||||
if ($obj instanceof Template || $obj instanceof Markup) {
|
||||
return;
|
||||
}
|
||||
|
||||
$method = strtolower($method);
|
||||
|
||||
if (in_array($method, $this->blockedMethods['*'])) {
|
||||
$this->throwMethodError($obj, $method);
|
||||
}
|
||||
|
||||
foreach ($this->allowedMethods as $type => $methods) {
|
||||
if ($obj instanceof $type && !in_array($method, $methods)) {
|
||||
$this->throwMethodError($obj, $method);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->blockedMethods as $type => $methods) {
|
||||
if ($type === '*') {
|
||||
continue;
|
||||
}
|
||||
if ($obj instanceof $type && in_array($method, $methods)) {
|
||||
$this->throwMethodError($obj, $method);
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce the blocklists of any class this object's __call forwards to, transitively.
|
||||
// This closes the forwarding escape (e.g. `model.increment()` reaching the Query Builder).
|
||||
foreach ($this->blockedForwarders as $sourceClass => $targetClass) {
|
||||
if (!($obj instanceof $sourceClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cursor = $targetClass;
|
||||
$seen = [];
|
||||
while ($cursor !== null && !isset($seen[$cursor])) {
|
||||
$seen[$cursor] = true;
|
||||
if (in_array($method, $this->blockedMethods[$cursor] ?? [])) {
|
||||
$this->throwMethodError($obj, $method);
|
||||
}
|
||||
$cursor = $this->blockedForwarders[$cursor] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts an object to a sandbox-safe proxy before a method is called on it in a template.
|
||||
* Used by the custom GetAttrNode to neutralise callable-passthrough on collections and
|
||||
* paginators (their higher-order methods would otherwise execute arbitrary callables).
|
||||
*
|
||||
* @param mixed $object
|
||||
* @return mixed
|
||||
*/
|
||||
public function castMethodObjectToSafeObject($object)
|
||||
{
|
||||
if ($object instanceof Enumerable) {
|
||||
return new SafeCollection($object);
|
||||
}
|
||||
|
||||
if ($object instanceof AbstractPaginator || $object instanceof AbstractCursorPaginator) {
|
||||
return new SafePaginator($object);
|
||||
}
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $obj
|
||||
* @param string $method
|
||||
* @throws SecurityNotAllowedMethodError
|
||||
*/
|
||||
protected function throwMethodError($obj, $method): void
|
||||
{
|
||||
$class = get_class($obj);
|
||||
throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is blocked.', $method, $class), $class, $method);
|
||||
}
|
||||
}
|
||||
38
modules/system/twig/SpacelessNode.php
Normal file
38
modules/system/twig/SpacelessNode.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use Twig\Compiler;
|
||||
use Twig\Node\Node;
|
||||
use Twig\Node\NodeOutputInterface;
|
||||
|
||||
/**
|
||||
* Represents a spaceless node.
|
||||
*
|
||||
* It removes spaces between HTML tags.
|
||||
*
|
||||
* Removed in Twig 3.0, but retained in Winter CMS for compatibility.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class SpacelessNode extends Node implements NodeOutputInterface
|
||||
{
|
||||
public function __construct(Node $body, int $lineno, string $tag = 'spaceless')
|
||||
{
|
||||
parent::__construct(['body' => $body], [], $lineno, $tag);
|
||||
}
|
||||
|
||||
public function compile(Compiler $compiler)
|
||||
{
|
||||
$compiler
|
||||
->addDebugInfo($this)
|
||||
;
|
||||
if ($compiler->getEnvironment()->isDebug()) {
|
||||
$compiler->write("ob_start();\n");
|
||||
} else {
|
||||
$compiler->write("ob_start(function () { return ''; });\n");
|
||||
}
|
||||
$compiler
|
||||
->subcompile($this->getNode('body'))
|
||||
->write("echo trim(preg_replace('/>\s+</', '><', ob_get_clean()));\n")
|
||||
;
|
||||
}
|
||||
}
|
||||
29
modules/system/twig/SpacelessTokenParser.php
Normal file
29
modules/system/twig/SpacelessTokenParser.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php namespace System\Twig;
|
||||
|
||||
use Twig\Token;
|
||||
use Twig\TokenParser\AbstractTokenParser;
|
||||
|
||||
final class SpacelessTokenParser extends AbstractTokenParser
|
||||
{
|
||||
public function parse(Token $token)
|
||||
{
|
||||
$stream = $this->parser->getStream();
|
||||
$lineno = $token->getLine();
|
||||
|
||||
$stream->expect(/* Token::BLOCK_END_TYPE */ 3);
|
||||
$body = $this->parser->subparse([$this, 'decideSpacelessEnd'], true);
|
||||
$stream->expect(/* Token::BLOCK_END_TYPE */ 3);
|
||||
|
||||
return new SpacelessNode($body, $lineno, $this->getTag());
|
||||
}
|
||||
|
||||
public function decideSpacelessEnd(Token $token)
|
||||
{
|
||||
return $token->test('endspaceless');
|
||||
}
|
||||
|
||||
public function getTag()
|
||||
{
|
||||
return 'spaceless';
|
||||
}
|
||||
}
|
||||
165
modules/system/twig/node/GetAttrNode.php
Normal file
165
modules/system/twig/node/GetAttrNode.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php namespace System\Twig\Node;
|
||||
|
||||
use System\Twig\SecurityPolicy;
|
||||
use Twig\Compiler;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\CoreExtension;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
use Twig\Node\Expression\GetAttrExpression;
|
||||
use Twig\Node\Node;
|
||||
use Twig\Source;
|
||||
use Twig\Template;
|
||||
|
||||
/**
|
||||
* GetAttrNode replaces Twig's GetAttrExpression so that, in sandbox mode, the object a method
|
||||
* is called on is first cast to a sandbox-safe proxy (see SecurityPolicy::castMethodObjectToSafeObject).
|
||||
* This is the only way to neutralise callable-passthrough on collections/paginators, because the
|
||||
* security policy is not given method arguments.
|
||||
*
|
||||
* The compile() logic is a faithful copy of the parent (Twig 3.22) so array access, the sandbox
|
||||
* ARRAY_LIKE_CLASSES fast-path, strict-variable handling and the `defined` test all behave
|
||||
* identically; only the getAttribute call target is swapped for customGetAttribute().
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
*/
|
||||
class GetAttrNode extends GetAttrExpression
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct(array $nodes = [], array $attributes = [], int $lineno = 0)
|
||||
{
|
||||
// Skip GetAttrExpression::__construct() (it requires positional child nodes); the node
|
||||
// visitor supplies fully-formed $nodes/$attributes copied from the original expression.
|
||||
Node::__construct($nodes, $attributes, $lineno);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function compile(Compiler $compiler): void
|
||||
{
|
||||
$env = $compiler->getEnvironment();
|
||||
$arrayAccessSandbox = false;
|
||||
|
||||
// optimize array calls
|
||||
if (
|
||||
$this->getAttribute('optimizable')
|
||||
&& (!$env->isStrictVariables() || $this->getAttribute('ignore_strict_check'))
|
||||
&& !$this->isDefinedTestEnabled()
|
||||
&& Template::ARRAY_CALL === $this->getAttribute('type')
|
||||
) {
|
||||
$var = '$'.$compiler->getVarName();
|
||||
$compiler
|
||||
->raw('(('.$var.' = ')
|
||||
->subcompile($this->getNode('node'))
|
||||
->raw(') && is_array(')
|
||||
->raw($var);
|
||||
|
||||
if (!$env->hasExtension(SandboxExtension::class)) {
|
||||
$compiler
|
||||
->raw(') || ')
|
||||
->raw($var)
|
||||
->raw(' instanceof ArrayAccess ? (')
|
||||
->raw($var)
|
||||
->raw('[')
|
||||
->subcompile($this->getNode('attribute'))
|
||||
->raw('] ?? null) : null)')
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$arrayAccessSandbox = true;
|
||||
|
||||
$compiler
|
||||
->raw(') || ')
|
||||
->raw($var)
|
||||
->raw(' instanceof ArrayAccess && in_array(')
|
||||
->raw($var.'::class')
|
||||
->raw(', \\Twig\\Extension\\CoreExtension::ARRAY_LIKE_CLASSES, true) ? (')
|
||||
->raw($var)
|
||||
->raw('[')
|
||||
->subcompile($this->getNode('attribute'))
|
||||
->raw('] ?? null) : ')
|
||||
;
|
||||
}
|
||||
|
||||
// Different from the parent: call our customGetAttribute() so the receiver is cast first.
|
||||
$compiler->raw(static::class.'::customGetAttribute($this->env, $this->source, ');
|
||||
|
||||
if ($this->getAttribute('ignore_strict_check')) {
|
||||
$this->getNode('node')->setAttribute('ignore_strict_check', true);
|
||||
}
|
||||
|
||||
$compiler
|
||||
->subcompile($this->getNode('node'))
|
||||
->raw(', ')
|
||||
->subcompile($this->getNode('attribute'))
|
||||
;
|
||||
|
||||
if ($this->hasNode('arguments')) {
|
||||
$compiler->raw(', ')->subcompile($this->getNode('arguments'));
|
||||
} else {
|
||||
$compiler->raw(', []');
|
||||
}
|
||||
|
||||
$compiler->raw(', ')
|
||||
->repr($this->getAttribute('type'))
|
||||
->raw(', ')->repr($this->isDefinedTestEnabled())
|
||||
->raw(', ')->repr($this->getAttribute('ignore_strict_check'))
|
||||
->raw(', ')->repr($env->hasExtension(SandboxExtension::class))
|
||||
->raw(', ')->repr($this->getNode('node')->getTemplateLine())
|
||||
->raw(')')
|
||||
;
|
||||
|
||||
if ($arrayAccessSandbox) {
|
||||
$compiler->raw(')');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* customGetAttribute wraps CoreExtension::getAttribute, casting the object to a safe proxy
|
||||
* before a method-invoking access when the sandbox is active.
|
||||
*
|
||||
* The cast fires on METHOD_CALL and on any access that carries arguments (so the built-in
|
||||
* `attribute()` function, which compiles to an ANY_CALL, is also covered). Property/relation
|
||||
* access with no arguments is left untouched, so collections stay iterable.
|
||||
*/
|
||||
public static function customGetAttribute(
|
||||
Environment $env,
|
||||
Source $source,
|
||||
$object,
|
||||
$item,
|
||||
array $arguments = [],
|
||||
$type = /* Template::ANY_CALL */ 'any',
|
||||
$isDefinedTest = false,
|
||||
$ignoreStrictCheck = false,
|
||||
$sandboxed = false,
|
||||
int $lineno = -1
|
||||
) {
|
||||
if (
|
||||
$sandboxed
|
||||
&& ($type === Template::METHOD_CALL || $arguments)
|
||||
&& $env->hasExtension(SandboxExtension::class)
|
||||
) {
|
||||
$policy = $env->getExtension(SandboxExtension::class)->getSecurityPolicy();
|
||||
if ($policy instanceof SecurityPolicy) {
|
||||
$object = $policy->castMethodObjectToSafeObject($object);
|
||||
}
|
||||
}
|
||||
|
||||
return CoreExtension::getAttribute(
|
||||
$env,
|
||||
$source,
|
||||
$object,
|
||||
$item,
|
||||
$arguments,
|
||||
$type,
|
||||
$isDefinedTest,
|
||||
$ignoreStrictCheck,
|
||||
$sandboxed,
|
||||
$lineno
|
||||
);
|
||||
}
|
||||
}
|
||||
165
modules/system/twig/securitypolicy/SafeCollection.php
Normal file
165
modules/system/twig/securitypolicy/SafeCollection.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php namespace System\Twig\SecurityPolicy;
|
||||
|
||||
use ArrayAccess;
|
||||
use Countable;
|
||||
use IteratorAggregate;
|
||||
use Traversable;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Contracts\Support\Jsonable;
|
||||
use Illuminate\Support\Enumerable;
|
||||
use Illuminate\Support\Traits\ForwardsCalls;
|
||||
|
||||
/**
|
||||
* SafeCollection is a collection proxy that is safe to use in a Twig sandbox.
|
||||
*
|
||||
* Collections are handed to templates everywhere, and their higher-order methods
|
||||
* (map, each, filter, reduce, ...) execute arbitrary callables — `things.map('system')`
|
||||
* would run `system()`. Twig's security policy cannot inspect method *arguments*, so
|
||||
* instead the receiver is cast to this proxy (by the custom GetAttrNode) before the call,
|
||||
* and every callable argument is nulled out before being forwarded. Callables are unusable
|
||||
* in Twig anyway, so nothing legitimate is lost.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
*/
|
||||
class SafeCollection implements ArrayAccess, Countable, IteratorAggregate, Arrayable, Jsonable
|
||||
{
|
||||
use ForwardsCalls;
|
||||
|
||||
/**
|
||||
* @var Enumerable The wrapped collection (Collection or LazyCollection).
|
||||
*/
|
||||
protected $collection;
|
||||
|
||||
/**
|
||||
* @var string[] Methods where a string argument is an attribute/key name (not a callback).
|
||||
* For these, string values are preserved; non-string callables are still stripped.
|
||||
* Safe because Laravel's useAsCallable() never treats a string as a callback.
|
||||
*/
|
||||
protected $hybridCallableArgs = [
|
||||
'contains',
|
||||
'containsstrict',
|
||||
'doesntcontain',
|
||||
'groupby',
|
||||
'keyby',
|
||||
'implode',
|
||||
'search',
|
||||
'sortby',
|
||||
'sortbydesc',
|
||||
'unique',
|
||||
'duplicates',
|
||||
'partition',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string[] Methods that instantiate arbitrary classes or dispatch statically from a
|
||||
* string argument (not caught by is_callable stripping), so they are blocked outright.
|
||||
*/
|
||||
protected $blockedMethods = [
|
||||
'mapinto',
|
||||
'pipeinto',
|
||||
'toresourcecollection',
|
||||
];
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(Enumerable $collection)
|
||||
{
|
||||
$this->collection = $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward all other calls to the collection, stripping callable arguments first.
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
if (in_array(strtolower($method), $this->blockedMethods)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$normalized = strtolower($method);
|
||||
foreach ($parameters as &$param) {
|
||||
$param = $this->stripCallables($param, $normalized);
|
||||
}
|
||||
unset($param);
|
||||
|
||||
return $this->forwardCallTo($this->collection, $method, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively null out any callable value at any depth. Hybrid methods keep string
|
||||
* values (used as attribute names) but still drop non-string callables.
|
||||
*/
|
||||
protected function stripCallables($value, string $method)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = $this->stripCallables($item, $method);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (
|
||||
is_callable($value) &&
|
||||
(!in_array($method, $this->hybridCallableArgs) || !is_string($value))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return $this->collection->getIterator();
|
||||
}
|
||||
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return $this->collection instanceof ArrayAccess
|
||||
? $this->collection->offsetExists($offset)
|
||||
: false;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function offsetGet($offset)
|
||||
{
|
||||
return $this->collection instanceof ArrayAccess
|
||||
? $this->collection->offsetGet($offset)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
if ($this->collection instanceof ArrayAccess) {
|
||||
$this->collection->offsetSet($offset, $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
if ($this->collection instanceof ArrayAccess) {
|
||||
$this->collection->offsetUnset($offset);
|
||||
}
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->collection->count();
|
||||
}
|
||||
|
||||
public function toArray()
|
||||
{
|
||||
return $this->collection->toArray();
|
||||
}
|
||||
|
||||
public function toJson($options = 0)
|
||||
{
|
||||
return $this->collection->toJson($options);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->collection->toJson();
|
||||
}
|
||||
}
|
||||
100
modules/system/twig/securitypolicy/SafePaginator.php
Normal file
100
modules/system/twig/securitypolicy/SafePaginator.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php namespace System\Twig\SecurityPolicy;
|
||||
|
||||
use ArrayAccess;
|
||||
use Countable;
|
||||
use IteratorAggregate;
|
||||
use Traversable;
|
||||
use Illuminate\Support\Traits\ForwardsCalls;
|
||||
|
||||
/**
|
||||
* SafePaginator is a paginator proxy that is safe to use in a Twig sandbox.
|
||||
*
|
||||
* Paginators expose `through(callable)` (on both AbstractPaginator and
|
||||
* AbstractCursorPaginator), which executes an arbitrary callable over the items. This proxy
|
||||
* forwards every method to the wrapped paginator but strips callable arguments first, exactly
|
||||
* like SafeCollection. All the rendering/navigation methods (render, links, currentPage,
|
||||
* total, items, url, ...) keep working because they take no callables.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
*/
|
||||
class SafePaginator implements ArrayAccess, Countable, IteratorAggregate
|
||||
{
|
||||
use ForwardsCalls;
|
||||
|
||||
/**
|
||||
* @var \Illuminate\Pagination\AbstractPaginator|\Illuminate\Pagination\AbstractCursorPaginator
|
||||
*/
|
||||
protected $paginator;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct($paginator)
|
||||
{
|
||||
$this->paginator = $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward all calls to the paginator, stripping callable arguments first.
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
foreach ($parameters as &$param) {
|
||||
$param = $this->stripCallables($param);
|
||||
}
|
||||
unset($param);
|
||||
|
||||
return $this->forwardCallTo($this->paginator, $method, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively null out any callable value at any depth.
|
||||
*/
|
||||
protected function stripCallables($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = $this->stripCallables($item);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
return is_callable($value) ? null : $value;
|
||||
}
|
||||
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return $this->paginator->getIterator();
|
||||
}
|
||||
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return $this->paginator->offsetExists($offset);
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function offsetGet($offset)
|
||||
{
|
||||
return $this->paginator->offsetGet($offset);
|
||||
}
|
||||
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
$this->paginator->offsetSet($offset, $value);
|
||||
}
|
||||
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
$this->paginator->offsetUnset($offset);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->paginator->count();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string) $this->paginator->render();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user