Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 607
0.00% covered (danger)
0.00%
0 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWVisitor
0.00% covered (danger)
0.00%
0 / 607
0.00% covered (danger)
0.00%
0 / 23
43890
0.00% covered (danger)
0.00%
0 / 1
 analyzeCallNode
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
110
 checkExternalLink
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 doSelectWrapperSpecialHandling
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
420
 maybeTriggerHook
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
156
 hasPassByReferenceParameter
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getHookTypeForRegistrationMethod
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 handleNormalHookRegistration
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 handleParserHookRegistration
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 registerHook
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 visitReturn
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 handleGetQueryInfoReturn
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
110
 checkMakeList
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
156
 literalListConstToName
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 checkSQLOptions
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 checkSQLOption
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 checkJoinConds
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
272
 visitReturnOfFunctionHook
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 getCallableFromHookRegistration
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
90
 getSingleCallable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getCallbackForVar
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 visitAssign
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
272
 detectHTMLForm
0.00% covered (danger)
0.00%
0 / 167
0.00% covered (danger)
0.00%
0 / 1
1640
 visitArray
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace SecurityCheckPlugin;
4
5use ast\Node;
6use Phan\Analysis\PostOrderAnalysisVisitor;
7use Phan\AST\ContextNode;
8use Phan\AST\UnionTypeVisitor;
9use Phan\Exception\CodeBaseException;
10use Phan\Exception\InvalidFQSENException;
11use Phan\Exception\IssueException;
12use Phan\Exception\NodeException;
13use Phan\Language\Element\ClassAliasRecord;
14use Phan\Language\Element\FunctionInterface;
15use Phan\Language\Element\Method;
16use Phan\Language\FQSEN\FullyQualifiedClassName;
17use Phan\Language\FQSEN\FullyQualifiedFunctionLikeName;
18use Phan\Language\FQSEN\FullyQualifiedFunctionName;
19use Phan\Language\FQSEN\FullyQualifiedMethodName;
20use Phan\Language\Type;
21use Phan\Language\Type\BoolType;
22use Phan\Language\Type\TrueType;
23use Phan\Language\UnionType;
24use UnexpectedValueException;
25use const ast\AST_CALL;
26use const ast\AST_CALLABLE_CONVERT;
27use const ast\AST_METHOD_CALL;
28use const ast\AST_STATIC_CALL;
29
30/**
31 * MediaWiki specific node visitor
32 *
33 * Copyright (C) 2017  Brian Wolff <bawolff@gmail.com>
34 *
35 * This program is free software; you can redistribute it and/or modify
36 * it under the terms of the GNU General Public License as published by
37 * the Free Software Foundation; either version 2 of the License, or
38 * (at your option) any later version.
39 *
40 * This program is distributed in the hope that it will be useful,
41 * but WITHOUT ANY WARRANTY; without even the implied warranty of
42 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
43 * GNU General Public License for more details.
44 *
45 * You should have received a copy of the GNU General Public License along
46 * with this program; if not, write to the Free Software Foundation, Inc.,
47 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
48 */
49class MWVisitor extends TaintednessVisitor {
50    /**
51     * @todo This is a temporary hack. Proper solution is refactoring/avoiding overrideContext
52     * @var bool|null
53     * @suppress PhanWriteOnlyProtectedProperty
54     */
55    protected $isHook;
56
57    /**
58     * Try and recognize hook registration
59     * @inheritDoc
60     */
61    protected function analyzeCallNode( Node $node, iterable $funcs ): void {
62        parent::analyzeCallNode( $node, $funcs );
63        if ( !isset( $node->children['method'] ) ) {
64            // Called by visitCall
65            return;
66        }
67
68        assert( is_array( $funcs ) && count( $funcs ) === 1 );
69        $method = $funcs[0];
70        assert( $method instanceof Method );
71
72        // Should this be getDefiningFQSEN() instead?
73        $methodName = (string)$method->getFQSEN();
74        // $this->debug( __METHOD__, "Checking to see if we should register $methodName" );
75        switch ( $methodName ) {
76            case "\\MediaWiki\\Parser\\Parser::setFunctionHook":
77            case "\\MediaWiki\\Parser\\Parser::setHook":
78                $type = $this->getHookTypeForRegistrationMethod( $methodName );
79                if ( $type === null ) {
80                    break;
81                }
82                // $this->debug( __METHOD__, "registering $methodName as $type" );
83                $this->handleParserHookRegistration( $node, $type );
84                break;
85            case '\MediaWiki\HookContainer\HookContainer::register':
86                $this->handleNormalHookRegistration( $node );
87                break;
88            case '\MediaWiki\Linker\Linker::makeExternalLink':
89                $this->checkExternalLink( $node );
90                break;
91            default:
92                if ( str_starts_with( $method->getName(), 'on' ) ) {
93                    $this->maybeTriggerHook( $node, $method );
94                }
95                $this->doSelectWrapperSpecialHandling( $node, $method );
96        }
97    }
98
99    /**
100     * MediaWiki\Linker\Linker::makeExternalLink escaping depends on third argument
101     */
102    private function checkExternalLink( Node $node ): void {
103        $escapeArg = $this->resolveValue( $node->children['args']->children[2] ?? true );
104        $text = $node->children['args']->children[1] ?? null;
105        if ( !$escapeArg && $text instanceof Node ) {
106            $this->maybeEmitIssueSimplified(
107                new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
108                $text,
109                "Calling Linker::makeExternalLink with user controlled text " .
110                "and third argument set to false"
111            );
112        }
113    }
114
115    /**
116     * Special casing for complex format of IReadableDatabase::select
117     *
118     * This handles the $options, and $join_cond. Other args are
119     * handled through normal means
120     *
121     * @param Node $node Either an AST_METHOD_CALL or AST_STATIC_CALL
122     * @param Method $method
123     */
124    private function doSelectWrapperSpecialHandling( Node $node, Method $method ): void {
125        static $relevantMethods;
126        if ( !$relevantMethods ) {
127            $makeFQSEN = [ FullyQualifiedClassName::class, 'fromFullyQualifiedString' ];
128            $relevantMethods = [
129                'makeList' => $makeFQSEN( '\\Wikimedia\\Rdbms\\Platform\\ISQLPlatform' ),
130                'select' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
131                'selectField' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
132                'selectFieldValues' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
133                'selectSQLText' => $makeFQSEN( '\\Wikimedia\\Rdbms\\Platform\\ISQLPlatform' ),
134                'selectRowCount' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
135                'selectRow' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
136                'option' => $makeFQSEN( '\\Wikimedia\\Rdbms\\SelectQueryBuilder' ),
137                'options' => $makeFQSEN( '\\Wikimedia\\Rdbms\\SelectQueryBuilder' ),
138                'joinConds' => $makeFQSEN( '\\Wikimedia\\Rdbms\\SelectQueryBuilder' ),
139            ];
140        }
141
142        $name = $method->getName();
143        if ( !isset( $relevantMethods[$name] ) ) {
144            return;
145        }
146
147        if ( !self::isSubclassOf( $method->getClassFQSEN(), $relevantMethods[$name], $this->code_base ) ) {
148            return;
149        }
150
151        $args = $node->children['args']->children;
152        switch ( $name ) {
153            case 'select':
154            case 'selectField':
155            case 'selectFieldValues':
156            case 'selectSQLText':
157            case 'selectRowCount':
158            case 'selectRow':
159                if ( isset( $args[4] ) ) {
160                    $this->checkSQLOptions( $args[4] );
161                }
162                if ( isset( $args[5] ) ) {
163                    $this->checkJoinConds( $args[5] );
164                }
165                return;
166            case 'option':
167                if ( count( $args ) >= 2 ) {
168                    $this->checkSQLOption( $args[0], $args[1], $node );
169                }
170                return;
171            case 'options':
172                if ( $args ) {
173                    $this->checkSQLOptions( $args[0] );
174                }
175                return;
176            case 'joinConds':
177                if ( $args ) {
178                    $this->checkJoinConds( $args[0] );
179                }
180                return;
181            case 'makeList':
182                $this->checkMakeList( $node );
183                return;
184            default:
185                throw new UnexpectedValueException( "Should be unreachable, got $name" );
186        }
187    }
188
189    /**
190     * Check if we are running a hook (i.e., calling a hook method on a HookRunner interface).
191     */
192    private function maybeTriggerHook( Node $node, FunctionInterface $method ): void {
193        if ( $node->kind !== AST_METHOD_CALL || !$method instanceof Method ) {
194            return;
195        }
196
197        try {
198            $implementedInterfaces = $method->getClass( $this->code_base )->getInterfaceFQSENList();
199        } catch ( CodeBaseException $e ) {
200            $this->debug( __METHOD__, "Class not found for method $method" . $this->getDebugInfo( $e ) );
201            return;
202        }
203
204        // We assume that for a hook called Foo, the interface is called FooHook and the handler onFoo, and that the
205        // hook runner (but not necessarily the handler) implements the hook interface.
206        $hookInterfaceName = preg_replace( '/^on/', '', $method->getName() ) . 'Hook';
207        $foundHookInterface = false;
208        foreach ( $implementedInterfaces as $implementedInterfaceFQSEN ) {
209            if ( $implementedInterfaceFQSEN->getName() === $hookInterfaceName ) {
210                $foundHookInterface = true;
211                break;
212            }
213        }
214
215        if ( !$foundHookInterface ) {
216            return;
217        }
218
219        $args = $node->children['args']->children;
220
221        $hasPassByRef = self::hasPassByReferenceParameter( $method );
222        $analyzer = new PostOrderAnalysisVisitor( $this->code_base, $this->context, [] );
223        $argumentTypes = array_fill( 0, count( $args ), UnionType::empty() );
224
225        $subscribers = MediaWikiHooksHelper::getInstance()->getHookSubscribers( $method->getName() );
226        foreach ( $subscribers as $subscriber ) {
227            if ( $subscriber instanceof FullyQualifiedMethodName ) {
228                if ( !$this->code_base->hasMethodWithFQSEN( $subscriber ) ) {
229                    $this->debug( __METHOD__, "Hook subscriber $subscriber not found!" );
230                    continue;
231                }
232                $func = $this->code_base->getMethodByFQSEN( $subscriber );
233            } else {
234                assert( $subscriber instanceof FullyQualifiedFunctionName );
235                if ( !$this->code_base->hasFunctionWithFQSEN( $subscriber ) ) {
236                    $this->debug( __METHOD__, "Hook subscriber $subscriber not found!" );
237                    continue;
238                }
239                $func = $this->code_base->getFunctionByFQSEN( $subscriber );
240            }
241
242            // $this->debug( __METHOD__, "Dispatching $hookName to $subscriber" );
243            // This is hacky, but try to ensure that the associated line
244            // number for any issues is in the extension, and not the
245            // line where the HookContainer::register() is in MW core.
246            // FIXME: In the case of reference parameters, this is
247            // still reporting things being in MW core instead of extension.
248            $oldContext = $this->overrideContext;
249            $fContext = $func->getContext();
250            $newContext = clone $this->context;
251            $newContext = $newContext->withFile( $fContext->getFile() )
252                ->withLineNumberStart( $fContext->getLineNumberStart() );
253            $this->overrideContext = $newContext;
254            $this->isHook = true;
255
256            if ( $hasPassByRef ) {
257                // Trigger an analysis of the function call (see e.g. ClosureReturnTypeOverridePlugin's
258                // handling of call_user_func_array). Note that it's not enough to use our
259                // handleMethodCall, because that doesn't handle references correctly.
260
261                // NOTE: This is only known to be necessary with references, hence the check above
262                // (for performance). There might be other edge cases, though...
263
264                // TODO We don't care about types, so we use an empty union type. However this looks
265                // very very fragile.
266                // TODO 2: Someday we could write a generic-purpose MW plugin, which could (among other
267                // things) understand hook. It could share some code with taint-check, and at that
268                // point we'd likely want to use the correct types here.
269                $analyzer->analyzeCallableWithArgumentTypes( $argumentTypes, $func, $args );
270            }
271            $this->handleMethodCall( $func, $subscriber, $args, false, true );
272
273            $this->overrideContext = $oldContext;
274            $this->isHook = false;
275        }
276    }
277
278    /**
279     * Check whether a function takes a parameter by reference (copy of
280     * {@link \Phan\Language\Element\FunctionTrait::hasPassByReferenceVariable()})
281     */
282    private static function hasPassByReferenceParameter( FunctionInterface $func ): bool {
283        foreach ( $func->getParameterList() as $param ) {
284            if ( $param->isPassByReference() ) {
285                return true;
286            }
287        }
288        return false;
289    }
290
291    /**
292     * @param string $method The method name of the registration function
293     * @return string|null The name of the hook that gets registered
294     */
295    private function getHookTypeForRegistrationMethod( string $method ): ?string {
296        switch ( $method ) {
297            case "\\MediaWiki\\Parser\\Parser::setFunctionHook":
298                return '!ParserFunctionHook';
299            case "\\MediaWiki\\Parser\\Parser::setHook":
300                return '!ParserHook';
301            default:
302                $this->debug( __METHOD__, "$method not a hook registerer" );
303                return null;
304        }
305    }
306
307    /**
308     * Handle registering a normal hook from HookContainer::register (Not from $wgHooks)
309     *
310     * @param Node $node The node representing the AST_METHOD_CALL
311     */
312    private function handleNormalHookRegistration( Node $node ): void {
313        assert( $node->kind === \ast\AST_METHOD_CALL );
314        $params = $node->children['args']->children;
315        if ( count( $params ) < 2 ) {
316            $this->debug( __METHOD__, "Could not understand HookContainer::register" );
317            return;
318        }
319        $hookName = $params[0];
320        if ( !is_string( $hookName ) ) {
321            $this->debug( __METHOD__, "Could not register hook. Name is complex" );
322            return;
323        }
324        $cb = $this->getCallableFromHookRegistration( $params[1], $hookName );
325        if ( $cb ) {
326            $this->registerHook( $hookName, $cb );
327        } else {
328            $this->debug( __METHOD__, "Could not register $hookName hook due to complex callback" );
329        }
330    }
331
332    /**
333     * When someone calls $parser->setFunctionHook() or setTagHook()
334     *
335     * @note Causes phan to error out if given non-existent class
336     * @param Node $node The AST_METHOD_CALL node
337     * @param string $hookType The name of the hook
338     */
339    private function handleParserHookRegistration( Node $node, string $hookType ): void {
340        $args = $node->children['args']->children;
341        if ( count( $args ) < 2 ) {
342            return;
343        }
344        $callback = $this->getCallableFromNode( $args[1] );
345        if ( $callback ) {
346            $this->registerHook( $hookType, $callback );
347        }
348    }
349
350    private function registerHook( string $hookType, FunctionInterface $callback ): void {
351        $fqsen = $callback->getFQSEN();
352        $alreadyRegistered = MediaWikiHooksHelper::getInstance()->registerHook( $hookType, $fqsen );
353        if ( !$alreadyRegistered ) {
354            // $this->debug( __METHOD__, "registering $fqsen for hook $hookType" );
355            // If this is the first time seeing this, make sure we reanalyze the hook function now that
356            // we know what it is, in case it's already been analyzed.
357            $this->analyzeFunc( $callback );
358        }
359    }
360
361    /**
362     * For special hooks, check their return value
363     *
364     * e.g. A tag hook's return value is output as html.
365     */
366    public function visitReturn( Node $node ): void {
367        parent::visitReturn( $node );
368        if (
369            !$node->children['expr'] instanceof Node ||
370            !$this->context->isInFunctionLikeScope()
371        ) {
372            return;
373        }
374        $funcFQSEN = $this->context->getFunctionLikeFQSEN();
375        $funcFQSENStr = $funcFQSEN->__toString();
376
377        if (
378            // The one in SelectQueryBuilder is generic, don't bother. The stuff it returns is analyzed separately when
379            // we find calls to SelectQueryBuilder methods.
380            $funcFQSENStr !== '\\Wikimedia\\Rdbms\\SelectQueryBuilder::getQueryInfo' &&
381            str_ends_with( $funcFQSENStr, '::getQueryInfo' )
382        ) {
383            $this->handleGetQueryInfoReturn( $node->children['expr'] );
384        }
385
386        $hookType = MediaWikiHooksHelper::getInstance()->isSpecialHookSubscriber( $funcFQSEN );
387        switch ( $hookType ) {
388            case '!ParserFunctionHook':
389                $this->visitReturnOfFunctionHook( $node->children['expr'], $funcFQSEN );
390                break;
391            case '!ParserHook':
392                $ret = $node->children['expr'];
393                $this->maybeEmitIssueSimplified(
394                    new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
395                    $ret,
396                    "Outputting user controlled HTML from Parser tag hook {FUNCTIONLIKE}",
397                    [ $funcFQSEN ]
398                );
399                break;
400        }
401    }
402
403    /**
404     * Methods named getQueryInfo() in MediaWiki usually
405     * return an array that is later fed to select
406     *
407     * @note This will only work where the return
408     *  statement is an array literal.
409     * @param Node|mixed $node Node from ast tree
410     */
411    private function handleGetQueryInfoReturn( mixed $node ): void {
412        if (
413            !( $node instanceof Node ) ||
414            $node->kind !== \ast\AST_ARRAY
415        ) {
416            return;
417        }
418        // The argument order is
419        // $table, $vars, $conds = '', $fname = __METHOD__,
420        // $options = [], $join_conds = []
421        $keysToArg = [
422            'tables' => 0,
423            'fields' => 1,
424            'conds' => 2,
425            'options' => 4,
426            'join_conds' => 5,
427        ];
428        $args = [ '', '', '', '' ];
429        foreach ( $node->children as $child ) {
430            // Can't have array destructuring in a return statement.
431            assert( $child !== null );
432            if ( $child->kind === \ast\AST_UNPACK ) {
433                // Can't analyze this, skip it.
434                continue;
435            }
436            assert( $child->kind === \ast\AST_ARRAY_ELEM );
437            $key = $child->children['key'];
438            if ( $key instanceof Node ) {
439                // Dynamic name, skip (T268055).
440                continue;
441            }
442            if ( !isset( $keysToArg[$key] ) ) {
443                continue;
444            }
445            $args[$keysToArg[$key]] = $child->children['value'];
446        }
447        $selectFQSEN = FullyQualifiedMethodName::fromFullyQualifiedString(
448            '\Wikimedia\Rdbms\IReadableDatabase::select'
449        );
450        if ( !$this->code_base->hasMethodWithFQSEN( $selectFQSEN ) ) {
451            // Huh. Core wasn't parsed. That's bad, but don't fail hard.
452            $this->debug( __METHOD__, 'Database::select does not exist.' );
453            return;
454        }
455        $select = $this->code_base->getMethodByFQSEN( $selectFQSEN );
456        // TODO: The message about calling Database::select here is not very clear.
457        $this->handleMethodCall( $select, $selectFQSEN, $args, false );
458        if ( isset( $args[4] ) ) {
459            $this->checkSQLOptions( $args[4] );
460        }
461        if ( isset( $args[5] ) ) {
462            $this->checkJoinConds( $args[5] );
463        }
464    }
465
466    /**
467     * Check IDatabase::makeList
468     *
469     * Special cased because the second arg totally changes
470     * how this function is interpreted.
471     */
472    private function checkMakeList( Node $node ): void {
473        $args = $node->children['args'];
474        // First determine which IDatabase::LIST_*
475        // 0 = IDatabase::LIST_COMMA is default value.
476        $typeArg = $args->children[1] ?? 0;
477        if ( $typeArg instanceof Node ) {
478            $typeArg = $this->getCtxN( $typeArg )->getEquivalentPHPValueForNode(
479                $typeArg,
480                ContextNode::RESOLVE_SCALAR_DEFAULT & ~ContextNode::RESOLVE_CONSTANTS
481            );
482        }
483        if ( $typeArg instanceof Node ) {
484            if ( $typeArg->kind === \ast\AST_CLASS_CONST ) {
485                // Probably IDatabase::LIST_*. Note that non-class constants are resolved
486                $typeArg = $typeArg->children['const'];
487            } elseif ( $typeArg->kind === \ast\AST_CONST ) {
488                $typeArg = $typeArg->children['name']->children['name'];
489            } else {
490                // Something that cannot be resolved statically. Since LIST_NAMES is very rare, and LIST_COMMA is
491                // default, assume its LIST_AND or LIST_OR
492                $this->debug( __METHOD__, "Could not determine 2nd arg makeList()" );
493                $this->maybeEmitIssueSimplified(
494                    new Taintedness( SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT ),
495                    $args->children[0],
496                    "IDatabase::makeList with unknown type arg is " .
497                    "given an array with unescaped keynames or " .
498                    "values for numeric keys (May be false positive)"
499                );
500
501                return;
502            }
503        }
504
505        // Make sure not to mix strings and ints in switch cases, as that will break horribly
506        if ( is_int( $typeArg ) ) {
507            $typeArg = $this->literalListConstToName( $typeArg );
508        }
509        switch ( $typeArg ) {
510            case 'LIST_COMMA':
511                // String keys ignored. Everything escaped. So nothing to worry about.
512                break;
513            case 'LIST_AND':
514            case 'LIST_SET':
515            case 'LIST_OR':
516                // exec_sql_numkey
517                $this->maybeEmitIssueSimplified(
518                    new Taintedness( SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT ),
519                    $args->children[0],
520                    "IDatabase::makeList with LIST_AND, LIST_OR or "
521                    . "LIST_SET must sql escape string key names and values of numeric keys"
522                );
523                break;
524            case 'LIST_NAMES':
525                // Like comma but with no escaping.
526                $this->maybeEmitIssueSimplified(
527                    new Taintedness( SecurityCheckPlugin::SQL_EXEC_TAINT ),
528                    $args->children[0],
529                    "IDatabase::makeList with LIST_NAMES needs "
530                    . "to escape for SQL"
531                );
532                break;
533            default:
534                $this->debug( __METHOD__, "Unrecognized 2nd arg " . "to IDatabase::makeList: '$typeArg'" );
535        }
536    }
537
538    /**
539     * Convert a literal int value for a LIST_* constant to its name. This is a horrible hack for crappy code
540     * that uses the constants literally rather than by name. Such code shouldn't deserve taint analysis.
541     * This method can obviously break very easily if the values are changed.
542     */
543    private function literalListConstToName( int $value ): string {
544        switch ( $value ) {
545            case 0:
546                return 'LIST_COMMA';
547            case 1:
548                return 'LIST_AND';
549            case 2:
550                return 'LIST_SET';
551            case 3:
552                return 'LIST_NAMES';
553            case 4:
554                return 'LIST_OR';
555            default:
556                // Oh boy, what the heck are you doing? Well, DWIM
557                $this->debug(
558                    __METHOD__,
559                    'Someone specified a LIST_* constant literally but it is not a valid value. Wow.'
560                );
561                return 'LIST_AND';
562        }
563    }
564
565    /**
566     * Check the options parameter to IReadableDatabase::select
567     *
568     * This only works if its specified as an array literal.
569     *
570     * @param Node|mixed $node The node from the AST tree
571     */
572    private function checkSQLOptions( mixed $node ): void {
573        if ( !( $node instanceof Node ) || $node->kind !== \ast\AST_ARRAY ) {
574            return;
575        }
576
577        foreach ( $node->children as $arrayElm ) {
578            // Can't use array destructuring as an expression
579            assert( $arrayElm !== null );
580            if ( $arrayElm->kind === \ast\AST_UNPACK ) {
581                // Can't analyze this, skip it.
582                continue;
583            }
584            assert( $arrayElm->kind === \ast\AST_ARRAY_ELEM );
585            $val = $arrayElm->children['value'];
586            $key = $arrayElm->children['key'];
587            $this->checkSQLOption( $key, $val, $node );
588        }
589    }
590
591    /**
592     *  Relevant options:
593     *   GROUP BY is put directly in the query (array gets imploded)
594     *   HAVING is treated like a WHERE clause
595     *   ORDER BY is put directly in the query (array gets imploded)
596     *   USE INDEX is directly put in string (both array and string version)
597     *   IGNORE INDEX ditto
598     *
599     * @param Node|mixed $option
600     * @param Node|mixed $value
601     * @param Node $callNode
602     */
603    private function checkSQLOption( mixed $option, mixed $value, Node $callNode ): void {
604        $relevant = [
605            'GROUP BY' => true,
606            'ORDER BY' => true,
607            'HAVING' => true,
608            'USE INDEX' => true,
609            'IGNORE INDEX' => true,
610        ];
611
612        if ( !is_string( $option ) || !isset( $relevant[$option] ) ) {
613            return;
614        }
615        $taintType = ( $option === 'HAVING' && $this->nodeIsArray( $value ) ) ?
616            SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT :
617            SecurityCheckPlugin::SQL_EXEC_TAINT;
618        $taintType = new Taintedness( $taintType );
619
620        $this->backpropagateArgTaint( $callNode, $taintType );
621        if ( $value instanceof Node && $value->lineno !== $this->context->getLineNumberStart() ) {
622            $ctx = clone $this->context;
623            $this->overrideContext = $ctx->withLineNumberStart( $value->lineno );
624        }
625        $this->maybeEmitIssueSimplified(
626            $taintType,
627            $value,
628            "{STRING_LITERAL} clause is user controlled",
629            [ $option ]
630        );
631        $this->overrideContext = null;
632    }
633
634    /**
635     * Check a join_cond structure.
636     *
637     * Syntax is like
638     *
639     *  [ 'aliasOfTable' => [ 'JOIN TYPE', $onConditions ], ... ]
640     *  join type is usually something safe like INNER JOIN, but it is not
641     *  validated or escaped. $onConditions is the same form as a WHERE clause.
642     *
643     * @param Node|mixed $node
644     */
645    private function checkJoinConds( mixed $node ): void {
646        if ( !( $node instanceof Node ) || $node->kind !== \ast\AST_ARRAY ) {
647            return;
648        }
649
650        foreach ( $node->children as $table ) {
651            // Can't use array destructuring as an expression
652            assert( $table !== null );
653            if ( $table->kind === \ast\AST_UNPACK ) {
654                // Can't analyze this, skip it.
655                continue;
656            }
657            assert( $table->kind === \ast\AST_ARRAY_ELEM );
658
659            $tableName = is_string( $table->children['key'] ) ?
660                $table->children['key'] :
661                '[UNKNOWN TABLE]';
662            $joinInfo = $table->children['value'];
663            if ( $joinInfo instanceof Node && $joinInfo->kind === \ast\AST_ARRAY ) {
664                if (
665                    count( $joinInfo->children ) === 0 ||
666                    $joinInfo->children[0]->children['key'] !== null
667                ) {
668                    $this->debug( __METHOD__, "join info has named key??" );
669                    continue;
670                }
671                $joinType = $joinInfo->children[0]->children['value'];
672                // join type does not get escaped.
673                $this->maybeEmitIssueSimplified(
674                    new Taintedness( SecurityCheckPlugin::SQL_EXEC_TAINT ),
675                    $joinType,
676                    "Join type for {STRING_LITERAL} is user controlled",
677                    [ $tableName ]
678                );
679                if ( $joinType instanceof Node ) {
680                    $this->backpropagateArgTaint(
681                        $joinType,
682                        new Taintedness( SecurityCheckPlugin::SQL_EXEC_TAINT )
683                    );
684                }
685                // On to the join ON conditions.
686                if (
687                    count( $joinInfo->children ) === 1 ||
688                    $joinInfo->children[1]->children['key'] !== null
689                ) {
690                    $this->debug( __METHOD__, "join info has named key??" );
691                    continue;
692                }
693                $onCond = $joinInfo->children[1]->children['value'];
694                if ( $onCond instanceof Node && $onCond->lineno !== $this->context->getLineNumberStart() ) {
695                    $ctx = clone $this->context;
696                    $this->overrideContext = $ctx->withLineNumberStart( $onCond->lineno );
697                }
698                $this->maybeEmitIssueSimplified(
699                    new Taintedness( SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT ),
700                    $onCond,
701                    "The ON conditions are not properly escaped for the join to `{STRING_LITERAL}`",
702                    [ $tableName ]
703                );
704                if ( $onCond instanceof Node ) {
705                    $this->backpropagateArgTaint(
706                        $onCond,
707                        new Taintedness( SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT )
708                    );
709                }
710                $this->overrideContext = null;
711            }
712        }
713    }
714
715    /**
716     * Check to see if isHTML => true and is tainted.
717     *
718     * @param Node $node The expr child of the return. NOT the return itself
719     * @param FullyQualifiedFunctionLikeName $funcName
720     */
721    private function visitReturnOfFunctionHook( Node $node, FullyQualifiedFunctionLikeName $funcName ): void {
722        // XXX: we limit this to literal arrays because otherwise, we wouldn't be able to match the output taintedness
723        // and the `isHTML` value from different branches. See `safeHookIndirect1` test.
724        if ( $node->kind !== \ast\AST_ARRAY || count( $node->children ) < 2 ) {
725            return;
726        }
727        $arg = $node->children[0];
728        // Can't have array destructuring in a return statement.
729        assert( $arg instanceof Node );
730        if ( $arg->kind === \ast\AST_UNPACK ) {
731            // Can't analyze this.
732            return;
733        }
734        assert( $arg->kind === \ast\AST_ARRAY_ELEM );
735
736        $retType = UnionTypeVisitor::unionTypeFromNode( $this->code_base, $this->context, $node );
737        $isHTMLElementType = UnionTypeVisitor::resolveArrayShapeElementTypesForOffset(
738            $retType,
739            'isHTML',
740            false,
741            $this->code_base
742        );
743        if ( !$isHTMLElementType instanceof UnionType ) {
744            // Can't be resolved statically.
745            return;
746        }
747
748        // NOTE: Cannot use `containsTrue` here, because it, huh, also returns true for `false`.
749        $containsTrue = $isHTMLElementType->hasRealTypeMatchingCallback(
750            static fn ( Type $t ): bool => $t instanceof BoolType || $t instanceof TrueType
751        );
752        if ( !$containsTrue ) {
753            return;
754        }
755
756        $this->maybeEmitIssueSimplified(
757            new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
758            $arg->children['value'],
759            "Outputting user controlled HTML from Parser function hook {FUNCTIONLIKE}",
760            [ $funcName ]
761        );
762    }
763
764    /**
765     * Given a MediaWiki hook registration, find the callback
766     *
767     * @note This is a different format than Parser hooks use.
768     *
769     * Valid examples of callbacks:
770     *  1) A normal callable (string, array, or first-class)
771     *  2) A class instance with an `on$hook` method
772     *  3) An extension hook handler spec (not handled here as we check extension.json instead)
773     *  4) `HookContainer::NOOP` for no-op handlers
774     *
775     * @param Node|mixed $node
776     * @param string $hookName
777     */
778    private function getCallableFromHookRegistration( mixed $node, string $hookName ): ?FunctionInterface {
779        if (
780            !$node instanceof Node ||
781            $node->kind === \ast\AST_CLOSURE ||
782            $node->kind === \ast\AST_ARRAY ||
783            (
784                ( $node->kind === AST_CALL || $node->kind === AST_METHOD_CALL || $node->kind === AST_STATIC_CALL ) &&
785                $node->children['args']->kind === AST_CALLABLE_CONVERT
786            )
787        ) {
788            return $this->getCallableFromNode( $node );
789        }
790
791        $cb = $this->getSingleCallable( $node, 'on' . $hookName );
792        if ( $cb ) {
793            return $cb;
794        }
795
796        // Either a callback we couldn't resolve, or extension hook handler spec. Ignore either way.
797        return null;
798    }
799
800    private function getSingleCallable( Node $node, string $methodName ): ?FunctionInterface {
801        if ( $node->kind === \ast\AST_VAR && is_string( $node->children['name'] ) ) {
802            return $this->getCallbackForVar( $node, $methodName );
803        }
804        if ( $node->kind === \ast\AST_NEW ) {
805            $cxn = $this->getCtxN( $node );
806            try {
807                $ctor = $cxn->getMethod( '__construct', false, false, true );
808                return $ctor->getClass( $this->code_base )->getMethodByName( $this->code_base, $methodName );
809            } catch ( CodeBaseException | NodeException $e ) {
810                // @todo Should probably emit a non-security issue
811                $this->debug( __METHOD__, "Missing hook handle: " . $this->getDebugInfo( $e ) );
812            }
813        }
814        return null;
815    }
816
817    /**
818     * Given an AST_VAR node, figure out what it represents as callback
819     *
820     * @param Node $node The variable
821     * @param string $defaultMethod If the var is an object, what method to use
822     */
823    private function getCallbackForVar( Node $node, string $defaultMethod = '' ): ?FunctionInterface {
824        assert( $node->kind === \ast\AST_VAR );
825        $cnode = $this->getCtxN( $node );
826        // Try the class case first, because the callable case might emit issues (about missing __invoke) if executed
827        // for a variable holding just a class instance.
828        try {
829            // Don't warn if it's the wrong type, for it might be a callable and not a class.
830            $classes = $cnode->getClassList( true, ContextNode::CLASS_LIST_ACCEPT_ANY, null, false );
831        } catch ( CodeBaseException | IssueException ) {
832            $classes = [];
833        }
834        foreach ( $classes as $class ) {
835            if ( $class->getFQSEN()->__toString() === '\Closure' ) {
836                // This means callable case, done below.
837                continue;
838            }
839            try {
840                return $class->getMethodByName( $this->code_base, $defaultMethod );
841            } catch ( CodeBaseException ) {
842                return null;
843            }
844        }
845
846        return $this->getCallableFromNode( $node );
847    }
848
849    /**
850     * Check for $wgHooks registration
851     *
852     * @param Node $node
853     * @note This assumes $wgHooks is always the global
854     *   even if there is no globals declaration.
855     */
856    public function visitAssign( Node $node ): void {
857        parent::visitAssign( $node );
858
859        $var = $node->children['var'];
860        if ( !$var instanceof Node ) {
861            // Syntax error
862            return;
863        }
864        $hookName = null;
865        $expr = $node->children['expr'];
866        // The $wgHooks['foo'][] case
867        if (
868            $var->kind === \ast\AST_DIM &&
869            $var->children['dim'] === null &&
870            $var->children['expr'] instanceof Node &&
871            $var->children['expr']->kind === \ast\AST_DIM &&
872            $var->children['expr']->children['expr'] instanceof Node &&
873            is_string( $var->children['expr']->children['dim'] ) &&
874            /* The $wgHooks['SomeHook'][] case */
875            ( ( $var->children['expr']->children['expr']->kind === \ast\AST_VAR &&
876            $var->children['expr']->children['expr']->children['name'] === 'wgHooks' ) ||
877            /* The $GLOBALS['wgHooks']['SomeHook'][] case */
878            ( $var->children['expr']->children['expr']->kind === \ast\AST_DIM &&
879            $var->children['expr']->children['expr']->children['expr'] instanceof Node &&
880            $var->children['expr']->children['expr']->children['expr']->kind === \ast\AST_VAR &&
881            $var->children['expr']->children['expr']->children['expr']->children['name'] === 'GLOBALS' ) )
882        ) {
883            $hookName = $var->children['expr']->children['dim'];
884        }
885
886        if ( $hookName !== null ) {
887            $cb = $this->getCallableFromHookRegistration( $expr, $hookName );
888            if ( $cb ) {
889                $this->registerHook( $hookName, $cb );
890            } else {
891                $this->debug( __METHOD__, "Could not register hook " .
892                    "$hookName due to complex callback"
893                );
894            }
895        }
896    }
897
898    /**
899     * Special implementation of visitArray to detect HTMLForm specifiers
900     */
901    private function detectHTMLForm( Node $node ): void {
902        // Try to immediately filter out things that certainly aren't HTMLForms
903        $maybeHTMLForm = false;
904        foreach ( $node->children as $child ) {
905            if ( $child instanceof Node && $child->kind === \ast\AST_ARRAY_ELEM ) {
906                $key = $child->children['key'];
907                if ( $key instanceof Node || $key === 'class' || $key === 'type' ) {
908                    $maybeHTMLForm = true;
909                    break;
910                }
911            }
912        }
913        if ( !$maybeHTMLForm ) {
914            return;
915        }
916
917        $authReqFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
918            'MediaWiki\Auth\AuthenticationRequest'
919        );
920
921        if (
922            $this->code_base->hasClassWithFQSEN( $authReqFQSEN ) &&
923            $this->context->isInClassScope() &&
924            self::isSubclassOf( $this->context->getClassFQSEN(), $authReqFQSEN, $this->code_base )
925        ) {
926            // AuthenticationRequest::getFieldInfo() defines a very
927            // similar array but with different rules. T202112
928            return;
929        }
930
931        // This is a rather superficial check. There
932        // are many ways to construct htmlform specifiers this
933        // won't catch, and it may also have some false positives.
934
935        static $HTMLFormTypesToClasses = null;
936        if ( !$HTMLFormTypesToClasses ) {
937            $makeFQSEN = FullyQualifiedClassName::fromFullyQualifiedString( ... );
938            $HTMLFormTypesToClasses = [
939                'api' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLApiField' ),
940                'text' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextField' ),
941                'textwithbutton' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextFieldWithButton' ),
942                'textarea' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextAreaField' ),
943                'select' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectField' ),
944                'combobox' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLComboboxField' ),
945                'radio' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLRadioField' ),
946                'multiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLMultiSelectField' ),
947                'limitselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectLimitField' ),
948                'check' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLCheckField' ),
949                'toggle' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLCheckField' ),
950                'int' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLIntField' ),
951                'file' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLFileField' ),
952                'float' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLFloatField' ),
953                'info' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLInfoField' ),
954                'selectorother' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectOrOtherField' ),
955                'selectandother' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectAndOtherField' ),
956                'namespaceselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectNamespace' ),
957                'namespaceselectwithbutton' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectNamespaceWithButton' ),
958                'tagfilter' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTagFilter' ),
959                'sizefilter' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSizeFilterField' ),
960                'submit' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSubmitField' ),
961                'hidden' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLHiddenField' ),
962                'edittools' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLEditTools' ),
963                'checkmatrix' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLCheckMatrix' ),
964                'cloner' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLFormFieldCloner' ),
965                'autocompleteselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLAutoCompleteSelectField' ),
966                'language' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectLanguageField' ),
967                'date' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLDateTimeField' ),
968                'time' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLDateTimeField' ),
969                'datetime' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLDateTimeField' ),
970                'expiry' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLExpiryField' ),
971                'timezone' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTimezoneField' ),
972                'email' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextField' ),
973                'password' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextField' ),
974                'url' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextField' ),
975                'title' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTitleTextField' ),
976                'user' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLUserTextField' ),
977                'tagmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTagMultiselectField' ),
978                'orderedmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLOrderedMultiselectField' ),
979                'usersmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLUsersMultiselectField' ),
980                'titlesmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTitlesMultiselectField' ),
981                'namespacesmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLNamespacesMultiselectField' ),
982                // NOTE: it isn't actually possible to create an HTMLButtonField using `type => button` for some reason.
983                // Here, pretending it to be possible is simpler than special-casing the exception.
984                'button' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLButtonField' ),
985            ];
986        }
987        static $rawProps = [
988            'label-raw',
989            'help-raw',
990            'buttonlabel-raw',
991        ];
992        static $propsToResolve = null;
993        $propsToResolve ??= [
994            'type',
995            'class',
996            'label',
997            'options',
998            'default',
999            'raw',
1000            'rawrow',
1001            // TODO: remove help key case when back compat is no longer needed (T356971)
1002            'help',
1003            ...$rawProps,
1004        ];
1005
1006        $fieldProps = [];
1007        foreach ( $node->children as $child ) {
1008            if ( $child === null || $child->kind === \ast\AST_UNPACK ) {
1009                // If we have list( , $x ) = foo(), or an in-place unpack, chances are this is not an HTMLForm.
1010                return;
1011            }
1012            assert( $child->kind === \ast\AST_ARRAY_ELEM );
1013            if ( $child->children['key'] === null ) {
1014                // Implicit offset, hence most certainly not an HTMLForm.
1015                return;
1016            }
1017            $key = $this->resolveOffset( $child->children['key'] );
1018            if ( !is_string( $key ) ) {
1019                // Either not resolvable (so nothing we can say) or a non-string literal, skip.
1020                return;
1021            }
1022            if ( in_array( $key, $propsToResolve, true ) ) {
1023                $fieldProps[$key] = $this->resolveValue( $child->children['value'] );
1024            }
1025        }
1026        // Special case
1027        $raw = $fieldProps['raw'] ?? $fieldProps['rawrow'] ?? null;
1028
1029        // Also important to reject empty string, not just
1030        // null, otherwise 9e409c781015 of Wikibase causes
1031        // this to fatal
1032        if ( !empty( $fieldProps['type'] ) && is_string( $fieldProps['type'] ) ) {
1033            $type = $fieldProps['type'];
1034            if ( !isset( $HTMLFormTypesToClasses[$type] ) ) {
1035                // Not a valid HTMLForm field (or a new field type we don't recognize)
1036                return;
1037            }
1038        } elseif ( !empty( $fieldProps['class'] ) && is_string( $fieldProps['class'] ) ) {
1039            try {
1040                $fqsen = FullyQualifiedClassName::fromStringInContext(
1041                    $fieldProps['class'],
1042                    $this->context
1043                );
1044            } catch ( InvalidFQSENException ) {
1045                // 'class' refers to something which is not a class, and this is probably not
1046                // an HTMLForm
1047                return;
1048            }
1049
1050            $type = null;
1051            foreach ( $HTMLFormTypesToClasses as $curType => $fieldFQSEN ) {
1052                if ( $fqsen === $fieldFQSEN ) {
1053                    $type = $curType;
1054                    break;
1055                }
1056                // Note, this assumes that the list above uses the canonical FQSENs
1057                $fieldAliasFQSENs = array_map(
1058                    static fn ( ClassAliasRecord $car ): FullyQualifiedClassName => $car->alias_fqsen,
1059                    $this->code_base->getClassAliasesByFQSEN( $fieldFQSEN )
1060                );
1061                if ( in_array( $fqsen, $fieldAliasFQSENs, true ) ) {
1062                    $type = $curType;
1063                    break;
1064                }
1065            }
1066            if ( !$type ) {
1067                // Not a valid HTMLForm field (or a new field type we don't recognize)
1068                return;
1069            }
1070        } else {
1071            // Definitely not an HTMLForm
1072            return;
1073        }
1074
1075        $fieldPropsToCheck = array_diff_key( $fieldProps, [ 'class' => 1, 'type' => 1 ] );
1076        if ( !$fieldPropsToCheck ) {
1077            // e.g. [ 'class' => 'someCssClass' ] appears a lot
1078            // in the code base. If we don't have any of the interesting
1079            // fields, skip out early.
1080            return;
1081        }
1082
1083        if ( $fieldPropsToCheck['label'] ?? null ) {
1084            // double escape check for label.
1085            $this->maybeEmitIssueSimplified(
1086                new Taintedness( SecurityCheckPlugin::ESCAPED_EXEC_TAINT ),
1087                $fieldPropsToCheck['label'],
1088                'HTMLForm label key escapes its input'
1089            );
1090        }
1091        if ( $fieldPropsToCheck['help'] ?? null ) {
1092            $this->maybeEmitIssueSimplified(
1093                new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
1094                $fieldPropsToCheck['help'],
1095                'HTMLForm help needs to escape input'
1096            );
1097        }
1098        foreach ( $rawProps as $prop ) {
1099            if ( $fieldPropsToCheck[$prop] ?? null ) {
1100                $this->maybeEmitIssueSimplified(
1101                    new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
1102                    $fieldPropsToCheck[$prop],
1103                    "HTMLForm $prop needs to escape input"
1104                );
1105            }
1106        }
1107
1108        if ( $type === 'info' && ( $fieldPropsToCheck['default'] ?? null ) ) {
1109            if ( $raw === true ) {
1110                $this->maybeEmitIssueSimplified(
1111                    new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
1112                    $fieldPropsToCheck['default'],
1113                    'HTMLForm info field in raw mode needs to escape default key'
1114                );
1115            }
1116            if ( $raw === false || $raw === null ) {
1117                $this->maybeEmitIssueSimplified(
1118                    new Taintedness( SecurityCheckPlugin::ESCAPED_EXEC_TAINT ),
1119                    $fieldPropsToCheck['default'],
1120                    'HTMLForm info field (non-raw) escapes default key already'
1121                );
1122            }
1123        }
1124
1125        // options key is really messed up with escaping.
1126        $isOptionsSafe = !in_array( $type, [ 'radio', 'multiselect' ], true );
1127        $options = $fieldProps['options'] ?? null;
1128        if ( !$isOptionsSafe && $options instanceof Node ) {
1129            $htmlExecTaint = new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT );
1130            $optTaint = $this->getTaintedness( $options );
1131            $this->maybeEmitIssue(
1132                $htmlExecTaint,
1133                $optTaint->getTaintedness()->asKeyForForeach(),
1134                'HTMLForm option label needs escaping{DETAILS}',
1135                [ [ 'lines' => $optTaint->getError(), 'sink' => false ] ]
1136            );
1137        }
1138    }
1139
1140    /**
1141     * Try to detect HTMLForm specifiers
1142     */
1143    public function visitArray( Node $node ): void {
1144        parent::visitArray( $node );
1145        // Performance: use isset(), not property_exists
1146        // @phan-suppress-next-line PhanUndeclaredProperty
1147        if ( !isset( $node->skipHTMLFormAnalysis ) ) {
1148            $this->detectHTMLForm( $node );
1149        }
1150    }
1151}