Brian Wolff has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/393540 )
Change subject: Support ParserFunctions, and start of work for hooks in general ...................................................................... Support ParserFunctions, and start of work for hooks in general Also fix bug sometimes function taint would get lost if setting a different taint for another parameter Add -v option to the test runner Change-Id: Idcd74eee6ad5fb38d7c8b81f4cf1058dc26f080b --- M .phpcs.xml A src/MWPreVisitor.php A src/MWVisitor.php M src/MediaWikiSecurityCheckPlugin.php M src/TaintednessBaseVisitor.php M src/TaintednessVisitor.php A tests/integration/callbackhook/Parser.php A tests/integration/callbackhook/db.php A tests/integration/callbackhook/expectedResults.txt A tests/integration/callbackhook/func.php A tests/integration/callbackhook/test.php A tests/integration/stripitem/Parser.php A tests/integration/stripitem/expectedResults.txt A tests/integration/stripitem/test.php M tests/runtests.sh 15 files changed, 833 insertions(+), 17 deletions(-) diff --git a/.phpcs.xml b/.phpcs.xml index 60518ae..8b2fe7e 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -4,6 +4,7 @@ <exclude name="MediaWiki.NamingConventions.PrefixedGlobalFunctions.wfPrefix"/> <exclude name="MediaWiki.Usage.ScalarTypeHintUsage.Found"/> <exclude name="MediaWiki.Commenting.FunctionComment.MissingParamComment"/> + <exclude name="MediaWiki.WhiteSpace.SpaceBeforeSingleLineComment.NewLineComment"/> </rule> <file>.</file> <arg name="bootstrap" value="./vendor/mediawiki/mediawiki-codesniffer/utils/bootstrap-ci.php"/> @@ -23,6 +24,12 @@ <rule ref="MediaWiki.Commenting.FunctionComment.MissingDocumentationPublic"> <exclude-pattern>./tests</exclude-pattern> </rule> + <rule ref="MediaWiki.Commenting.FunctionComment.MissingDocumentationProtected"> + <exclude-pattern>./tests</exclude-pattern> + </rule> + <rule ref="MediaWiki.Commenting.FunctionComment.MissingDocumentationPrivate"> + <exclude-pattern>./tests</exclude-pattern> + </rule> <rule ref="MediaWiki.Files.ClassMatchesFilename.NotMatch"> <exclude-pattern>./tests</exclude-pattern> </rule> diff --git a/src/MWPreVisitor.php b/src/MWPreVisitor.php new file mode 100644 index 0000000..ebe5042 --- /dev/null +++ b/src/MWPreVisitor.php @@ -0,0 +1,119 @@ +<?php + +use Phan\Language\Context; +use ast\Node; +use ast\Node\Decl; +use Phan\Debug; +use Phan\CodeBase; +use Phan\Language\Element\FunctionInterface; + +/** + * Class for visiting any nodes we want to handle in pre-order. + * + */ +class MWPreVisitor extends TaintednessBaseVisitor { + + + /** + * Ensure type of plugin is instance of MediaWikiSecurityCheckPlugin + * + * @param CodeBase $code_base + * @param Context $context + * @param MediaWikiSecurityCheckPlugin + */ + public function __construct( + CodeBase $code_base, + Context $context, + MediaWikiSecurityCheckPlugin $plugin + ) { + parent::__construct( $code_base, $context, $plugin ); + // Ensure phan knows plugin is right type + $this->plugin = $plugin; + } + + /** + * Handle any node not otherwise handled. + * + * Currently a no-op. + * + * @param Node $node + */ + public function visit( Node $node ) { + } + + /** + * @see visitMethod + * @param Decl $node + * @return void Just has a return statement in case visitMethod changes + */ + public function visitFuncDecl( Decl $node ) { + return $this->visitMethod( $node ); + } + + /** + * Set taint for certain hook types. + * + * Also handles FuncDecl + * @param Decl $node + */ + public function visitMethod( Decl $node ) { + $method = $this->context->getFunctionLikeInScope( $this->code_base ); + $hookType = $this->plugin->isSpecialHookSubscriber( $method->getFQSEN() ); + if ( !$hookType ) { + return; + } + $params = $node->children['params']->children; + + switch ( $hookType ) { + case '!ParserFunctionHook': + $this->setFuncHookParamTaint( $params, $method ); + break; + } + } + + /** + * Set the appropriate taint for a parser function hook + * + * Basically all but the first arg comes from wikitext + * and is tainted. + * + * @todo This is handling SFH_OBJECT type func hooks incorrectly. + * @param Node[] $params Children of the AST_PARAM_LIST + * @param FunctionInterface $method + */ + private function setFuncHookParamTaint( array $params, FunctionInterface $method ) { + $this->debug( __METHOD__, "Setting taint for hook $method" ); + $funcTaint = $this->getTaintOfFunction( $method ); + $varObjs = []; + foreach ( $params as $i => $param ) { + if ( $i === 0 ) { + continue; + } + $scope = $this->context->getScope(); + if ( !$scope->hasVariableWithName( $param->children['name'] ) ) { + // Well uh-oh. + $this->debug( __METHOD__, "Missing variable for param \$" . $param->children['name'] ); + continue; + } + $varObj = $scope->getVariableByName( $param->children['name'] ); + $this->setTaintedness( $varObj, SecurityCheckPlugin::YES_TAINT ); + /*** Is this needed ? Disabling for now. + if ( isset( $funcTaint[$i] ) ) { + if ( !$this->isSafeAssignment( + $funcTaint[$i], + SecurityCheckPlugin::YES_TAINT + ) ) { + $funcName = $method->getFQSEN(); + $this->plugin->emitIssue( + $this->code_base, + $this->context, + 'SecurityCheckTaintedOutput', + "Outputting evil HTML from Parser function hook $funcName (case 2)" + . $this->getOriginalTaintLine( $method ) + ); + } + } + */ + } + } +} diff --git a/src/MWVisitor.php b/src/MWVisitor.php new file mode 100644 index 0000000..76a9909 --- /dev/null +++ b/src/MWVisitor.php @@ -0,0 +1,169 @@ +<?php + +use Phan\Language\Context; +use Phan\Language\FQSEN\FullyQualifiedMethodName; +use Phan\Language\FQSEN\FullyQualifiedFunctionName; +use Phan\Language\FQSEN; +use Phan\Plugin; +use Phan\CodeBase; +use ast\Node; + +/** + * MediaWiki specific node visitor + */ +class MWVisitor extends TaintednessBaseVisitor { + + /** + * Constructor to enforce plugin is instance of MediaWikiSecurityCheckPlugin + * + * @param CodeBase $code_base + * @param Context $context + * @param MediaWikiSecurityCheckPlugin $plugin + */ + public function __construct( + CodeBase $code_base, + Context $context, + MediaWikiSecurityCheckPlugin $plugin + ) { + parent::__construct( $code_base, $context, $plugin ); + // Ensure phan knows plugin is right type + $this->plugin = $plugin; + } + + /** + * @param Node $node + */ + public function visit( Node $node ) { + } + + /** + * Try and recognize + * @param Node $node + */ + public function visitMethodCall( Node $node ) { + try { + $ctx = $this->getCtxN( $node ); + $methodName = $node->children['method']; + $method = $ctx->getMethod( + $methodName, + false /* not a static call */ + ); + // Should this be getDefiningFQSEN() instead? + switch ( (string)$method->getFQSEN() ) { + case '\Parser::setFunctionHook': + $this->handleFuncRegistration( $node ); + break; + } + } catch ( Exception $e ) { + // ignore + } + } + + /** + * When someone calls $parser->setFunctionHook() + * + * @note Causes phan to error out if given non-existent class + * @param Node $node + */ + private function handleFuncRegistration( Node $node ) { + $args = $node->children['args']->children; + if ( count( $args ) < 2 ) { + return; + } + $callback = $this->getFQSENFromCallable( $args[1] ); + if ( $callback ) { + $alreadyRegistered = $this->plugin->registerHook( '!ParserFunctionHook', $callback ); + if ( !$alreadyRegistered ) { + // If this is the first time seeing this, re-analyze the + // node, just in case we had already passed it by. + if ( $callback->isClosure() ) { + // For closures we have to reanalyze the parent + // function, as we can't reanalyze the closure, and + // we definitely need to since the closure would + // have already been analyzed at this point since + // we are operating in post-order. + $func = $this->context->getFunctionLikeInScope( $this->code_base ); + } elseif ( $callback instanceof FullyQualifiedMethodName ) { + $func = $this->code_base->getMethodByFQSEN( $callback ); + } else { + assert( $callback instanceof FullyQualifiedFunctionName ); + $func = $this->code_base->getFunctionByFQSEN( $callback ); + } + $func->analyze( + $func->getContext(), + $this->code_base + ); + } + } + } + + /** + * For special hooks, check their return value + * + * e.g. A tag hook's return value is output as html. + * @param Node $node + */ + public function visitReturn( Node $node ) { + if ( + !$this->context->isInFunctionLikeScope() + || !$node->children['expr'] instanceof Node + ) { + return; + } + $funcFQSEN = $this->context->getFunctionLikeFQSEN(); + $hookType = $this->plugin->isSpecialHookSubscriber( $funcFQSEN ); + switch ( $hookType ) { + case '!ParserFunctionHook': + $this->visitReturnOfFunctionHook( $node->children['expr'], $funcFQSEN ); + break; + + } + } + + /** + * Check to see if isHTML => true and is tainted. + * + * @param Node $node The expr child of the return. NOT the return itself + * @suppress PhanTypeMismatchForeach + */ + private function visitReturnOfFunctionHook( Node $node, FQSEN $funcName ) { + if ( + !( $node instanceof Node ) || + $node->kind !== \ast\AST_ARRAY || + count( $node->children ) < 2 + ) { + return; + } + $isHTML = false; + foreach ( $node->children as $child ) { + assert( + $child instanceof Node + && $child->kind === \ast\AST_ARRAY_ELEM + ); + + if ( + $child->children['key'] === 'isHTML' && + $child->children['value'] instanceof Node && + $child->children['value']->kind === \ast\AST_CONST && + $child->children['value']->children['name'] instanceof Node && + $child->children['value']->children['name']->children['name'] === 'true' + ) { + $isHTML = true; + break; + } + } + if ( !$isHTML ) { + return; + } + $taintedness = $this->getTaintedness( $node->children[0] ); + if ( !$this->isSafeAssignment( SecurityCheckPlugin::HTML_EXEC_TAINT, $taintedness ) ) { + $this->plugin->emitIssue( + $this->code_base, + $this->context, + 'SecurityCheckTaintedOutput', + "Outputting evil HTML from Parser function hook $funcName" + . $this->getOriginalTaintLine( $node->children[0] ) + ); + } + } +} diff --git a/src/MediaWikiSecurityCheckPlugin.php b/src/MediaWikiSecurityCheckPlugin.php index 232ecfe..e621b94 100644 --- a/src/MediaWikiSecurityCheckPlugin.php +++ b/src/MediaWikiSecurityCheckPlugin.php @@ -3,8 +3,52 @@ * Copyright Brian Wolff 2017. Released under the GPL version 2 or later. */ require_once __DIR__ . "/SecurityCheckPlugin.php"; +require_once __DIR__ . "/MWVisitor.php"; +require_once __DIR__ . "/MWPreVisitor.php"; + +use Phan\CodeBase; +use Phan\Language\Context; +use Phan\Language\FQSEN\FullyQualifiedFunctionLikeName; +use ast\Node; class MediaWikiSecurityCheckPlugin extends SecurityCheckPlugin { + + /** + * @var Array A mapping from hook names to FQSEN that implement it + */ + protected $hookSubscribers = []; + + /** + * Override so we can check for hook registration + * + * @param CodeBase $code_base + * @param Context $context + * @param Node $node + * @param Node $parentNode + */ + public function analyzeNode( + CodeBase $code_base, + Context $context, + Node $node, + Node $parentNode = null + ) { + parent::analyzeNode( $code_base, $context, $node, $parentNode ); + + $visitor = new MWVisitor( $code_base, $context, $this ); + $visitor( $node ); + } + + /** + * Called on every node in the ast, but in pre-order + * + * @param CodeBase $code_base + * @param Context $context + * @param Node $node + */ + public function preAnalyzeNode( CodeBase $code_base, Context $context, Node $node ) { + parent::preAnalyzeNode( $code_base, $context, $node ); + ( new MWPreVisitor( $code_base, $context, $this ) )( $node ); + } /** * @inheritDoc @@ -76,6 +120,13 @@ SecurityCheckPlugin::HTML_EXEC_TAINT, // meh, not sure how right the overall is. 'overall' => SecurityCheckPlugin::HTML_TAINT + ], + // AddItem should also take care of addGeneral and friends. + '\StripState::addItem' => [ + self::NO_TAINT, // type + self::NO_TAINT, // marker + self::HTML_EXEC_TAINT, // contents + 'overall' => self::NO_TAINT ], // FIXME Doesn't handle array args right. '\wfShellExec' => [ @@ -188,6 +239,68 @@ ]; } + /** + * Add a hook implementation to our list. + * + * This also handles parser hooks which aren't normal hooks. + * Non-normal hooks start their name with a "!" + * + * @param string $hookName Name of hook + * @param FullyQualifiedFunctionLikeName $fqsen The implementing method + * @return bool true if already registered, false otherwise + */ + public function registerHook( string $hookName, FullyQualifiedFunctionLikeName $fqsen ) { + if ( !isset( $this->hookSubscribers[$hookName] ) ) { + $this->hookSubscribers[$hookName] = []; + } + foreach ( $this->hookSubscribers[$hookName] as $subscribe ) { + if ( (string)$subscribe === (string)$fqsen ) { + // dupe + return true; + } + } + $this->hookSubscribers[$hookName][] = $fqsen; + return false; + } + + /** + * Get a list of subscribers for hook + * + * @param string $hookName Hook in question. Hooks starting with ! are special. + * @return FullyQualifiedFunctionLikeName[] + */ + public function getHookSubscribers( string $hookName ) : array { + if ( isset( $this->hookSubscribers[$hookName] ) ) { + return $this->hookSubscribers[$hookName]; + } + return []; + } + + /** + * Is a particular function implementing a special hook. + * + * @note This assumes that any given func will only implement + * one hook + * @param FullyQualifiedFunctionLikeName $fqsen The function to check + * @return string The hook it is implementing + */ + public function isSpecialHookSubscriber( FullyQualifiedFunctionLikeName $fqsen ) { + $specialHooks = [ + '!ParserFunctionHook' + ]; + + // @todo This is probably not the most efficient thing. + foreach ( $specialHooks as $hook ) { + if ( !isset( $this->hookSubscribers[$hook] ) ) { + continue; + } + foreach ( $this->hookSubscribers[$hook] as $implFQSEN ) { + if ( (string)$implFQSEN === (string)$fqsen ) { + return $hook; + } + } + } + } } return new MediaWikiSecurityCheckPlugin; diff --git a/src/TaintednessBaseVisitor.php b/src/TaintednessBaseVisitor.php index 58195b7..52fb30d 100644 --- a/src/TaintednessBaseVisitor.php +++ b/src/TaintednessBaseVisitor.php @@ -9,7 +9,10 @@ use Phan\Language\Element\TypedElementInterface; use Phan\Language\Element\ClassElement; use Phan\Language\UnionType; +use Phan\Language\Type\CallableType; use Phan\Language\FQSEN\FullyQualifiedFunctionLikeName; +use Phan\Language\FQSEN\FullyQualifiedFunctionName; +use Phan\Language\FQSEN\FullyQualifiedMethodName; use Phan\Plugin; use ast\Node; use Phan\Debug; @@ -74,7 +77,10 @@ if ( $override ) { $newTaint = $taint; } - foreach ( $taint as $index => $t ) { + + $bothTaint = $taint + $curTaint; + foreach ( $bothTaint as $index => $_ ) { + $t = $taint[$index] ?? 0; assert( is_int( $t ) ); if ( !$override ) { $newTaint[$index] = ( $curTaint[$index] ?? 0 ) | $t; @@ -192,7 +198,7 @@ $parentVarObj->taintedOriginalError =& $variableObj->taintedOriginalError; } else { - $this->debug( __METHOD__, "var {$variableObj->getName()} does not exist outside branch!" ); + // $this->debug( __METHOD__, "var {$variableObj->getName()} does not exist outside branch!" ); } } // This may not be executed, so it can only increase @@ -825,7 +831,7 @@ $paramTaint = [ 'overall' => SecurityCheckPlugin::NO_TAINT ]; foreach ( $paramInfo as $i => $_ ) { $paramTaint[$i] = $taint; - // $this->debug( __METHOD__ , "Setting method $method" . + // $this->debug( __METHOD__, "Setting method $method" . // " arg $i as $taint due to depenency on $var" ); } $this->setFuncTaint( $method, $paramTaint ); @@ -1114,4 +1120,161 @@ assert( is_int( $t ) && $t >= 0, "Taint index $i wrong $t" . $this->dbgInfo() ); } } + + /** + * Given an AST node that's a callable, try and determine what it is + * + * This is intended for functions that register callbacks. It will + * only really work for callbacks that are basically literals. + * + * @note $node may not be the current node in $this->context. + * + * @param Node|string $node The thingy from AST expected to be a Callable + * @return FullyQualifiedFunctionLikeName|null The corresponding FQSEN + */ + protected function getFQSENFromCallable( $node ) { + $callback = null; + if ( is_string( $node ) ) { + // Easy case, 'Foo::Bar' + if ( strpos( '::', $callback ) === false ) { + $callback = FullyQualifiedFunctionName::fromFullyQualifiedString( + $node + ); + } else { + $callback = FullyQualifiedMethodName::fromFullyQualifiedString( + $node + ); + } + } elseif ( $node instanceof Node && $node->kind === \ast\AST_CLOSURE ) { + $method = ( + new ContextNode( + $this->code_base, + $this->context->withLineNumberStart( + $node->lineno ?? 0 + ), + $node + ) + )->getClosure(); + $callback = $method->getFQSEN(); + } elseif ( + $node instanceof Node + && $node->kind === \ast\AST_VAR + && is_string( $node->children['name'] ) + ) { + $cnode = $this->getCtxN( $node ); + $var = $cnode->getVariable(); + $types = $var->getUnionType()->getTypeSet(); + foreach ( $types as $type ) { + if ( + $type instanceof CallableType && + $type->asFQSEN() instanceof FullyQualifiedFunctionLikeName + ) { + // @todo FIXME This doesn't work if the closure + // is defined in a different function scope + // then the one we are currently in. Perhaps + // we could look up the closure in + // $this->code_base to figure out what func + // its defined on via its parent scope. Or + // something. + $callback = $type->asFQSEN(); + break; + } + } + } elseif ( $node instanceof Node && $node->kind === \ast\AST_ARRAY ) { + if ( count( $node->children ) !== 2 ) { + return null; + } + if ( + $node->children[0]->children['key'] !== null || + $node->children[1]->children['key'] !== null || + !is_string( $node->children[1]->children['value'] ) + ) { + return null; + } + $methodName = $node->children[1]->children['value']; + $classNode = $node->children[0]->children['value']; + if ( is_string( $node->children[0]->children['value'] ) ) { + $className = $classNode; + } elseif ( $classNode instanceof Node ) { + switch ( $classNode->kind ) { + case \ast\AST_MAGIC_CONST: + // Mostly a special case for MediaWiki + // CoreParserFunctions.php + if ( + $classNode->flags & \ast\flags\MAGIC_CLASS !== 0 + && $this->context->isInClassScope() + ) { + $className = (string)$this->context->getClassFQSEN(); + } else { + return null; + } + break; + case \ast\AST_CLASS_CONST: + if ( + $classNode->children['const'] === 'class' && + $classNode->children['class']->kind === \ast\AST_NAME && + is_string( $classNode->children['class']->children['name'] ) + ) { + $className = $classNode->children['class']->children['name']; + } else { + return null; + } + break; + case \ast\AST_VAR: + $var = $this->getCtxN( $classNode )->getVariable(); + $type = $var->getUnionType(); + if ( $type->typeCount() !== 1 || $type->isScalar() ) { + return null; + } + $cl = $type->asClassList( + $this->code_base, + $this->context + ); + $clazz = false; + foreach ( $cl as $item ) { + $clazz = $item; + break; + } + if ( !$clazz ) { + return null; + } + $className = (string)$clazz->getFQSEN(); + break; + case \ast\AST_PROP: + $var = $this->getCtxN( $classNode ) + ->getProperty( $classNode->children['prop'] ); + $type = $var->getUnionType(); + if ( $type->typeCount() !== 1 || $type->isScalar() ) { + return null; + } + $cl = $type->asClassList( + $this->code_base, + $this->context + ); + $clazz = false; + foreach ( $cl as $item ) { + $clazz = $item; + break; + } + if ( !$clazz ) { + return null; + } + $className = (string)$clazz->getFQSEN(); + break; + default: + return null; + } + + } else { + return null; + } + // Note, not from in context, since this goes to call_user_func. + $callback = FullyQualifiedMethodName::fromFullyQualifiedString( + $className . '::' . $methodName + ); + } else { + return null; + } + return $callback; + } } diff --git a/src/TaintednessVisitor.php b/src/TaintednessVisitor.php index aa4b54c..7a45000 100644 --- a/src/TaintednessVisitor.php +++ b/src/TaintednessVisitor.php @@ -104,6 +104,22 @@ * @param Node $node * @return int Taint */ + public function visitUseElem( Node $node ) : int { + return SecurityCheckPlugin::INAPLICABLE_TAINT; + } + + /** + * @param Node $node + * @return int Taint + */ + public function visitType( Node $node ) : int { + return SecurityCheckPlugin::INAPLICABLE_TAINT; + } + + /** + * @param Node $node + * @return int Taint + */ public function visitArgList( Node $node ) : int { return SecurityCheckPlugin::INAPLICABLE_TAINT; } @@ -546,8 +562,8 @@ if ( isset( $taint[$i] ) ) { $effectiveArgTaintedness = $curArgTaintedness & ( $taint[$i] | $this->execToYesTaint( $taint[$i] ) ); - # $this->debug( __METHOD__, "effective $effectiveArgTaintedness" - # . " via arg $i $funcName" ); + // $this->debug( __METHOD__, "effective $effectiveArgTaintedness" + // . " via arg $i $funcName" ); } elseif ( ( $taint['overall'] & ( SecurityCheckPlugin::PRESERVE_TAINT | SecurityCheckPlugin::UNKNOWN_TAINT ) ) ) { @@ -621,8 +637,8 @@ // someFunc( $execArg ) for pass by reference where // the parameter is later executed outside the func. if ( $func && $this->isYesTaint( $curArgTaintedness ) ) { - # $this->debug( __METHOD__, "cur arg $i is YES taint " . - # "($curArgTaintedness). Marking dependent $funcName" ); + // $this->debug( __METHOD__, "cur arg $i is YES taint " . + // "($curArgTaintedness). Marking dependent $funcName" ); // Mark all dependent vars as tainted. $this->markAllDependentVarsYes( $func, $i ); } @@ -631,7 +647,7 @@ // where $arg is a parameter to the current function. // So backpropagate that assigning to $arg can cause evilness. if ( $this->isExecTaint( $taint[$i] ?? 0 ) ) { - # $this->debug( __METHOD__, "cur param is EXEC. $funcName" ); + // $this->debug( __METHOD__, "cur param is EXEC. $funcName" ); try { $phanObjs = $this->getPhanObjsForNode( $argument ); foreach ( $phanObjs as $phanObj ) { @@ -646,7 +662,7 @@ // gets passed to return value, but which taint is EXECed. // $this->debug( __METHOD__, "Checking safe assing $funcName" . // " arg=$i paramTaint= " . ( $taint[$i] ?? "MISSING" ) . - // " vs argTaint= $curArgTaintedness" ); + // " vs argTaint= $curArgTaintedness" ); if ( !$this->isSafeAssignment( $taint[$i] ?? 0, $curArgTaintedness ) ) { $containingMethod = $this->getCurrentMethod(); $this->plugin->emitIssue( diff --git a/tests/integration/callbackhook/Parser.php b/tests/integration/callbackhook/Parser.php new file mode 100644 index 0000000..9def58e --- /dev/null +++ b/tests/integration/callbackhook/Parser.php @@ -0,0 +1,64 @@ +<?php + +class Parser { + + const MARKER_SUFFIX = "-QINU`\"'\x7f"; + const MARKER_PREFIX = "\x7f'\"`UNIQ-"; + /** @var int */ + private $mMarkerIndex = 0; + /** @var StripState */ + public $mStripState; + public function setFunctionHook( $id, callable $cb, $opts = 0 ) { + } + + public function __construct() { + $this->mStripState = new StripState; + } + public function bar( Parser $parser, $arg1, $arg2 ) { + return $arg1; + } + + public function insertStripItem( $text ) { + $marker = self::MARKER_PREFIX . "-item-{$this->mMarkerIndex}-" . self::MARKER_SUFFIX; + $this->mMarkerIndex++; + $this->mStripState->addGeneral( $marker, $text ); + return $marker; + } + +} + +class StripState { + protected $prefix; + protected $data; + protected $regex; + + protected $tempType, $tempMergePrefix; + protected $circularRefGuard; + protected $recursionLevel = 0; + + const UNSTRIP_RECURSION_LIMIT = 20; + protected function addItem( $type, $marker, $value ) { + if ( !preg_match( $this->regex, $marker, $m ) ) { + throw new Exception( "Invalid marker: $marker" ); + } + + $this->data[$type][$m[1]] = $value; + } + + /** + * Add a nowiki strip item + * @param string $marker + * @param string $value + */ + public function addNoWiki( $marker, $value ) { + $this->addItem( 'nowiki', $marker, $value ); + } + + /** + * @param string $marker + * @param string $value + */ + public function addGeneral( $marker, $value ) { + $this->addItem( 'general', $marker, $value ); + } +} diff --git a/tests/integration/callbackhook/db.php b/tests/integration/callbackhook/db.php new file mode 100644 index 0000000..93be506 --- /dev/null +++ b/tests/integration/callbackhook/db.php @@ -0,0 +1,20 @@ +<?php +namespace Wikimedia\Rdbms; + +interface IDatabase { + public function query( $sql, $method ); +} + +class Database implements IDatabase { + public function query( $sql, $method = 'query' ) { + // do some stuff + return [ (object)[ 'some_field' => 'some value' ] ]; + } + +} + +class MysqlDatabase extends Database { + public function getType() { + return 'mysql'; + } +} diff --git a/tests/integration/callbackhook/expectedResults.txt b/tests/integration/callbackhook/expectedResults.txt new file mode 100644 index 0000000..550ef70 --- /dev/null +++ b/tests/integration/callbackhook/expectedResults.txt @@ -0,0 +1,7 @@ +integration/callbackhook/func.php:5 SecurityCheckTaintedOutput Outputting evil HTML from Parser function hook \wfSomeFunc (Caused by: integration/callbackhook/func.php +3; integration/callbackhook/func.php +4) +integration/callbackhook/func.php:5 SecurityCheckTaintedOutput Outputting evil HTML from Parser function hook \wfSomeFunc (Caused by: integration/callbackhook/func.php +3; integration/callbackhook/func.php +4; integration/callbackhook/func.php +3; integration/callbackhook/func.php +4) +integration/callbackhook/test.php:12 SecurityCheckTaintedOutput Outputting evil HTML from Parser function hook \closure_eabad951a64c +integration/callbackhook/test.php:20 SecurityCheckTaintedOutput Outputting evil HTML from Parser function hook \closure_fbf3d4f34852 +integration/callbackhook/test.php:30 SecurityCheckTaintedOutput Calling Method \Wikimedia\Rdbms\MysqlDatabase::query in \MyNS\SomeClass::bar that outputs using tainted (2040; 64) argument $[arg #1]. (Caused by: integration/callbackhook/test.php +27; integration/callbackhook/test.php +30; integration/callbackhook/test.php +30) +integration/callbackhook/test.php:30 SecurityCheckTaintedOutput Calling Method \Wikimedia\Rdbms\MysqlDatabase::query in \MyNS\SomeClass::bar that outputs using tainted (680; 64) argument $[arg #1]. (Caused by: integration/callbackhook/test.php +27; integration/callbackhook/test.php +30) +integration/callbackhook/test.php:40 SecurityCheckTaintedOutput Outputting evil HTML from Parser function hook \MyNS\SomeClass::baz (Caused by: integration/callbackhook/test.php +39) diff --git a/tests/integration/callbackhook/func.php b/tests/integration/callbackhook/func.php new file mode 100644 index 0000000..eef9f60 --- /dev/null +++ b/tests/integration/callbackhook/func.php @@ -0,0 +1,6 @@ +<?php +function wfSomeFunc() { + $someEvil = $_GET['d']; + $someEvil .= "More text"; + return [ $someEvil, 'isHTML' => true ]; +} diff --git a/tests/integration/callbackhook/test.php b/tests/integration/callbackhook/test.php new file mode 100644 index 0000000..148501f --- /dev/null +++ b/tests/integration/callbackhook/test.php @@ -0,0 +1,47 @@ +<?php +namespace MyNS; + +use \Parser; +use Wikimedia\Rdbms\MysqlDatabase; + +class SomeClass { + public function register() { + $parser = new Parser; + + $indirectClosure = function ( Parser $parser, $arg ) { + return [ $arg, 'isHTML' => true ]; + }; + + $parser->setFunctionHook( 'something', [ __CLASS__, 'bar' ] ); + $parser->setFunctionHook( 'something', [ __CLASS__, 'baz' ] ); + + $parser->setFunctionHook( 'afunc', 'wfSomeFunc' ); + $parser->setFunctionHook( 'two', function ( Parser $parser, $arg1 ) { + return [ $_GET['d'], 'isHTML' => true ]; + } + ); + + $parser->setFunctionHook( 'three', $indirectClosure ); + } + + public function bar( Parser $parser, $arg1, $arg2 ) { + $out = ''; + $db = new MysqlDatabase; + $res = $db->query( "Select foo from someTable WHERE field = '$arg2' LIMIT 1" ); + foreach ( $res as $row ) { + $out .= $row->foo; + } + $out .= $arg1; + + return $out; + } + + public function baz( Parser $parser, $arg1 ) { + return [ $arg1, 'isHTML' => true ]; + } +} + +function wfSomeFunc() { + // This should not trigger because its in wrong NS + return [ $_GET['d'], 'isHTML' => true ]; +} diff --git a/tests/integration/stripitem/Parser.php b/tests/integration/stripitem/Parser.php new file mode 100644 index 0000000..ac3ebc1 --- /dev/null +++ b/tests/integration/stripitem/Parser.php @@ -0,0 +1,61 @@ +<?php + +class Parser { + + const MARKER_SUFFIX = "-QINU`\"'\x7f"; + const MARKER_PREFIX = "\x7f'\"`UNIQ-"; + /** @var int */ + private $mMarkerIndex = 0; + /** @var StripState */ + public $mStripState; + public function setFunctionHook( $id, callable $cb, $opts = 0 ) { + } + + public function __construct() { + $this->mStripState = new StripState; + } + + public function insertStripItem( $text ) { + $marker = self::MARKER_PREFIX . "-item-{$this->mMarkerIndex}-" . self::MARKER_SUFFIX; + $this->mMarkerIndex++; + $this->mStripState->addGeneral( $marker, $text ); + return $marker; + } + +} + +class StripState { + protected $prefix; + protected $data; + protected $regex; + + protected $tempType, $tempMergePrefix; + protected $circularRefGuard; + protected $recursionLevel = 0; + + const UNSTRIP_RECURSION_LIMIT = 20; + protected function addItem( $type, $marker, $value ) { + if ( !preg_match( $this->regex, $marker, $m ) ) { + throw new Exception( "Invalid marker: $marker" ); + } + + $this->data[$type][$m[1]] = $value; + } + + /** + * Add a nowiki strip item + * @param string $marker + * @param string $value + */ + public function addNoWiki( $marker, $value ) { + $this->addItem( 'nowiki', $marker, $value ); + } + + /** + * @param string $marker + * @param string $value + */ + public function addGeneral( $marker, $value ) { + $this->addItem( 'general', $marker, $value ); + } +} diff --git a/tests/integration/stripitem/expectedResults.txt b/tests/integration/stripitem/expectedResults.txt new file mode 100644 index 0000000..e5e03e8 --- /dev/null +++ b/tests/integration/stripitem/expectedResults.txt @@ -0,0 +1,4 @@ +integration/stripitem/test.php:11 SecurityCheckTaintedOutput Calling Method \Parser::insertStripItem in \SomeClass::xyzzy that outputs using tainted (2040; 1360) argument $arg1. (Caused by: integration/stripitem/Parser.php +22; integration/stripitem/Parser.php +22) (Caused by: integration/stripitem/test.php +10; integration/stripitem/test.php +11; integration/stripitem/test.php +11) +integration/stripitem/test.php:11 SecurityCheckTaintedOutput Calling Method \Parser::insertStripItem in \SomeClass::xyzzy that outputs using tainted (2040; 1360) argument $arg1. (Caused by: integration/stripitem/Parser.php +22; integration/stripitem/Parser.php +22; integration/stripitem/Parser.php +22) (Caused by: integration/stripitem/test.php +10; integration/stripitem/test.php +11; integration/stripitem/test.php +11) +integration/stripitem/test.php:11 SecurityCheckTaintedOutput Calling Method \Parser::insertStripItem in \SomeClass::xyzzy that outputs using tainted (680; 1360) argument $arg1. (Caused by: integration/stripitem/Parser.php +22; integration/stripitem/Parser.php +22) (Caused by: integration/stripitem/test.php +10; integration/stripitem/test.php +11) +integration/stripitem/test.php:11 SecurityCheckTaintedOutput Calling Method \Parser::insertStripItem in \SomeClass::xyzzy that outputs using tainted (680; 1360) argument $arg1. (Caused by: integration/stripitem/Parser.php +22; integration/stripitem/Parser.php +22; integration/stripitem/Parser.php +22) (Caused by: integration/stripitem/test.php +10; integration/stripitem/test.php +11) diff --git a/tests/integration/stripitem/test.php b/tests/integration/stripitem/test.php new file mode 100644 index 0000000..ba8aaf5 --- /dev/null +++ b/tests/integration/stripitem/test.php @@ -0,0 +1,14 @@ +<?php + +class SomeClass { + public function register() { + $parser = new Parser; + + $parser->setFunctionHook( 'something', [ __CLASS__, 'xyzzy' ] ); + } + + public function xyzzy( Parser $parser, $arg1 ) { + $a = $parser->insertStripItem( $arg1 ); + return $a; + } +} diff --git a/tests/runtests.sh b/tests/runtests.sh index d5bcd5c..a3de9af 100755 --- a/tests/runtests.sh +++ b/tests/runtests.sh @@ -1,18 +1,25 @@ #!/bin/bash -# If you want to see debug output -# call with SECCHECK_DEBUG=/dev/stderr -# environment variable. # -# If you want to run a specific test -# then give it as first arg. +# Run the tests in integration. +# Usage: ./runtests.sh [-v] [testname] +# +# -v will add debug output. testname will run only that test +# + cd `dirname $0`/ -testList=${1:-`ls integration`} tmpFile=`mktemp testtmp.XXXXXXXX` +trap "rm $tmpFile" exit totalTests=0 failedTests=0 php=`which php7.0` php=${php:-`which php`} -SECCHECK_DEBUG=${SECCHECK_DEBUG:-/dev/null} +if [ "$1" = '-v' ] + then SECCHECK_DEBUG=/dev/stderr + shift + export SECCHECK_DEBUG + else SECCHECK_DEBUG=${SECCHECK_DEBUG:-/dev/null} +fi +testList=${1:-`ls integration`} for i in $testList do @@ -28,7 +35,6 @@ then failedTests=$((failedTests+1)) fi done -rm $tmpFile if [ $failedTests -gt 0 ] then echo $failedTests out of $totalTests failed. exit 1 -- To view, visit https://gerrit.wikimedia.org/r/393540 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: Idcd74eee6ad5fb38d7c8b81f4cf1058dc26f080b Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/tools/phan/SecurityCheckPlugin Gerrit-Branch: master Gerrit-Owner: Brian Wolff <bawolff...@gmail.com> Gerrit-Reviewer: Brian Wolff <bawolff...@gmail.com> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits