Skip to content

Instantly share code, notes, and snippets.

@meglio
Last active April 29, 2020 13:01
Show Gist options
  • Save meglio/f78a28c32b30fe0431f78e4538bf7899 to your computer and use it in GitHub Desktop.
Save meglio/f78a28c32b30fe0431f78e4538bf7899 to your computer and use it in GitHub Desktop.
Sanity-checks user PHP code intended to be used for simple formulas / logic description in admin panels. Only use with trusted members - this is a soft protection and not a fully-fledged sandbox.
<?php
/**
* Sanity-checks PHP code that was already parsed into AST with using php-parser library.
*
* The AST must be represented as a multi-level array with scalar values.
*
* Example usage:
*
* <code>
* $parser = (new ParserFactory)->create(ParserFactory::ONLY_PHP7);
* $ast = $parser->parse($code);
* $json = json_decode(json_encode($ast), true);
* $checker = new CodeSanityChecker();
* $checker->check($json); // throws InvalidArgumentException
* </code>
*/
class CodeSanityChecker
{
private $isVariableVariablesDisabled = true;
private $isThisDisabled = true;
private $stopNodeTypes = [
'Expr_Eval' => 'eval() is disabled.',
'Stmt_Use' => 'the "use" statement is disabled.',
'Stmt_Namespace' => 'the "namespace" statement is disabled.',
'Stmt_Global' => 'the "global" statement is disabled.',
'Expr_Include' => 'the "require" and "include" statements are disabled.',
'Stmt_StaticVar' => 'static variables are disabled.',
'Stmt_Class' => 'class declarations are disabled.',
'Stmt_Trait' => 'trait declarations are disabled.',
'Stmt_Interface' => 'interface declarations are disabled.',
'Expr_Exit' => '"die" and "exit" statements are disabled.',
'Expr_ErrorSuppress' => 'error suppression is disabled.',
'Scalar_MagicConst_Line' => '__LINE__ magic constant is disabled.',
'Scalar_MagicConst_File' => '__FILE__ magic constant is disabled.',
'Scalar_MagicConst_Dir' => '__DIR__ magic constant is disabled.',
'Scalar_MagicConst_Function' => '__FUNCTION__ magic constant is disabled.',
'Scalar_MagicConst_Class' => '__CLASS__ magic constant is disabled.',
'Scalar_MagicConst_Trait' => '__TRAIT__ magic constant is disabled.',
'Scalar_MagicConst_Method' => '__METHOD__ magic constant is disabled.',
'Scalar_MagicConst_Namespace' => '__NAMESPACE__ magic constant is disabled.',
];
/**
* Fully Qualified class names.
*
* Notes:
* - Entries MUST begin with \.
* - Do not forget to escape \ - use \\ instead.
*
* @var string[]
*/
private $stopNamespaces = [
// System
'\\Swoole',
'\\Componere',
'\\FFI',
'\\parallel',
// Vendor
'\\Twig',
// Application
'\\MyApp',
];
/**
* Uppercase variable names that are prohibited.
*
* @var string[]
*/
private $stopVariableNames = [
'GLOBALS',
'_SERVER',
'_GET',
'_POST',
'_FILES',
'_COOKIE',
'_SESSION',
'_REQUEST',
'_ENV',
'PHP_ERRORMSG',
'HTTP_RAW_POST_DATA',
'HTTP_RESPONSE_HEADER',
'ARGC',
'ARGV',
];
private $stopGlobalFuncNames = [
// Misc
'connection_aborted',
'connection_status',
'constant',
'define',
'defined',
'die',
'eval',
'exit',
'get_browser',
'__halt_compiler ',
'highlight_file',
'ignore_user_abort',
'php_check_syntax',
'php_strip_whitespace',
'sapi_windows_cp_conv',
'sapi_windows_cp_get',
'sapi_windows_cp_is_utf8',
'sapi_windows_cp_set',
'sapi_windows_generate_ctrl_event',
'sapi_windows_set_ctrl_handler ',
'show_source',
'sleep',
'sys_getloadavg',
'time_nanosleep',
'time_sleep_until',
'usleep',
// SPL
'class_implements',
'class_parents',
'class_uses',
'iterator_apply',
'iterator_count',
'spl_autoload_call',
'spl_autoload_extensions',
'spl_autoload_functions',
'spl_autoload_register',
'spl_autoload_unregister',
'spl_autoload',
'spl_classes',
'spl_object_hash',
'spl_object_id',
// Seaslog
'seaslog_get_author ',
'seaslog_get_version',
// Parsekit
'/^parsekit_.+$/is',
// Streams
'set_socket_blocking',
'/^stream_.+$/is',
// Swoole
'/^swoole_.+$/is',
// Tidy
'ob_tidyhandler',
'/^tidy_.+$/is',
// Tokenizer
'/^token_.+$/is',
// URLs
'get_headers',
'get_meta_tags',
// Yaml
'/^yaml_.+$/is',
// Taint
'is_tainted',
'taint',
'untaint',
// PHP Options / Info Functions
'assert_options',
'assert',
'cli',
'cli_get_process_title',
'cli_set_process_title',
'dl',
'extension_loaded',
'/^gc_.+$/is',
'get_cfg_var',
'get_current_user',
'get_defined_constants',
'get_extension_funcs',
'get_include_path',
'get_included_files',
'get_loaded_extensions',
'get_magic_quotes_gpc',
'get_magic_quotes_runtime',
'get_required_files',
'get_resources',
'getenv',
'getopt',
'getrusage',
'/^ini_.+$/is',
'magic_quotes_runtime',
'main',
'memory_get_peak_usage',
'memory_get_usage',
'/^php_.+$/is',
'phpcredits',
'phpinfo',
'phpversion',
'putenv',
'restore_include_path',
'set_include_path',
'set_magic_quotes_runtime',
'set_time_limit',
'sys_get_temp_dir',
'/^zend_.+$/is',
// APC
'/^apc_.+$/is',
// APCU
'/^apcu_.+$/is',
// APD
'/^apd_.+$/is',
'override_function',
'rename_function',
// BCOMPILER
'/^bcompiler_.+$/is',
// BLENC
'blenc_encrypt',
// Error handling
'debug_backtrace',
'debug_print_backtrace',
'error_clear_last',
'error_get_last',
'error_log',
'error_reporting',
'restore_error_handler',
'restore_exception_handler',
'set_error_handler',
'set_exception_handler',
'trigger_error',
'user_error',
// inclued
'inclued_get_data',
// OPCache
'/^opcache_.+$/is',
// Output Control Functions
'/^ob_.+$/is',
'flush',
'output_add_rewrite_var',
'output_reset_rewrite_vars',
// PHPDBG
'/^phpdbg_.+$/is',
// RUNKIT
'/^runkit_.+$/is',
// runkit7
'/^runkit7_.+$/is',
// uopz
'/^uopz_.+$/is',
// WinCache
'/^wincache_.+$/is',
// Xhprof
'/^xhprof_.+$/is',
// Direct IO Functions
'/^dio_.+$/is',
// File system
'chdir',
'chroot',
'closedir',
'dir',
'getcwd',
'opendir',
'readdir',
'rewinddir',
'scandir',
'/^finfo_.+$/is',
'mime_content_type',
'chgrp',
'chmod',
'chown',
'copy',
'delete',
'dirname',
'disk_free_space',
'disk_total_space ',
'diskfreespace',
'fclose',
'feof',
'fflush',
'fgetc',
'fgetcsv',
'fgets',
'fgetss',
'file_exists',
'file_get_contents ',
'file_put_contents',
'file',
'flock',
'fopen',
'fpassthru',
'fputcsv',
'fputs',
'fread',
'fscanf',
'fseek',
'fstat',
'ftell',
'ftruncate',
'fwrite',
'glob',
'lchgrp',
'lchown',
'link',
'linkinfo',
'mkdir',
'move_uploaded_file',
'parse_ini_file',
'pclose',
'popen',
'readfile',
'readlink',
'realpath_cache_get',
'rename',
'rewind',
'rmdir',
'set_file_buffer',
'stat',
'symlink',
'tempnam',
'tmpfile',
'touch',
'umask',
'unlink',
// Inotify
'/^inotify_.+$/is',
// Proctitle
'setproctitle',
'setthreadtitle',
// Xattr
'/^xattr_.+$/is',
// Xdiff
'/^xdiff_.+$/is',
// Enchant
'/^enchant_.+$/is',
// Multibyte String
'/^mb_.+$/is',
// GNU Recode
'recode_file',
'recode_string',
'recode',
// Posix
'/^posix_.+$/is',
// Execution
'exec',
'passthru',
'proc_nice',
'proc_open',
'shell_exec',
'system',
// Semaphore, Shared Memory and IPC
'/^msg_.+$/is',
'/^sem_.+$/is',
'/^shm_.+$/is',
'ftok',
// Shared Memory
'/^shmop_.+$/is',
// CURL
'/^curl_.+$/is',
// FTP
'/^ftp_.+$/is',
// LDAP
'/^ldap_.+$/is',
// Network
'checkdnsrr',
'closelog',
'define_syslog_variables',
'dns_check_record',
'dns_get_mx',
'dns_get_record',
'fsockopen',
'header_register_callback',
'header_remove ',
'header',
'headers_list',
'headers_sent',
'http_response_code',
'openlog',
'pfsockopen',
'setcookie',
'setrawcookie',
'socket_get_status',
'socket_set_blocking',
'socket_set_timeout',
'syslog',
// SNMP
'/^snmp.+$/is', // no underscore!
// BBCode
'/^bbcode_.+$/is',
// Classes / Objects
'call_user_method_array',
'call_user_method',
'class_alias',
'class_exists',
'get_called_class',
'get_class_methods',
'get_class_vars',
'get_class',
'get_declared_classes',
'get_declared_interfaces',
'get_declared_traits',
'get_object_vars',
'get_parent_class',
'interface_exists',
'is_a',
'is_subclass_of',
'method_exists',
'property_exists',
'trait_exists',
// Classkit
'/^classkit_.+$/is',
// Function handling
'call_user_func_array',
'call_user_func',
'create_function',
'forward_static_call_array',
'forward_static_call',
'func_get_arg',
'func_get_args',
'func_num_args',
'function_exists',
'get_defined_functions',
'register_shutdown_function',
'register_tick_function',
'unregister_tick_function',
// Variable handling
'debug_zval_dump',
'import_request_variables',
'print_r',
'serialize',
'settype',
'var_dump',
'var_export',
// XML-RPC
'/^xmlrpc_.+$/is',
// SOAP
'use_soap_error_handler',
// DOM
'dom_import_simplexml',
// libxml
'/^libxml_.+$/is',
// SimpleXML
'/^simplexml_.+$/is',
// WDDX
'/^wddx_.+$/is',
// XMLWriter
'/^xmlwriter_.+$/is',
'',
'',
];
private $stopGlobalClasses = [
// SPL Iterators
'CachingIterator',
'DirectoryIterator',
'FilesystemIterator',
'GlobIterator',
'InfiniteIterator',
'RecursiveCachingIterator',
'RecursiveDirectoryIterator',
// SPL
'/^Spl.+$/is',
// Seaslog
'SeasLog',
// Streams
'php_user_filter',
'streamWrapper',
// Tidy
'tidy',
'tidyNode',
// V8Js
'V8Js',
'V8JsException',
// Yaf
'/^Yaf_.+$/is',
// Yaconf
'Yaconf',
// APC
'APCIterator',
// APCU
'APCUIterator',
// FFI
'FFI',
// Runkit
'Runkit_Sandbox',
'Runkit_Sandbox_Parent',
// Weakref
'WeakRef',
'WeakMap',
// Yac
'Yac',
// File System
'Directory',
'finfo',
'phdfs',
// Pthreads
'Threaded',
'Thread',
'Worker',
'Modifiers',
'Pool',
'Mutex',
'Cond',
'Volatile',
// Sync
'SyncMutex',
'SyncSemaphore',
'SyncEvent',
'SyncReaderWriter',
'SyncSharedMemory',
// CURL
'CURLFile',
// SNMP
'SNMP',
'SNMPException',
// Reflection
'Reflection',
'ReflectionClass',
'ReflectionClassConstant',
'ReflectionZendExtension',
'ReflectionExtension',
'ReflectionFunction',
'ReflectionFunctionAbstract',
'ReflectionMethod',
'ReflectionNamedType',
'ReflectionObject',
'ReflectionParameter',
'ReflectionProperty',
'ReflectionType',
'ReflectionGenerator',
'Reflector',
'ReflectionException',
// SOAP
'SoapClient',
'SoapServer',
'SoapFault',
'SoapHeader',
'SoapParam',
'SoapVar',
// DOM
'DOMAttr',
'DOMCdataSection',
'DOMCharacterData',
'DOMComment',
'DOMDocument',
'DOMDocumentFragment',
'DOMDocumentType',
'DOMElement',
'DOMEntity',
'DOMException',
'DOMImplementation',
'DOMNamedNodeMap',
'DOMNode',
'DOMNodeList',
'DOMNotation',
'DOMText',
'DOMXPath',
// SimpleXML
'SimpleXMLElement',
'SimpleXMLIterator',
// XMLReader
'XMLReader',
// XMLWriter
'XMLWriter',
// XSLT
'XSLTProcessor',
'',
];
private function checkLoop($input)
{
if (is_scalar($input)) {
return;
}
if (is_array($input)) {
$this->checkArrayAsAWhole($input);
// Check all array elements individually
foreach ($input as $key => $value) {
if (is_scalar($value)) {
$this->checkKeyValue($key, $value);
} else {
$this->check($value);
}
}
}
}
/**
* Sanity-checks PHP AST and throws if sanity check cannot be passed.
*
* @param array $astArray
* @throws InvalidArgumentException
*/
public function check($astArray)
{
$this->checkLoop($astArray);
}
private function checkKeyValue($key, $value)
{
switch ($key) {
case 'nodeType':
if (array_key_exists($value, $this->stopNodeTypes)) {
throw new InvalidArgumentException($this->stopNodeTypes[$value]);
}
}
}
private function checkArrayAsAWhole(array $ar)
{
$nodeType = (string) $ar['nodeType'] ?? null;
if ($nodeType === 'Expr_Variable') {
if (!array_key_exists('name', $ar)) {
throw new InvalidArgumentException("Variable names made of expressions are disabled.");
}
}
// Check access to some global variables, like $_GET and $_POST.
// This will prevent both $_GET and "{$_GET}"
if ($nodeType === 'Expr_Variable'
&& array_key_exists('name', $ar)
) {
$varName = $ar['name'];
if (!is_scalar($varName)) {
throw new InvalidArgumentException("Variable names made of expressions are disabled.");
}
if (is_array($varName)) {
// Variable variable, e.g. $$x
if ($this->isVariableVariablesDisabled
&& array_key_exists('nodeType', $varName)
&& $varName['nodeType'] === 'Expr_Variable'
) {
throw new InvalidArgumentException("Variable variables are disabled.");
}
} elseif (in_array($varName, $this->stopVariableNames)) {
$varName = strtoupper($varName);
throw new InvalidArgumentException("Variable {$ar['name']} cannot be used.");
}
}
if ($nodeType === 'Expr_FuncCall'
&& array_key_exists('name', $ar)
&& is_array($ar['name'])
) {
$this->checkFuncName($ar['name']);
}
// Instantiation of a new class
if ($nodeType === 'Expr_New'
&& array_key_exists('class', $ar)
&& is_array($ar['class'])
) {
$this->checkClassName($ar['class']);
}
// Static class of a class member
if ($nodeType === 'Expr_StaticCall'
&& array_key_exists('class', $ar)
&& is_array($ar['class'])
) {
$this->checkClassName($ar['class']);
}
// $this
if ($nodeType === 'Expr_Variable'
&& array_key_exists('name', $ar)
&& strtolower((string)$ar['name']) === 'this'
&& $this->isThisDisabled
) {
throw new InvalidArgumentException('Usage of $this is disabled.');
}
}
private function checkFuncName(array $nameAr)
{
$nodeType = $nameAr['nodeType'] ?? '';
// Fully qualified function name. Example:
// - \constant
// - \Namespace1\constant
if ($nodeType === 'Name_FullyQualified') {
$isFullyQualified = true;
}
// Relative function name. Examples:
// - constant
// - Namespace1\constant
elseif ($nodeType === 'Name') {
$isFullyQualified = false;
}
// Variable function name. Example:
// - $f = 'trim'; $f();
elseif ($nodeType === 'Expr_Variable') {
throw new InvalidArgumentException("Variable function names are disabled.");
}
else {
throw new InvalidArgumentException("Function names cannot be made out of expressions.");
}
// Examples:
// - ['Namespace1', 'constant']
// - ['constant']
$parts = $nameAr['parts'] ?? null;
if (!$parts || !is_array($parts) || !count($parts)) {
return;
}
$isGlobalFunc = count($parts) === 1;
if ($isGlobalFunc) {
$funcName = $parts[0];
if ($this->stringMatchesAny($funcName, $this->stopGlobalFuncNames)) {
throw new InvalidArgumentException("Function ${parts[0]}() disabled.");
}
}
// Check if the namespace containing the function is prohibited
$inProhibitedNamespace = $this->isInNamespace($parts, $this->stopNamespaces);
if ($inProhibitedNamespace) {
throw new InvalidArgumentException("Namespace $inProhibitedNamespace disabled.");
}
}
private function checkClassName(array $nameAr)
{
$nodeType = $nameAr['nodeType'] ?? '';
// Fully qualified class name. Examples:
// - \FilesystemIterator
// - \Namespace1\FilesystemIterator
if ($nodeType === 'Name_FullyQualified') {
$isFullyQualified = true;
}
// Relative class name. Examples:
// FilesystemIterator
// Namespace1\FilesystemIterator
elseif ($nodeType === 'Name') {
$isFullyQualified = false;
}
else {
return;
}
// Examples:
// - ['Namespace1', 'FilesystemIterator']
// - ['FilesystemIterator']
$parts = $nameAr['parts'] ?? null;
if (!$parts || !is_array($parts) || !count($parts)) {
return;
}
$isGlobalClass = count($parts) === 1;
if ($isGlobalClass) {
$className = $parts[0];
if ($this->stringMatchesAny($className, $this->stopGlobalClasses)) {
throw new InvalidArgumentException("Class ${parts[0]} disabled.");
}
}
// Check if the class is in namespace
$inProhibitedNamespace = $this->isInNamespace($parts, $this->stopNamespaces);
if ($inProhibitedNamespace) {
throw new InvalidArgumentException("Namespace $inProhibitedNamespace disabled.");
}
}
private function stringMatches($s, $pattern)
{
$isRegex = substr($pattern, 0, 1) === '/';
if ($isRegex) {
// Pattern match
if (preg_match($pattern, $s)) {
return true;
}
} else {
// Exact match
if (strtolower($pattern) == strtolower($s)) {
return true;
}
}
return false;
}
private function stringMatchesAny($s, $patterns)
{
foreach ($patterns as $p) {
if ($this->stringMatches($s, $p)) {
return true;
}
}
return false;
}
/**
* Checks if the given class name (a string of an array of FQN parts)
* is in one of the given namespaces.
*
* If found the namespace the class name is in, returns that namespace.
* If not found, returns NULL.
*
* @param string|array $namespaceNameOrFullyQualifiedPartsArr
* @param string[] $namespaces
* @return bool|string
*/
private function isInNamespace($namespaceNameOrFullyQualifiedPartsArr, $namespaces)
{
if (is_array($namespaceNameOrFullyQualifiedPartsArr)) {
$namespace = '\\' . join('\\', $namespaceNameOrFullyQualifiedPartsArr);
} else {
$namespace = (string) $namespaceNameOrFullyQualifiedPartsArr;
}
// Loop over namespace names/patterns to check against
foreach ($namespaces as $pattern) {
if (strtolower(substr($namespace, 0, strlen($pattern))) == strtolower($pattern)) {
return $pattern;
}
}
return false;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment