Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F4077011
MWVisitor.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
35 KB
Referenced Files
None
Subscribers
None
MWVisitor.php
View Options
<?php
namespace
SecurityCheckPlugin
;
use
ast\Node
;
use
Phan\Analysis\PostOrderAnalysisVisitor
;
use
Phan\AST\ContextNode
;
use
Phan\Exception\CodeBaseException
;
use
Phan\Exception\InvalidFQSENException
;
use
Phan\Exception\IssueException
;
use
Phan\Language\Element\FunctionInterface
;
use
Phan\Language\Element\Method
;
use
Phan\Language\FQSEN\FullyQualifiedClassName
;
use
Phan\Language\FQSEN\FullyQualifiedFunctionLikeName
;
use
Phan\Language\FQSEN\FullyQualifiedFunctionName
;
use
Phan\Language\FQSEN\FullyQualifiedMethodName
;
use
Phan\Language\UnionType
;
/**
* MediaWiki specific node visitor
*
* Copyright (C) 2017 Brian Wolff <bawolff@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
class
MWVisitor
extends
TaintednessVisitor
{
/**
* @todo This is a temporary hack. Proper solution is refactoring/avoiding overrideContext
* @var bool|null
* @suppress PhanWriteOnlyProtectedProperty
*/
protected
$isHook
;
/**
* Try and recognize hook registration
* @inheritDoc
*/
protected
function
analyzeCallNode
(
Node
$node
,
iterable
$funcs
):
void
{
parent
::
analyzeCallNode
(
$node
,
$funcs
);
if
(
!
isset
(
$node
->
children
[
'method'
]
)
)
{
// Called by visitCall
return
;
}
assert
(
is_array
(
$funcs
)
&&
count
(
$funcs
)
===
1
);
$method
=
$funcs
[
0
];
assert
(
$method
instanceof
Method
);
// Should this be getDefiningFQSEN() instead?
$methodName
=
(
string
)
$method
->
getFQSEN
();
$parserFQSEN
=
MediaWikiHooksHelper
::
getInstance
()->
getMwParserClassFQSEN
(
$this
->
code_base
)->
__toString
();
// $this->debug( __METHOD__, "Checking to see if we should register $methodName" );
switch
(
$methodName
)
{
case
"$parserFQSEN::setFunctionHook"
:
case
"$parserFQSEN::setHook"
:
$type
=
$this
->
getHookTypeForRegistrationMethod
(
$methodName
);
if
(
$type
===
null
)
{
break
;
}
// $this->debug( __METHOD__, "registering $methodName as $type" );
$this
->
handleParserHookRegistration
(
$node
,
$type
);
break
;
case
'
\H
ooks::register'
:
$this
->
handleNormalHookRegistration
(
$node
);
break
;
case
'
\H
ooks::run'
:
case
'
\H
ooks::runWithoutAbort'
:
$this
->
triggerHook
(
$node
);
break
;
case
'
\L
inker::makeExternalLink'
:
$this
->
checkExternalLink
(
$node
);
break
;
default
:
$this
->
doSelectWrapperSpecialHandling
(
$node
,
$method
);
}
}
/**
* Linker::makeExternalLink escaping depends on third argument
*
* @param Node $node
*/
private
function
checkExternalLink
(
Node
$node
):
void
{
$escapeArg
=
$this
->
resolveValue
(
$node
->
children
[
'args'
]->
children
[
2
]
??
true
);
$text
=
$node
->
children
[
'args'
]->
children
[
1
]
??
null
;
if
(
!
$escapeArg
&&
$text
instanceof
Node
)
{
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
HTML_EXEC_TAINT
),
$text
,
"Calling Linker::makeExternalLink with user controlled text "
.
"and third argument set to false"
);
}
}
/**
* Special casing for complex format of IDatabase::select
*
* This handles the $options, and $join_cond. Other args are
* handled through normal means
*
* @param Node $node Either an AST_METHOD_CALL or AST_STATIC_CALL
* @param Method $method
*/
private
function
doSelectWrapperSpecialHandling
(
Node
$node
,
Method
$method
):
void
{
$relevantMethods
=
[
'makeList'
=>
true
,
'select'
=>
true
,
'selectField'
=>
true
,
'selectFieldValues'
=>
true
,
'selectSQLText'
=>
true
,
'selectRowCount'
=>
true
,
'selectRow'
=>
true
,
];
if
(
!
isset
(
$relevantMethods
[
$method
->
getName
()]
)
)
{
return
;
}
$idbFQSEN
=
FullyQualifiedClassName
::
fromFullyQualifiedString
(
'
\\
Wikimedia
\\
Rdbms
\\
IDatabase'
);
if
(
!
self
::
isSubclassOf
(
$method
->
getClassFQSEN
(),
$idbFQSEN
,
$this
->
code_base
)
)
{
return
;
}
if
(
$method
->
getName
()
===
'makeList'
)
{
$this
->
checkMakeList
(
$node
);
return
;
}
$args
=
$node
->
children
[
'args'
]->
children
;
if
(
isset
(
$args
[
4
]
)
)
{
$this
->
checkSQLOptions
(
$args
[
4
]
);
}
if
(
isset
(
$args
[
5
]
)
)
{
$this
->
checkJoinCond
(
$args
[
5
]
);
}
}
/**
* Dispatch a hook (i.e. Handle Hooks::run)
*
* @param Node $node The Hooks::run AST_STATIC_CALL
*/
private
function
triggerHook
(
Node
$node
):
void
{
$argList
=
$node
->
children
[
'args'
]->
children
;
if
(
count
(
$argList
)
===
0
)
{
$this
->
debug
(
__METHOD__
,
"Too few args to Hooks::run"
);
return
;
}
if
(
!
is_string
(
$argList
[
0
]
)
)
{
$this
->
debug
(
__METHOD__
,
"Cannot determine hook name"
);
return
;
}
'@phan-var array{0:string,1?:Node} $argList'
;
$hookName
=
$argList
[
0
];
if
(
count
(
$argList
)
<
2
||
$argList
[
1
]->
kind
!==
\ast\AST_ARRAY
)
{
// @todo There are definitely cases where this
// will prevent us from running hooks
// e.g. EditPageGetPreviewContent
$this
->
debug
(
__METHOD__
,
"Could not run hook $hookName due to complex args"
);
return
;
}
$args
=
$this
->
extractHookArgs
(
$argList
[
1
]
);
$hasPassByRef
=
self
::
hookArgsContainReference
(
$argList
[
1
]
);
$analyzer
=
new
PostOrderAnalysisVisitor
(
$this
->
code_base
,
$this
->
context
,
[]
);
$argumentTypes
=
array_fill
(
0
,
count
(
$args
),
UnionType
::
empty
()
);
$subscribers
=
MediaWikiHooksHelper
::
getInstance
()->
getHookSubscribers
(
$hookName
);
foreach
(
$subscribers
as
$subscriber
)
{
if
(
$subscriber
instanceof
FullyQualifiedMethodName
)
{
if
(
!
$this
->
code_base
->
hasMethodWithFQSEN
(
$subscriber
)
)
{
$this
->
debug
(
__METHOD__
,
"Hook subscriber $subscriber not found!"
);
continue
;
}
$func
=
$this
->
code_base
->
getMethodByFQSEN
(
$subscriber
);
}
else
{
assert
(
$subscriber
instanceof
FullyQualifiedFunctionName
);
if
(
!
$this
->
code_base
->
hasFunctionWithFQSEN
(
$subscriber
)
)
{
$this
->
debug
(
__METHOD__
,
"Hook subscriber $subscriber not found!"
);
continue
;
}
$func
=
$this
->
code_base
->
getFunctionByFQSEN
(
$subscriber
);
}
// $this->debug( __METHOD__, "Dispatching $hookName to $subscriber" );
// This is hacky, but try to ensure that the associated line
// number for any issues is in the extension, and not the
// line where the Hooks::register() is in MW core.
// FIXME: In the case of reference parameters, this is
// still reporting things being in MW core instead of extension.
$oldContext
=
$this
->
overrideContext
;
$fContext
=
$func
->
getContext
();
$newContext
=
clone
$this
->
context
;
$newContext
=
$newContext
->
withFile
(
$fContext
->
getFile
()
)
->
withLineNumberStart
(
$fContext
->
getLineNumberStart
()
);
$this
->
overrideContext
=
$newContext
;
$this
->
isHook
=
true
;
if
(
$hasPassByRef
)
{
// Trigger an analysis of the function call (see e.g. ClosureReturnTypeOverridePlugin's
// handling of call_user_func_array). Note that it's not enough to use our
// handleMethodCall, because that doesn't handle references correctly.
// NOTE: This is only known to be necessary with references, hence the check above
// (for performance). There might be other edge cases, though...
// TODO We don't care about types, so we use an empty union type. However this looks
// very very fragile.
// TODO 2: Someday we could write a generic-purpose MW plugin, which could (among other
// things) understand hook. It could share some code with taint-check, and at that
// point we'd likely want to use the correct types here (note that phan alone isn't
// able to analyze hooks at all).
$analyzer
->
analyzeCallableWithArgumentTypes
(
$argumentTypes
,
$func
,
$args
);
}
$this
->
handleMethodCall
(
$func
,
$subscriber
,
$args
,
false
,
true
);
$this
->
overrideContext
=
$oldContext
;
$this
->
isHook
=
false
;
}
}
/**
* Check whether any argument to (inside an array) is a reference.
*
* @param Node $argArrayNode
* @return bool
*/
private
static
function
hookArgsContainReference
(
Node
$argArrayNode
):
bool
{
foreach
(
$argArrayNode
->
children
as
$child
)
{
if
(
$child
instanceof
Node
&&
(
$child
->
flags
&
\ast\flags\ARRAY_ELEM_REF
)
)
{
return
true
;
}
}
return
false
;
}
/**
* Convenience methods for extracting hooks arguments. Copied from
* ClosureReturnTypeOverridePlugin::extractArrayArgs (which is private)
* and simplified for our use case.
*
* @param Node $argArrayNode
* @return Node[]
*/
private
function
extractHookArgs
(
Node
$argArrayNode
):
array
{
assert
(
$argArrayNode
->
kind
===
\ast\AST_ARRAY
);
$arguments
=
[];
foreach
(
$argArrayNode
->
children
as
$child
)
{
if
(
!(
$child
instanceof
Node
)
)
{
continue
;
}
$arguments
[]
=
$child
->
children
[
'value'
];
}
return
$arguments
;
}
/**
* @param string $method The method name of the registration function
* @return string|null The name of the hook that gets registered
*/
private
function
getHookTypeForRegistrationMethod
(
string
$method
):
?
string
{
$parserFQSEN
=
MediaWikiHooksHelper
::
getInstance
()->
getMwParserClassFQSEN
(
$this
->
code_base
)->
__toString
();
switch
(
$method
)
{
case
"$parserFQSEN::setFunctionHook"
:
return
'!ParserFunctionHook'
;
case
"$parserFQSEN::setHook"
:
return
'!ParserHook'
;
default
:
$this
->
debug
(
__METHOD__
,
"$method not a hook registerer"
);
return
null
;
}
}
/**
* Handle registering a normal hook from Hooks::register (Not from $wgHooks)
*
* @param Node $node The node representing the AST_STATIC_CALL
*/
private
function
handleNormalHookRegistration
(
Node
$node
):
void
{
assert
(
$node
->
kind
===
\ast\AST_STATIC_CALL
);
$params
=
$node
->
children
[
'args'
]->
children
;
if
(
count
(
$params
)
<
2
)
{
$this
->
debug
(
__METHOD__
,
"Could not understand Hooks::register"
);
return
;
}
$hookName
=
$params
[
0
];
if
(
!
is_string
(
$hookName
)
)
{
$this
->
debug
(
__METHOD__
,
"Could not register hook. Name is complex"
);
return
;
}
$cb
=
$this
->
getCallableFromHookRegistration
(
$params
[
1
],
$hookName
);
if
(
$cb
)
{
$this
->
registerHook
(
$hookName
,
$cb
);
}
else
{
$this
->
debug
(
__METHOD__
,
"Could not register $hookName hook due to complex callback"
);
}
}
/**
* When someone calls $parser->setFunctionHook() or setTagHook()
*
* @note Causes phan to error out if given non-existent class
* @param Node $node The AST_METHOD_CALL node
* @param string $hookType The name of the hook
*/
private
function
handleParserHookRegistration
(
Node
$node
,
string
$hookType
):
void
{
$args
=
$node
->
children
[
'args'
]->
children
;
if
(
count
(
$args
)
<
2
)
{
return
;
}
$callback
=
$this
->
getCallableFromNode
(
$args
[
1
]
);
if
(
$callback
)
{
$this
->
registerHook
(
$hookType
,
$callback
);
}
}
/**
* @param string $hookType
* @param FunctionInterface $callback
*/
private
function
registerHook
(
string
$hookType
,
FunctionInterface
$callback
):
void
{
$fqsen
=
$callback
->
getFQSEN
();
$alreadyRegistered
=
MediaWikiHooksHelper
::
getInstance
()->
registerHook
(
$hookType
,
$fqsen
);
if
(
!
$alreadyRegistered
)
{
// $this->debug( __METHOD__, "registering $fqsen for hook $hookType" );
// If this is the first time seeing this, make sure we reanalyze the hook function now that
// we know what it is, in case it's already been analyzed.
$this
->
analyzeFunc
(
$callback
);
}
}
/**
* 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
):
void
{
parent
::
visitReturn
(
$node
);
if
(
!
$node
->
children
[
'expr'
]
instanceof
Node
||
!
$this
->
context
->
isInFunctionLikeScope
()
)
{
return
;
}
$funcFQSEN
=
$this
->
context
->
getFunctionLikeFQSEN
();
if
(
strpos
(
(
string
)
$funcFQSEN
,
'::getQueryInfo'
)
!==
false
)
{
$this
->
handleGetQueryInfoReturn
(
$node
->
children
[
'expr'
]
);
}
$hookType
=
MediaWikiHooksHelper
::
getInstance
()->
isSpecialHookSubscriber
(
$funcFQSEN
);
switch
(
$hookType
)
{
case
'!ParserFunctionHook'
:
$this
->
visitReturnOfFunctionHook
(
$node
->
children
[
'expr'
],
$funcFQSEN
);
break
;
case
'!ParserHook'
:
$ret
=
$node
->
children
[
'expr'
];
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
HTML_EXEC_TAINT
),
$ret
,
"Outputting user controlled HTML from Parser tag hook {FUNCTIONLIKE}"
,
[
$funcFQSEN
]
);
break
;
}
}
/**
* Methods named getQueryInfo() in MediaWiki usually
* return an array that is later fed to select
*
* @note This will only work where the return
* statement is an array literal.
* @param Node|mixed $node Node from ast tree
*/
private
function
handleGetQueryInfoReturn
(
$node
):
void
{
if
(
!(
$node
instanceof
Node
)
||
$node
->
kind
!==
\ast\AST_ARRAY
)
{
return
;
}
// The argument order is
// $table, $vars, $conds = '', $fname = __METHOD__,
// $options = [], $join_conds = []
$keysToArg
=
[
'tables'
=>
0
,
'fields'
=>
1
,
'conds'
=>
2
,
'options'
=>
4
,
'join_conds'
=>
5
,
];
$args
=
[
''
,
''
,
''
,
''
];
foreach
(
$node
->
children
as
$child
)
{
assert
(
$child
->
kind
===
\ast\AST_ARRAY_ELEM
);
$key
=
$child
->
children
[
'key'
];
if
(
$key
instanceof
Node
)
{
// Dynamic name, skip (T268055).
continue
;
}
if
(
!
isset
(
$keysToArg
[
$key
]
)
)
{
continue
;
}
$args
[
$keysToArg
[
$key
]]
=
$child
->
children
[
'value'
];
}
$selectFQSEN
=
FullyQualifiedMethodName
::
fromFullyQualifiedString
(
'
\W
ikimedia
\R
dbms
\I
Database::select'
);
if
(
!
$this
->
code_base
->
hasMethodWithFQSEN
(
$selectFQSEN
)
)
{
// Huh. Core wasn't parsed. That's bad, but don't fail hard.
$this
->
debug
(
__METHOD__
,
'Database::select does not exist.'
);
return
;
}
$select
=
$this
->
code_base
->
getMethodByFQSEN
(
$selectFQSEN
);
// TODO: The message about calling Database::select here is not very clear.
$this
->
handleMethodCall
(
$select
,
$selectFQSEN
,
$args
,
false
);
if
(
isset
(
$args
[
4
]
)
)
{
$this
->
checkSQLOptions
(
$args
[
4
]
);
}
if
(
isset
(
$args
[
5
]
)
)
{
$this
->
checkJoinCond
(
$args
[
5
]
);
}
}
/**
* Check IDatabase::makeList
*
* Special cased because the second arg totally changes
* how this function is interpreted.
* @param Node $node
*/
private
function
checkMakeList
(
Node
$node
):
void
{
$args
=
$node
->
children
[
'args'
];
// First determine which IDatabase::LIST_*
// 0 = IDatabase::LIST_COMMA is default value.
$typeArg
=
$args
->
children
[
1
]
??
0
;
if
(
$typeArg
instanceof
Node
)
{
$typeArg
=
$this
->
getCtxN
(
$typeArg
)->
getEquivalentPHPValueForNode
(
$typeArg
,
ContextNode
::
RESOLVE_SCALAR_DEFAULT
&
~
ContextNode
::
RESOLVE_CONSTANTS
);
}
if
(
$typeArg
instanceof
Node
)
{
if
(
$typeArg
->
kind
===
\ast\AST_CLASS_CONST
)
{
// Probably IDatabase::LIST_*. Note that non-class constants are resolved
$typeArg
=
$typeArg
->
children
[
'const'
];
}
elseif
(
$typeArg
->
kind
===
\ast\AST_CONST
)
{
$typeArg
=
$typeArg
->
children
[
'name'
]->
children
[
'name'
];
}
else
{
// Something that cannot be resolved statically. Since LIST_NAMES is very rare, and LIST_COMMA is
// default, assume its LIST_AND or LIST_OR
$this
->
debug
(
__METHOD__
,
"Could not determine 2nd arg makeList()"
);
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
SQL_NUMKEY_EXEC_TAINT
),
$args
->
children
[
0
],
"IDatabase::makeList with unknown type arg is "
.
"given an array with unescaped keynames or "
.
"values for numeric keys (May be false positive)"
);
return
;
}
}
// Make sure not to mix strings and ints in switch cases, as that will break horribly
if
(
is_int
(
$typeArg
)
)
{
$typeArg
=
$this
->
literalListConstToName
(
$typeArg
);
}
switch
(
$typeArg
)
{
case
'LIST_COMMA'
:
// String keys ignored. Everything escaped. So nothing to worry about.
break
;
case
'LIST_AND'
:
case
'LIST_SET'
:
case
'LIST_OR'
:
// exec_sql_numkey
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
SQL_NUMKEY_EXEC_TAINT
),
$args
->
children
[
0
],
"IDatabase::makeList with LIST_AND, LIST_OR or "
.
"LIST_SET must sql escape string key names and values of numeric keys"
);
break
;
case
'LIST_NAMES'
:
// Like comma but with no escaping.
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
SQL_EXEC_TAINT
),
$args
->
children
[
0
],
"IDatabase::makeList with LIST_NAMES needs "
.
"to escape for SQL"
);
break
;
default
:
$this
->
debug
(
__METHOD__
,
"Unrecognized 2nd arg "
.
"to IDatabase::makeList: '$typeArg'"
);
}
}
/**
* Convert a literal int value for a LIST_* constant to its name. This is a horrible hack for crappy code
* that uses the constants literally rather than by name. Such code shouldn't deserve taint analysis.
* This method can obviously break very easily if the values are changed.
*
* @param int $value
* @return string
*/
private
function
literalListConstToName
(
int
$value
):
string
{
switch
(
$value
)
{
case
0
:
return
'LIST_COMMA'
;
case
1
:
return
'LIST_AND'
;
case
2
:
return
'LIST_SET'
;
case
3
:
return
'LIST_NAMES'
;
case
4
:
return
'LIST_OR'
;
default
:
// Oh boy, what the heck are you doing? Well, DWIM
$this
->
debug
(
__METHOD__
,
'Someone specified a LIST_* constant literally but it is not a valid value. Wow.'
);
return
'LIST_AND'
;
}
}
/**
* Check the options parameter to IDatabase::select
*
* This only works if its specified as an array literal.
*
* Relevant options:
* GROUP BY is put directly in the query (array gets imploded)
* HAVING is treated like a WHERE clause
* ORDER BY is put directly in the query (array gets imploded)
* USE INDEX is directly put in string (both array and string version)
* IGNORE INDEX ditto
* @param Node|mixed $node The node from the AST tree
*/
private
function
checkSQLOptions
(
$node
):
void
{
if
(
!(
$node
instanceof
Node
)
||
$node
->
kind
!==
\ast\AST_ARRAY
)
{
return
;
}
$relevant
=
[
'GROUP BY'
=>
true
,
'ORDER BY'
=>
true
,
'HAVING'
=>
true
,
'USE INDEX'
=>
true
,
'IGNORE INDEX'
=>
true
,
];
foreach
(
$node
->
children
as
$arrayElm
)
{
assert
(
$arrayElm
->
kind
===
\ast\AST_ARRAY_ELEM
);
$val
=
$arrayElm
->
children
[
'value'
];
$key
=
$arrayElm
->
children
[
'key'
];
if
(
isset
(
$relevant
[
$key
]
)
)
{
$taintType
=
(
$key
===
'HAVING'
&&
$this
->
nodeIsArray
(
$val
)
)
?
SecurityCheckPlugin
::
SQL_NUMKEY_EXEC_TAINT
:
SecurityCheckPlugin
::
SQL_EXEC_TAINT
;
$taintType
=
new
Taintedness
(
$taintType
);
$this
->
backpropagateArgTaint
(
$node
,
$taintType
);
$ctx
=
clone
$this
->
context
;
$this
->
overrideContext
=
$ctx
->
withLineNumberStart
(
$val
->
lineno
??
$ctx
->
getLineNumberStart
()
);
$this
->
maybeEmitIssueSimplified
(
$taintType
,
$val
,
"{STRING_LITERAL} clause is user controlled"
,
[
$key
]
);
$this
->
overrideContext
=
null
;
}
}
}
/**
* Check a join_cond structure.
*
* Syntax is like
*
* [ 'aliasOfTable' => [ 'JOIN TYPE', $onConditions ], ... ]
* join type is usually something safe like INNER JOIN, but it is not
* validated or escaped. $onConditions is the same form as a WHERE clause.
*
* @param Node|mixed $node
*/
private
function
checkJoinCond
(
$node
):
void
{
if
(
!(
$node
instanceof
Node
)
||
$node
->
kind
!==
\ast\AST_ARRAY
)
{
return
;
}
foreach
(
$node
->
children
as
$table
)
{
assert
(
$table
->
kind
===
\ast\AST_ARRAY_ELEM
);
$tableName
=
is_string
(
$table
->
children
[
'key'
]
)
?
$table
->
children
[
'key'
]
:
'[UNKNOWN TABLE]'
;
$joinInfo
=
$table
->
children
[
'value'
];
if
(
$joinInfo
instanceof
Node
&&
$joinInfo
->
kind
===
\ast\AST_ARRAY
)
{
if
(
count
(
$joinInfo
->
children
)
===
0
||
$joinInfo
->
children
[
0
]->
children
[
'key'
]
!==
null
)
{
$this
->
debug
(
__METHOD__
,
"join info has named key??"
);
continue
;
}
$joinType
=
$joinInfo
->
children
[
0
]->
children
[
'value'
];
// join type does not get escaped.
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
SQL_EXEC_TAINT
),
$joinType
,
"Join type for {STRING_LITERAL} is user controlled"
,
[
$tableName
]
);
if
(
$joinType
instanceof
Node
)
{
$this
->
backpropagateArgTaint
(
$joinType
,
new
Taintedness
(
SecurityCheckPlugin
::
SQL_EXEC_TAINT
)
);
}
// On to the join ON conditions.
if
(
count
(
$joinInfo
->
children
)
===
1
||
$joinInfo
->
children
[
1
]->
children
[
'key'
]
!==
null
)
{
$this
->
debug
(
__METHOD__
,
"join info has named key??"
);
continue
;
}
$onCond
=
$joinInfo
->
children
[
1
]->
children
[
'value'
];
$ctx
=
clone
$this
->
context
;
$this
->
overrideContext
=
$ctx
->
withLineNumberStart
(
$onCond
->
lineno
??
$ctx
->
getLineNumberStart
()
);
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
SQL_NUMKEY_EXEC_TAINT
),
$onCond
,
"The ON conditions are not properly escaped for the join to `{STRING_LITERAL}`"
,
[
$tableName
]
);
if
(
$onCond
instanceof
Node
)
{
$this
->
backpropagateArgTaint
(
$onCond
,
new
Taintedness
(
SecurityCheckPlugin
::
SQL_NUMKEY_EXEC_TAINT
)
);
}
$this
->
overrideContext
=
null
;
}
}
}
/**
* Check to see if isHTML => true and is tainted.
*
* @param Node $node The expr child of the return. NOT the return itself
* @param FullyQualifiedFunctionLikeName $funcName
*/
private
function
visitReturnOfFunctionHook
(
Node
$node
,
FullyQualifiedFunctionLikeName
$funcName
):
void
{
if
(
$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
;
}
$arg
=
$node
->
children
[
0
];
assert
(
$arg
instanceof
Node
&&
$arg
->
kind
===
\ast\AST_ARRAY_ELEM
);
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
HTML_EXEC_TAINT
),
$arg
->
children
[
'value'
],
"Outputting user controlled HTML from Parser function hook {FUNCTIONLIKE}"
,
[
$funcName
]
);
}
/**
* Given a MediaWiki hook registration, find the callback
*
* @note This is a different format than Parser hooks use.
*
* Valid examples of callbacks:
* "wfSomeFunction"
* "SomeClass::SomeStaticMethod"
* A Closure
* $instanceOfSomeObject (With implied method name based on hook name)
* new SomeClass
* [ <one of the above>, $extraArgsForCallback, ...]
* [ [<one of the above>], $extraArgsForCallback, ...]
* [ $instanceOfObj, 'methodName', $optionalArgForCallback, ... ]
* [ [ $instanceOfObj, 'methodName' ], $optionalArgForCallback, ...]
*
* Oddly enough, [ 'NameOfClass', 'NameOfStaticMethod' ] does not appear
* to be valid, despite that being a valid callable.
*
* @param Node|mixed $node
* @param string $hookName
* @return FunctionInterface|null
*/
private
function
getCallableFromHookRegistration
(
$node
,
string
$hookName
):
?
FunctionInterface
{
// "wfSomething", "Class::Method", closure
if
(
!
$node
instanceof
Node
||
$node
->
kind
===
\ast\AST_CLOSURE
)
{
return
$this
->
getCallableFromNode
(
$node
);
}
$cb
=
$this
->
getSingleCallable
(
$node
,
'on'
.
$hookName
);
if
(
$cb
)
{
return
$cb
;
}
if
(
$node
->
kind
===
\ast\AST_ARRAY
)
{
if
(
count
(
$node
->
children
)
===
0
)
{
return
null
;
}
$firstChild
=
$node
->
children
[
0
]->
children
[
'value'
];
if
(
(
$firstChild
instanceof
Node
&&
$firstChild
->
kind
===
\ast\AST_ARRAY
)
||
!(
$firstChild
instanceof
Node
)
||
count
(
$node
->
children
)
===
1
)
{
// One of:
// [ [ <callback> ], $optionalArgs, ... ]
// [ 'SomeClass::method', $optionalArgs, ... ]
// [ <callback> ]
// Important to note, this is safe because the
// [ 'SomeClass', 'MethodToCallStatically' ]
// syntax isn't supported by hooks.
return
$this
->
getCallableFromHookRegistration
(
$firstChild
,
$hookName
);
}
// Remaining case is: [ $someObject, 'methodToCall', 'arg', ... ]
$methodName
=
$this
->
resolveValue
(
$node
->
children
[
1
]->
children
[
'value'
]
);
if
(
!
is_string
(
$methodName
)
)
{
return
null
;
}
$cb
=
$this
->
getSingleCallable
(
$firstChild
,
$methodName
);
if
(
$cb
)
{
return
$cb
;
}
}
return
null
;
}
/**
* @param Node $node
* @param string $methodName
* @return FunctionInterface|null
*/
private
function
getSingleCallable
(
Node
$node
,
string
$methodName
):
?
FunctionInterface
{
if
(
$node
->
kind
===
\ast\AST_VAR
&&
is_string
(
$node
->
children
[
'name'
]
)
)
{
return
$this
->
getCallbackForVar
(
$node
,
$methodName
);
}
if
(
$node
->
kind
===
\ast\AST_NEW
)
{
$cxn
=
$this
->
getCtxN
(
$node
);
try
{
$ctor
=
$cxn
->
getMethod
(
'__construct'
,
false
,
false
,
true
);
return
$ctor
->
getClass
(
$this
->
code_base
)->
getMethodByName
(
$this
->
code_base
,
$methodName
);
}
catch
(
CodeBaseException
$e
)
{
// @todo Should probably emit a non-security issue
$this
->
debug
(
__METHOD__
,
"Missing hook handle: "
.
$this
->
getDebugInfo
(
$e
)
);
}
}
return
null
;
}
/**
* Given an AST_VAR node, figure out what it represents as callback
*
* @param Node $node The variable
* @param string $defaultMethod If the var is an object, what method to use
* @return FunctionInterface|null
*/
private
function
getCallbackForVar
(
Node
$node
,
$defaultMethod
=
''
):
?
FunctionInterface
{
assert
(
$node
->
kind
===
\ast\AST_VAR
);
$cnode
=
$this
->
getCtxN
(
$node
);
// Try the class case first, because the callable case might emit issues (about missing __invoke) if executed
// for a variable holding just a class instance.
try
{
// Don't warn if it's the wrong type, for it might be a callable and not a class.
$classes
=
$cnode
->
getClassList
(
true
,
ContextNode
::
CLASS_LIST_ACCEPT_ANY
,
null
,
false
);
}
catch
(
CodeBaseException
|
IssueException
$_
)
{
$classes
=
[];
}
foreach
(
$classes
as
$class
)
{
if
(
$class
->
getFQSEN
()->
__toString
()
===
'
\C
losure'
)
{
// This means callable case, done below.
continue
;
}
try
{
return
$class
->
getMethodByName
(
$this
->
code_base
,
$defaultMethod
);
}
catch
(
CodeBaseException
$_
)
{
return
null
;
}
}
return
$this
->
getCallableFromNode
(
$node
);
}
/**
* Check for $wgHooks registration
*
* @param Node $node
* @note This assumes $wgHooks is always the global
* even if there is no globals declaration.
*/
public
function
visitAssign
(
Node
$node
):
void
{
parent
::
visitAssign
(
$node
);
$var
=
$node
->
children
[
'var'
];
if
(
!
$var
instanceof
Node
)
{
// Syntax error
return
;
}
$hookName
=
null
;
$expr
=
$node
->
children
[
'expr'
];
// The $wgHooks['foo'][] case
if
(
$var
->
kind
===
\ast\AST_DIM
&&
$var
->
children
[
'dim'
]
===
null
&&
$var
->
children
[
'expr'
]
instanceof
Node
&&
$var
->
children
[
'expr'
]->
kind
===
\ast\AST_DIM
&&
$var
->
children
[
'expr'
]->
children
[
'expr'
]
instanceof
Node
&&
is_string
(
$var
->
children
[
'expr'
]->
children
[
'dim'
]
)
&&
/* The $wgHooks['SomeHook'][] case */
(
(
$var
->
children
[
'expr'
]->
children
[
'expr'
]->
kind
===
\ast\AST_VAR
&&
$var
->
children
[
'expr'
]->
children
[
'expr'
]->
children
[
'name'
]
===
'wgHooks'
)
||
/* The $_GLOBALS['wgHooks']['SomeHook'][] case */
(
$var
->
children
[
'expr'
]->
children
[
'expr'
]->
kind
===
\ast\AST_DIM
&&
$var
->
children
[
'expr'
]->
children
[
'expr'
]->
children
[
'expr'
]
instanceof
Node
&&
$var
->
children
[
'expr'
]->
children
[
'expr'
]->
children
[
'expr'
]->
kind
===
\ast\AST_VAR
&&
$var
->
children
[
'expr'
]->
children
[
'expr'
]->
children
[
'expr'
]->
children
[
'name'
]
===
'_GLOBALS'
)
)
)
{
$hookName
=
$var
->
children
[
'expr'
]->
children
[
'dim'
];
}
if
(
$hookName
!==
null
)
{
$cb
=
$this
->
getCallableFromHookRegistration
(
$expr
,
$hookName
);
if
(
$cb
)
{
$this
->
registerHook
(
$hookName
,
$cb
);
}
else
{
$this
->
debug
(
__METHOD__
,
"Could not register hook "
.
"$hookName due to complex callback"
);
}
}
}
/**
* Special implementation of visitArray to detect HTMLForm specifiers
*
* @param Node $node
*/
private
function
detectHTMLForm
(
Node
$node
):
void
{
// Try to immediately filter out things that certainly aren't HTMLForms
$maybeHTMLForm
=
false
;
foreach
(
$node
->
children
as
$child
)
{
if
(
$child
instanceof
Node
&&
$child
->
kind
===
\ast\AST_ARRAY_ELEM
)
{
$key
=
$child
->
children
[
'key'
];
if
(
$key
instanceof
Node
||
$key
===
'class'
||
$key
===
'type'
)
{
$maybeHTMLForm
=
true
;
break
;
}
}
}
if
(
!
$maybeHTMLForm
)
{
return
;
}
$authReqFQSEN
=
FullyQualifiedClassName
::
fromFullyQualifiedString
(
'MediaWiki
\A
uth
\A
uthenticationRequest'
);
if
(
$this
->
code_base
->
hasClassWithFQSEN
(
$authReqFQSEN
)
&&
$this
->
context
->
isInClassScope
()
&&
self
::
isSubclassOf
(
$this
->
context
->
getClassFQSEN
(),
$authReqFQSEN
,
$this
->
code_base
)
)
{
// AuthenticationRequest::getFieldInfo() defines a very
// similar array but with different rules. T202112
return
;
}
// This is a rather superficial check. There
// are many ways to construct htmlform specifiers this
// won't catch, and it may also have some false positives.
static
$validHTMLFormTypes
=
[
'api'
,
'text'
,
'textwithbutton'
,
'textarea'
,
'select'
,
'combobox'
,
'radio'
,
'multiselect'
,
'limitselect'
,
'check'
,
'toggle'
,
'int'
,
'float'
,
'info'
,
'selectorother'
,
'selectandother'
,
'namespaceselect'
,
'namespaceselectwithbutton'
,
'tagfilter'
,
'sizefilter'
,
'submit'
,
'hidden'
,
'edittools'
,
'checkmatrix'
,
'cloner'
,
'autocompleteselect'
,
'date'
,
'time'
,
'datetime'
,
'email'
,
'password'
,
'url'
,
'title'
,
'user'
,
'usersmultiselect'
,
];
$type
=
null
;
$raw
=
null
;
$class
=
null
;
$rawLabel
=
null
;
$help
=
null
;
$label
=
null
;
$default
=
null
;
$options
=
null
;
$isInfo
=
false
;
// options key is really messed up with escaping.
$isOptionsSafe
=
true
;
foreach
(
$node
->
children
as
$child
)
{
if
(
$child
===
null
||
$child
->
kind
===
\ast\AST_UNPACK
)
{
// If we have list( , $x ) = foo(), or an in-place unpack, chances are this is not an HTMLForm.
return
;
}
assert
(
$child
->
kind
===
\ast\AST_ARRAY_ELEM
);
if
(
$child
->
children
[
'key'
]
===
null
)
{
// Implicit offset, hence most certainly not an HTMLForm.
return
;
}
$key
=
$this
->
resolveOffset
(
$child
->
children
[
'key'
]
);
if
(
!
is_string
(
$key
)
)
{
// Either not resolvable (so nothing we can say) or a non-string literal, skip.
return
;
}
switch
(
$key
)
{
case
'type'
:
$type
=
$this
->
resolveValue
(
$child
->
children
[
'value'
]
);
break
;
case
'class'
:
$class
=
$this
->
resolveValue
(
$child
->
children
[
'value'
]
);
break
;
case
'label'
:
$label
=
$this
->
resolveValue
(
$child
->
children
[
'value'
]
);
break
;
case
'options'
:
$options
=
$this
->
resolveValue
(
$child
->
children
[
'value'
]
);
break
;
case
'default'
:
$default
=
$this
->
resolveValue
(
$child
->
children
[
'value'
]
);
break
;
case
'label-raw'
:
$rawLabel
=
$this
->
resolveValue
(
$child
->
children
[
'value'
]
);
break
;
case
'raw'
:
case
'rawrow'
:
$raw
=
$this
->
resolveValue
(
$child
->
children
[
'value'
]
);
break
;
case
'help'
:
$help
=
$this
->
resolveValue
(
$child
->
children
[
'value'
]
);
break
;
}
}
if
(
!
$class
&&
!
$type
)
{
// Definitely not an HTMLForm
// Also important to reject empty string, not just
// null, otherwise 9e409c781015 of Wikibase causes
// this to fatal
return
;
}
if
(
$raw
===
null
&&
$label
===
null
&&
$rawLabel
===
null
&&
$help
===
null
&&
$default
===
null
&&
$options
===
null
)
{
// e.g. [ 'class' => 'someCssClass' ] appears a lot
// in the code base. If we don't have any of the html
// fields, skip out early.
return
;
}
if
(
$type
!==
null
&&
!
in_array
(
$type
,
$validHTMLFormTypes
,
true
)
)
{
// Not a valid HTMLForm field
// (Or someone just added a new field type)
return
;
}
if
(
$type
===
'info'
)
{
$isInfo
=
true
;
}
if
(
in_array
(
$type
,
[
'radio'
,
'multiselect'
],
true
)
)
{
$isOptionsSafe
=
false
;
}
if
(
$class
!==
null
)
{
if
(
!
is_string
(
$class
)
)
{
return
;
}
try
{
$fqsen
=
FullyQualifiedClassName
::
fromStringInContext
(
$class
,
$this
->
context
);
}
catch
(
InvalidFQSENException
$_
)
{
// 'class' refers to something which is not a class, and this is probably not
// an HTMLForm
return
;
}
if
(
!
$this
->
code_base
->
hasClassWithFQSEN
(
$fqsen
)
)
{
return
;
}
if
(
(
string
)
$fqsen
===
'
\H
TMLInfoField'
)
{
$isInfo
=
true
;
}
if
(
(
string
)
$fqsen
===
'
\H
TMLMultiSelectField'
||
(
string
)
$fqsen
===
'
\H
TMLRadioField'
)
{
$isOptionsSafe
=
false
;
}
$fqsenBase
=
FullyQualifiedClassName
::
fromFullyQualifiedString
(
'
\H
TMLFormField'
);
if
(
!
$this
->
code_base
->
hasClassWithFQSEN
(
$fqsenBase
)
)
{
$this
->
debug
(
__METHOD__
,
"Missing HTMLFormField base class?!"
);
return
;
}
$isAField
=
self
::
isSubclassOf
(
$fqsen
,
$fqsenBase
,
$this
->
code_base
);
if
(
!
$isAField
)
{
return
;
}
}
if
(
$label
!==
null
)
{
// double escape check for label.
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
ESCAPED_EXEC_TAINT
),
$label
,
'HTMLForm label key escapes its input'
);
}
if
(
$rawLabel
!==
null
)
{
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
HTML_EXEC_TAINT
),
$rawLabel
,
'HTMLForm label-raw needs to escape input'
);
}
if
(
$help
!==
null
)
{
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
HTML_EXEC_TAINT
),
$help
,
'HTMLForm help needs to escape input'
);
}
if
(
$isInfo
&&
$raw
===
true
)
{
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
HTML_EXEC_TAINT
),
$default
,
'HTMLForm info field in raw mode needs to escape default key'
);
}
if
(
$isInfo
&&
(
$raw
===
false
||
$raw
===
null
)
)
{
$this
->
maybeEmitIssueSimplified
(
new
Taintedness
(
SecurityCheckPlugin
::
ESCAPED_EXEC_TAINT
),
$default
,
'HTMLForm info field (non-raw) escapes default key already'
);
}
if
(
!
$isOptionsSafe
&&
$options
instanceof
Node
)
{
$htmlExecTaint
=
new
Taintedness
(
SecurityCheckPlugin
::
HTML_EXEC_TAINT
);
$optTaint
=
$this
->
getTaintedness
(
$options
);
$this
->
maybeEmitIssue
(
$htmlExecTaint
,
$optTaint
->
getTaintedness
()->
asKeyForForeach
(),
'HTMLForm option label needs escaping{DETAILS}'
,
[
$optTaint
->
getError
()
]
);
}
}
/**
* Try to detect HTMLForm specifiers
*
* @param Node $node
*/
public
function
visitArray
(
Node
$node
):
void
{
parent
::
visitArray
(
$node
);
// Performance: use isset(), not property_exists
// @phan-suppress-next-line PhanUndeclaredProperty
if
(
!
isset
(
$node
->
skipHTMLFormAnalysis
)
)
{
$this
->
detectHTMLForm
(
$node
);
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Aug 18 2026, 02:44 (9 w, 2 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
fb/e9/e0412ab09ef2c9c9ffd933bbaae9
Default Alt Text
MWVisitor.php (35 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment