diff --git a/application/Espo/Core/Formula/Evaluator.php b/application/Espo/Core/Formula/Evaluator.php index 6cc7c985ed..3fe3641cd9 100644 --- a/application/Espo/Core/Formula/Evaluator.php +++ b/application/Espo/Core/Formula/Evaluator.php @@ -32,6 +32,7 @@ namespace Espo\Core\Formula; use Espo\Core\Formula\Exceptions\Error; use Espo\Core\Formula\Exceptions\ExecutionException; use Espo\Core\Formula\Exceptions\SyntaxError; +use Espo\Core\Formula\Exceptions\UnsafeFunction; use Espo\Core\Formula\Functions\Base as DeprecatedBaseFunction; use Espo\Core\Formula\Functions\BaseFunction; use Espo\Core\Formula\Parser\Ast\Attribute; @@ -46,6 +47,8 @@ use stdClass; /** * Creates an instance of Processor and executes a script. + * + * @internal */ class Evaluator { @@ -56,10 +59,12 @@ class Evaluator /** * @param array> $functionClassNameMap + * @param string[] $unsafeFunctionList */ public function __construct( private InjectableFactory $injectableFactory, - private array $functionClassNameMap = [] + private array $functionClassNameMap = [], + private array $unsafeFunctionList = [] ) { $this->attributeFetcher = $injectableFactory->create(AttributeFetcher::class); $this->parser = new Parser(); @@ -74,6 +79,31 @@ class Evaluator */ public function process(string $expression, ?Entity $entity = null, ?stdClass $variables = null): mixed { + return $this->processInternal($expression, $entity, $variables, false); + } + + /** + * Process expression in safe mode. + * + * @throws SyntaxError + * @throws Error + */ + public function processSafe(string $expression, ?Entity $entity = null, ?stdClass $variables = null): mixed + { + return $this->processInternal($expression, $entity, $variables, true); + } + + /** + * @throws SyntaxError + * @throws Error + */ + private function processInternal( + string $expression, + ?Entity $entity, + ?stdClass $variables, + bool $safeMode, + ): mixed { + $processor = new Processor( $this->injectableFactory, $this->attributeFetcher, @@ -84,6 +114,10 @@ class Evaluator $item = $this->getParsedExpression($expression); + if ($safeMode) { + $this->checkIsSafe($item->getData()); + } + try { $result = $processor->process($item); } @@ -107,4 +141,24 @@ class Evaluator return new Argument($this->parsedHash[$expression]); } + + /** + * @throws UnsafeFunction + */ + private function checkIsSafe(mixed $data): void + { + if (!$data instanceof Node) { + return; + } + + $name = $data->getType(); + + if (in_array($name, $this->unsafeFunctionList)) { + throw new UnsafeFunction("$name is not safe."); + } + + foreach ($data->getChildNodes() as $subData) { + $this->checkIsSafe($subData); + } + } } diff --git a/application/Espo/Core/Formula/Exceptions/UnsafeFunction.php b/application/Espo/Core/Formula/Exceptions/UnsafeFunction.php new file mode 100644 index 0000000000..0212248a25 --- /dev/null +++ b/application/Espo/Core/Formula/Exceptions/UnsafeFunction.php @@ -0,0 +1,34 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Formula\Exceptions; + +class UnsafeFunction extends Error +{ +} diff --git a/application/Espo/Core/Formula/Manager.php b/application/Espo/Core/Formula/Manager.php index 827473b35c..6140f78231 100644 --- a/application/Espo/Core/Formula/Manager.php +++ b/application/Espo/Core/Formula/Manager.php @@ -46,17 +46,45 @@ class Manager { $functionClassNameMap = $metadata->get(['app', 'formula', 'functionClassNameMap'], []); - $this->evaluator = new Evaluator($injectableFactory, $functionClassNameMap); + $unsafeFunctionList = $this->getUnsafeFunctionList($metadata); + + $this->evaluator = new Evaluator($injectableFactory, $functionClassNameMap, $unsafeFunctionList); } /** * Executes a script and returns its result. * - * @return mixed * @throws Exceptions\Error */ - public function run(string $script, ?Entity $entity = null, ?stdClass $variables = null) + public function run(string $script, ?Entity $entity = null, ?stdClass $variables = null) : mixed { return $this->evaluator->process($script, $entity, $variables); } + + /** + * Executes a script in safe mode and returns its result. + * + * @throws Exceptions\Error + * @since 8.3.0 + * @internal + */ + public function runSafe(string $script, ?Entity $entity = null, ?stdClass $variables = null): mixed + { + return $this->evaluator->processSafe($script, $entity, $variables); + } + + /** + * @return string[] + */ + private function getUnsafeFunctionList(Metadata $metadata): array + { + $unsafeFunctionList = []; + + foreach ($metadata->get("app.formula.functionList") ?? [] as $item) { + if ($item['unsafe'] ?? false) { + $unsafeFunctionList[] = $item['name']; + } + } + return $unsafeFunctionList; + } } diff --git a/application/Espo/Core/Utils/Metadata/Builder.php b/application/Espo/Core/Utils/Metadata/Builder.php index 0c0508d141..8c0fe6b6dc 100644 --- a/application/Espo/Core/Utils/Metadata/Builder.php +++ b/application/Espo/Core/Utils/Metadata/Builder.php @@ -40,6 +40,7 @@ class Builder /** @var array */ private $forceAppendPathList = [ ['app', 'rebuild', 'actionClassNameList'], + ['app', 'formula', 'functionList'], ['app', 'fieldProcessing', 'readLoaderClassNameList'], ['app', 'fieldProcessing', 'listLoaderClassNameList'], ['app', 'fieldProcessing', 'saverClassNameList'], diff --git a/application/Espo/Resources/metadata/app/formula.json b/application/Espo/Resources/metadata/app/formula.json index 609e27ff41..357265a198 100644 --- a/application/Espo/Resources/metadata/app/formula.json +++ b/application/Espo/Resources/metadata/app/formula.json @@ -235,132 +235,160 @@ { "name": "entity\\attribute", "insertText": "entity\\attribute(ATTRIBUTE)", - "returnType": "mixed" + "returnType": "mixed", + "unsafe": true }, { "name": "entity\\attributeFetched", "insertText": "entity\\attributeFetched(ATTRIBUTE)", - "returnType": "mixed" + "returnType": "mixed", + "unsafe": true }, { "name": "entity\\setAttribute", - "insertText": "entity\\setAttribute(ATTRIBUTE, VALUE)" + "insertText": "entity\\setAttribute(ATTRIBUTE, VALUE)", + "unsafe": true }, { "name": "entity\\clearAttribute", - "insertText": "entity\\clearAttribute(ATTRIBUTE)" + "insertText": "entity\\clearAttribute(ATTRIBUTE)", + "unsafe": true }, { "name": "entity\\addLinkMultipleId", - "insertText": "entity\\addLinkMultipleId(LINK, ID)" + "insertText": "entity\\addLinkMultipleId(LINK, ID)", + "unsafe": true }, { "name": "entity\\hasLinkMultipleId", "insertText": "entity\\hasLinkMultipleId(LINK, ID)", - "returnType": "bool" + "returnType": "bool", + "unsafe": true }, { "name": "entity\\removeLinkMultipleId", - "insertText": "entity\\removeLinkMultipleId(LINK, ID)" + "insertText": "entity\\removeLinkMultipleId(LINK, ID)", + "unsafe": true }, { "name": "entity\\getLinkColumn", "insertText": "entity\\getLinkColumn(LINK, ID, COLUMN)", - "returnType": "mixed" + "returnType": "mixed", + "unsafe": true }, { "name": "entity\\setLinkMultipleColumn", - "insertText": "entity\\setLinkMultipleColumn(LINK, ID, COLUMN, VALUE)" + "insertText": "entity\\setLinkMultipleColumn(LINK, ID, COLUMN, VALUE)", + "unsafe": true }, { "name": "entity\\isRelated", "insertText": "entity\\isRelated(LINK, ID)", - "returnType": "bool" + "returnType": "bool", + "unsafe": true }, { "name": "entity\\sumRelated", "insertText": "entity\\sumRelated(LINK, FIELD, FILTER)", - "returnType": "int|float" + "returnType": "int|float", + "unsafe": true }, { "name": "entity\\countRelated", "insertText": "entity\\countRelated(LINK, FILTER)", - "returnType": "int" + "returnType": "int", + "unsafe": true }, { "name": "record\\exists", "insertText": "record\\exists(ENTITY_TYPE, KEY, VALUE)", - "returnType": "bool" + "returnType": "bool", + "unsafe": true }, { "name": "record\\count", "insertText": "record\\count(ENTITY_TYPE, KEY, VALUE)", - "returnType": "int" + "returnType": "int", + "unsafe": true }, { "name": "record\\attribute", "insertText": "record\\attribute(ENTITY_TYPE, ID, ATTRIBUTE)", - "returnType": "mixed" + "returnType": "mixed", + "unsafe": true }, { "name": "record\\findOne", "insertText": "record\\findOne(ENTITY_TYPE, ORDER_BY, ORDER, KEY1, VALUE1, KEY2, VALUE2)", - "returnType": "string" + "returnType": "string", + "unsafe": true }, { "name": "record\\findMany", "insertText": "record\\findMany(ENTITY_TYPE, LIMIT, ORDER_BY, ORDER, KEY1, VALUE1, KEY2, VALUE2)", - "returnType": "string" + "returnType": "string", + "unsafe": true }, { "name": "record\\findRelatedOne", "insertText": "record\\findRelatedOne(ENTITY_TYPE, ID, LINK, ORDER_BY, ORDER, KEY1, VALUE1, KEY2, VALUE2)", - "returnType": "string" + "returnType": "string", + "unsafe": true }, { "name": "record\\findRelatedMany", "insertText": "record\\findRelatedMany(ENTITY_TYPE, ID, LINK, LIMIT, ORDER_BY, ORDER, KEY1, VALUE1, KEY2, VALUE2)", - "returnType": "string[]" + "returnType": "string[]", + "unsafe": true }, { "name": "record\\fetch", "insertText": "record\\fetch(ENTITY_TYPE, ID)", - "returnType": "?object" + "returnType": "?object", + "unsafe": true }, { "name": "record\\relate", - "insertText": "record\\relate(ENTITY_TYPE, ID, LINK, FOREIGN_ID)" + "insertText": "record\\relate(ENTITY_TYPE, ID, LINK, FOREIGN_ID)", + "unsafe": true }, { "name": "record\\unrelate", - "insertText": "record\\unrelate(ENTITY_TYPE, ID, LINK, FOREIGN_ID)" + "insertText": "record\\unrelate(ENTITY_TYPE, ID, LINK, FOREIGN_ID)", + "unsafe": true }, { "name": "record\\create", "insertText": "record\\create(ENTITY_TYPE, ATTRIBUTE1, VALUE1, ATTRIBUTE2, VALUE2)", - "returnType": "string" + "returnType": "string", + "unsafe": true }, { "name": "record\\update", - "insertText": "record\\update(ENTITY_TYPE, ID, ATTRIBUTE1, VALUE1, ATTRIBUTE2, VALUE2)" + "insertText": "record\\update(ENTITY_TYPE, ID, ATTRIBUTE1, VALUE1, ATTRIBUTE2, VALUE2)", + "unsafe": true }, { "name": "record\\delete", - "insertText": "record\\delete(ENTITY_TYPE, ID)" + "insertText": "record\\delete(ENTITY_TYPE, ID)", + "unsafe": true }, { "name": "record\\relationColumn", "insertText": "record\\relationColumn(ENTITY_TYPE, ID, LINK, FOREIGN_ID, COLUMN)", - "returnType": "mixed" + "returnType": "mixed", + "unsafe": true }, { "name": "record\\updateRelationColumn", - "insertText": "record\\updateRelationColumn(ENTITY_TYPE, ID, LINK, FOREIGN_ID, COLUMN, VALUE)" + "insertText": "record\\updateRelationColumn(ENTITY_TYPE, ID, LINK, FOREIGN_ID, COLUMN, VALUE)", + "unsafe": true }, { "name": "env\\userAttribute", "insertText": "env\\userAttribute(ATTRIBUTE)", - "returnType": "mixed" + "returnType": "mixed", + "unsafe": true }, { "name": "util\\generateId", @@ -472,19 +500,23 @@ }, { "name": "log\\info", - "insertText": "log\\info(MESSAGE)" + "insertText": "log\\info(MESSAGE)", + "unsafe": true }, { "name": "log\\notice", - "insertText": "log\\notice(MESSAGE)" + "insertText": "log\\notice(MESSAGE)", + "unsafe": true }, { "name": "log\\warning", - "insertText": "log\\warning(MESSAGE)" + "insertText": "log\\warning(MESSAGE)", + "unsafe": true }, { "name": "log\\error", - "insertText": "log\\error(MESSAGE)" + "insertText": "log\\error(MESSAGE)", + "unsafe": true }, { "name": "json\\retrieve", @@ -499,21 +531,25 @@ { "name": "ext\\email\\send", "insertText": "ext\\email\\send(EMAIL_ID)", - "returnType": "bool" + "returnType": "bool", + "unsafe": true }, { "name": "ext\\sms\\send", "insertText": "ext\\sms\\send(SMS_ID)", - "returnType": "bool" + "returnType": "bool", + "unsafe": true }, { "name": "ext\\email\\applyTemplate", - "insertText": "ext\\email\\applyTemplate(EMAIL_ID, EMAIL_TEMPLATE_ID)" + "insertText": "ext\\email\\applyTemplate(EMAIL_ID, EMAIL_TEMPLATE_ID)", + "unsafe": true }, { "name": "ext\\pdf\\generate", "insertText": "ext\\pdf\\generate(ENTITY_TYPE, ENTITY_ID, TEMPLATE_ID, FILENAME)", - "returnType": "string" + "returnType": "string", + "unsafe": true }, { "name": "ext\\workingTime\\addWorkingDays", @@ -547,12 +583,14 @@ }, { "name": "ext\\user\\sendAccessInfo", - "insertText": "ext\\user\\sendAccessInfo(USER_ID)" + "insertText": "ext\\user\\sendAccessInfo(USER_ID)", + "unsafe": true }, { "name": "ext\\email\\send", "insertText": "ext\\email\\send(EMAIL_ID)", - "returnType": "bool" + "returnType": "bool", + "unsafe": true }, { "name": "ext\\currency\\convert", @@ -562,22 +600,26 @@ { "name": "ext\\acl\\checkEntity", "insertText": "ext\\acl\\checkEntity(USER_ID, ENTITY_TYPE, ID, ACTION)", - "returnType": "bool" + "returnType": "bool", + "unsafe": true }, { "name": "ext\\acl\\checkScope", "insertText": "ext\\acl\\checkScope(USER_ID, SCOPE, ACTION)", - "returnType": "bool" + "returnType": "bool", + "unsafe": true }, { "name": "ext\\acl\\getLevel", "insertText": "ext\\acl\\getLevel(USER_ID, SCOPE, ACTION)", - "returnType": "string" + "returnType": "string", + "unsafe": true }, { "name": "ext\\acl\\getPermissionLevel", "insertText": "ext\\acl\\getPermissionLevel(USER_ID, PERMISSION)", - "returnType": "string" + "returnType": "string", + "unsafe": true } ], "functionClassNameMap": { diff --git a/client/src/views/fields/formula.js b/client/src/views/fields/formula.js index 71abce7b7e..e6c2f35f23 100644 --- a/client/src/views/fields/formula.js +++ b/client/src/views/fields/formula.js @@ -240,9 +240,8 @@ class FormulaFieldView extends TextFieldView { } getFunctionDataList() { - let list = Espo.Utils.clone( - this.getMetadata().get(['app', 'formula', 'functionList']) || [] - ); + let list = [...this.getMetadata().get(['app', 'formula', 'functionList'], [])] + .filter(item => item.insertText); if (this.options.additionalFunctionDataList) { list = list.concat(this.options.additionalFunctionDataList); diff --git a/schema/metadata/app/formula.json b/schema/metadata/app/formula.json index 8acbac7cc2..1d3b768df8 100644 --- a/schema/metadata/app/formula.json +++ b/schema/metadata/app/formula.json @@ -7,7 +7,7 @@ "properties": { "functionList": { "type": "array", - "description": "Functions available in the editor.", + "description": "Functions.", "items": { "anyOf": [ { @@ -24,6 +24,10 @@ "returnType": { "type": "string", "description": "A return type." + }, + "unsafe": { + "type": "boolean", + "description": "Is unsafe. As of v8.3.0." } } }, diff --git a/tests/integration/Espo/Core/Formula/FormulaTest.php b/tests/integration/Espo/Core/Formula/FormulaTest.php index 9737074e81..324420c4ae 100644 --- a/tests/integration/Espo/Core/Formula/FormulaTest.php +++ b/tests/integration/Espo/Core/Formula/FormulaTest.php @@ -31,6 +31,7 @@ namespace tests\integration\Espo\Core\Formula; use Espo\Core\Acl\Table; use Espo\Core\Field\DateTimeOptional; +use Espo\Core\Formula\Exceptions\UnsafeFunction; use Espo\Core\Formula\Manager; use Espo\Entities\User; use Espo\Modules\Crm\Entities\Account; @@ -960,4 +961,16 @@ class FormulaTest extends BaseTestCase /** @noinspection PhpUnhandledExceptionInspection */ $this->assertEquals(Table::LEVEL_YES, $fm->run($script)); } + + public function testSafe(): void + { + $fm = $this->getContainer()->getByClass(Manager::class); + + $script = "record\\create('Account')"; + + $this->expectException(UnsafeFunction::class); + + /** @noinspection PhpUnhandledExceptionInspection */ + $fm->runSafe($script); + } } diff --git a/tests/unit/Espo/Core/Formula/EvaluatorTest.php b/tests/unit/Espo/Core/Formula/EvaluatorTest.php index 19a18e3391..e3ff07f0d0 100644 --- a/tests/unit/Espo/Core/Formula/EvaluatorTest.php +++ b/tests/unit/Espo/Core/Formula/EvaluatorTest.php @@ -31,14 +31,16 @@ namespace tests\unit\Espo\Core\Formula; use Espo\Core\Formula\Evaluator; use Espo\Core\Formula\Exceptions\Error; +use Espo\Core\Formula\Exceptions\UnsafeFunction; use Espo\Core\InjectableFactory; use Espo\Core\Formula\Exceptions\SyntaxError; use Espo\Core\Utils\Log; use Espo\ORM\EntityManager; +use PHPUnit\Framework\TestCase; use tests\unit\ContainerMocker; -class EvaluatorTest extends \PHPUnit\Framework\TestCase +class EvaluatorTest extends TestCase { /** * @var Evaluator @@ -59,7 +61,7 @@ class EvaluatorTest extends \PHPUnit\Framework\TestCase $injectableFactory = new InjectableFactory($container); - $this->evaluator = new Evaluator($injectableFactory); + $this->evaluator = new Evaluator($injectableFactory, [], ['test\\unsafe']); } protected function tearDown() : void @@ -1546,4 +1548,24 @@ class EvaluatorTest extends \PHPUnit\Framework\TestCase $this->assertEquals('1', $value); } + + public function testUnsafe1(): void + { + $expression = "util\\base64Encode(test\\unsafe());"; + + $this->expectException(UnsafeFunction::class); + + /** @noinspection PhpUnhandledExceptionInspection */ + $this->evaluator->processSafe($expression); + } + + public function testUnsafe2(): void + { + $expression = "test\\unsafe();"; + + $this->expectException(UnsafeFunction::class); + + /** @noinspection PhpUnhandledExceptionInspection */ + $this->evaluator->processSafe($expression); + } }