Last active
April 29, 2020 13:01
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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