Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.91% covered (warning)
77.91%
261 / 335
57.14% covered (warning)
57.14%
16 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Waf_Runtime
77.84% covered (warning)
77.84%
260 / 334
57.14% covered (warning)
57.14%
16 / 28
369.52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 rule_removed
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 update_targets
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 match_targets
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
 get_ip_hash
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 is_ip_allowed_for_recovery
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 allow_login_or_prompt_recovery
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 block
36.84% covered (danger)
36.84%
7 / 19
0.00% covered (danger)
0.00%
0 / 1
41.48
 redirect
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 flag_rule_for_removal
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 flag_target_for_removal
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_var
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_var
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 inc_var
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 dec_var
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 unset_var
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 meta
88.37% covered (warning)
88.37%
76 / 86
0.00% covered (danger)
0.00%
0 / 1
29.23
 state_values
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_body_processor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_body_processor
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 normalize_header_name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 normalize_targets
97.26% covered (success)
97.26%
71 / 73
0.00% covered (danger)
0.00%
0 / 1
31
 reset_matched_vars
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 is_ip_in_array
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 normalize_array_target
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
10
 args_names
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 key_matches
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 sanitize_output
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Runtime for Jetpack Waf
4 *
5 * @package automattic/jetpack-waf
6 */
7
8namespace Automattic\Jetpack\Waf;
9
10use Automattic\Jetpack\IP\Utils as IP_Utils;
11
12require_once __DIR__ . '/functions.php';
13
14// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- This class is all about sanitizing input.
15
16/**
17 * The environment variable that defined the WAF running mode.
18 *
19 * @var string JETPACK_WAF_MODE
20 */
21
22// Type aliases for this file.
23<<<'PHAN'
24@phan-type Target = array{ only?: string[], except?: string[], count?: boolean }
25@phan-type TargetBag = array<string, Target>
26PHAN;
27
28/**
29 * Waf_Runtime class
30 */
31class Waf_Runtime {
32    /**
33     * If used, normalize_array_targets() will just return the number of matching values, instead of the values themselves.
34     */
35    const NORMALIZE_ARRAY_COUNT = 1;
36    /**
37     * If used, normalize_array_targets() will apply "only" and "except" filters to the values of the source array, instead of the keys.
38     */
39    const NORMALIZE_ARRAY_MATCH_VALUES = 2;
40
41    /**
42     * The version of this runtime class. Used by rule files to ensure compatibility.
43     *
44     * @since 0.21.0
45     *
46     * @var int
47     */
48    public $version = 1;
49    /**
50     * Last rule.
51     *
52     * @var string
53     */
54    public $last_rule = '';
55    /**
56     * Matched vars.
57     *
58     * @var array
59     */
60    public $matched_vars = array();
61    /**
62     * Matched var.
63     *
64     * @var string
65     */
66    public $matched_var = '';
67    /**
68     * Matched var names.
69     *
70     * @var array
71     */
72    public $matched_vars_names = array();
73    /**
74     * Matched var name.
75     *
76     * @var string
77     */
78    public $matched_var_name = '';
79    /**
80     * Body Processor.
81     *
82     * @var string 'URLENCODED' | 'JSON' | ''
83     */
84    private $body_processor = '';
85
86    /**
87     * State.
88     *
89     * @var array
90     */
91    private $state = array();
92    /**
93     * Metadata.
94     *
95     * @var array
96     */
97    private $metadata = array();
98
99    /**
100     * Transforms.
101     *
102     * @var Waf_Transforms
103     */
104    private $transforms;
105    /**
106     * Operators.
107     *
108     * @var Waf_Operators
109     */
110    private $operators;
111
112    /**
113     * The request
114     *
115     * @var Waf_Request
116     */
117    private $request;
118
119    /**
120     * Rules to remove.
121     *
122     * @var array[]
123     */
124    private $rules_to_remove = array(
125        'id'  => array(),
126        'tag' => array(),
127    );
128
129    /**
130     * Targets to remove.
131     *
132     * @var array[]
133     */
134    private $targets_to_remove = array(
135        'id'  => array(),
136        'tag' => array(),
137    );
138
139    /**
140     * Constructor method.
141     *
142     * @param Waf_Transforms $transforms Transforms.
143     * @param Waf_Operators  $operators  Operators.
144     * @param ?Waf_Request   $request    Information about the request.
145     */
146    public function __construct( $transforms, $operators, $request = null ) {
147        $this->transforms = $transforms;
148        $this->operators  = $operators;
149        $this->request    = null === $request
150            ? new Waf_Request()
151            : $request;
152    }
153
154    /**
155     * Rule removed method.
156     *
157     * @param string   $id Ids.
158     * @param string[] $tags Tags.
159     */
160    public function rule_removed( $id, $tags ) {
161        if ( isset( $this->rules_to_remove['id'][ $id ] ) ) {
162            return true;
163        }
164        foreach ( $tags as $tag ) {
165            if ( isset( $this->rules_to_remove['tag'][ $tag ] ) ) {
166                return true;
167            }
168        }
169        return false;
170    }
171
172    /**
173     * Update Targets.
174     *
175     * @param array    $targets Targets.
176     * @param string   $rule_id Rule id.
177     * @param string[] $rule_tags Rule tags.
178     */
179    public function update_targets( $targets, $rule_id, $rule_tags ) {
180        $updates = array();
181        // look for target updates based on the rule's ID.
182        if ( isset( $this->targets_to_remove['id'][ $rule_id ] ) ) {
183            foreach ( $this->targets_to_remove['id'][ $rule_id ] as $name => $props ) {
184                $updates[] = array( $name, $props );
185            }
186        }
187        // look for target updates based on the rule's tags.
188        foreach ( $rule_tags as $tag ) {
189            if ( isset( $this->targets_to_remove['tag'][ $tag ] ) ) {
190                foreach ( $this->targets_to_remove['tag'][ $tag ] as $name => $props ) {
191                    $updates[] = array( $name, $props );
192                }
193            }
194        }
195        // apply any found target updates.
196
197        foreach ( $updates as list( $name, $props ) ) {
198            if ( isset( $targets[ $name ] ) ) {
199                // we only need to remove targets that exist.
200                if ( true === $props ) {
201                    // if the entire target is being removed, remove it.
202                    unset( $targets[ $name ] );
203                } else {
204                    // otherwise just mark single props to ignore.
205                    $targets[ $name ]['except'] = array_merge(
206                        $targets[ $name ]['except'] ?? array(),
207                        $props
208                    );
209                }
210            }
211        }
212        return $targets;
213    }
214
215    /**
216     * Return TRUE if at least one of the targets matches the rule.
217     *
218     * @param string[]  $transforms One of the transform methods defined in the Jetpack Waf_Transforms class.
219     * @param TargetBag $targets Targets.
220     * @param string    $match_operator Match operator.
221     * @param mixed     $match_value Match value.
222     * @param bool      $match_not Match not.
223     * @param bool      $capture Capture.
224     * @return bool
225     */
226    public function match_targets( $transforms, $targets, $match_operator, $match_value, $match_not, $capture = false ) {
227        $match_found = false;
228
229        // get values.
230        $values = $this->normalize_targets( $targets );
231
232        // apply transforms.
233        foreach ( $transforms as $t ) {
234            foreach ( $values as &$v ) {
235                $v['value'] = $this->transforms->$t( $v['value'] );
236            }
237        }
238        unset( $v );
239        // pass each target value to the operator to find any that match.
240        $matched  = array();
241        $captures = array();
242        foreach ( $values as $v ) {
243            $match     = $this->operators->{$match_operator}( $v['value'], $match_value );
244            $did_match = false !== $match;
245            if ( $match_not !== $did_match ) {
246                // If either:
247                // - rule is negated ("not" flag set) and the target was not matched
248                // - rule not negated and the target was matched
249                // then this is considered a match.
250                $match_found                = true;
251                $this->matched_vars_names[] = $v['name'];
252                $this->matched_vars[]       = $v['value'];
253                $this->matched_var_name     = end( $this->matched_vars_names );
254                $this->matched_var          = end( $this->matched_vars );
255                $matched[]                  = array( $v, $match );
256                // Set any captured matches into state if the rule has the "capture" flag.
257                if ( $capture ) {
258                    $captures = is_array( $match ) ? $match : array( $match );
259                    foreach ( array_slice( $captures, 0, 10 )  as $i => $c ) {
260                        $this->set_var( "tx.$i", $c );
261                    }
262                }
263            }
264        }
265
266        return $match_found;
267    }
268
269    /**
270     * Generate a secure hash for an IP address.
271     *
272     * @param string $ip IP address.
273     * @return string Hashed IP.
274     */
275    private function get_ip_hash( string $ip ): string {
276        $hash_key = wp_salt( 'auth' );
277        return hash_hmac( 'sha256', $ip, $hash_key );
278    }
279
280    /**
281     * Check if the IP is allowed for recovery.
282     *
283     * @param string $ip IP address.
284     * @return bool
285     */
286    public function is_ip_allowed_for_recovery( string $ip ): bool {
287        $allow_hash = get_transient( 'jetpack_waf_recovery_' . $ip );
288        return $allow_hash && hash_equals( $allow_hash, $this->get_ip_hash( $ip ) );
289    }
290
291    /**
292     * Process a recovery attempt.
293     *
294     * @param string $real_ip The real IP address of the request.
295     */
296    private function allow_login_or_prompt_recovery( $real_ip ) {
297        $blocked_login_page = Waf_Blocked_Login_Page::instance( $real_ip );
298
299        if ( $blocked_login_page->is_blocked_user_valid() ) {
300            // Allow the IP to bypass the block for 15 minutes.
301            set_transient( 'jetpack_waf_recovery_' . $real_ip, $this->get_ip_hash( $real_ip ), 15 * 60 );
302            return;
303        }
304
305        $blocked_login_page->render_and_die();
306    }
307
308    /**
309     * Block.
310     *
311     * @param string $action Action.
312     * @param string $rule_id Rule id.
313     * @param string $reason Block reason.
314     * @param int    $status_code Http status code.
315     */
316    public function block( $action, $rule_id, $reason, $status_code = 403 ) {
317        // The recovery flow needs transients, `wp_salt()` and `$pagenow`, so it cannot run in
318        // standalone mode, where the WAF executes from `auto_prepend_file` before WordPress.
319        if ( 'ip block list' === $reason && defined( 'ABSPATH' ) ) {
320            $real_ip = $this->request->get_real_user_ip_address();
321
322            if ( $this->is_ip_allowed_for_recovery( $real_ip ) ) {
323                return;
324            }
325
326            global $pagenow;
327            if ( isset( $pagenow ) && 'wp-login.php' === $pagenow ) {
328                $this->allow_login_or_prompt_recovery( $real_ip );
329                return;
330            }
331        }
332
333        if ( ! $reason ) {
334            $reason = "rule $rule_id";
335        } else {
336            $reason = $this->sanitize_output( $reason );
337        }
338
339        Waf_Blocklog_Manager::write_blocklog( $rule_id, $reason );
340        error_log( "Jetpack WAF Blocked Request\t$action\t$rule_id\t$status_code\t$reason" );
341        header( "X-JetpackWAF-Blocked: $status_code - rule $rule_id" );
342        if ( defined( 'JETPACK_WAF_MODE' ) && 'normal' === JETPACK_WAF_MODE ) {
343            $protocol = isset( $_SERVER['SERVER_PROTOCOL'] ) ? wp_unslash( $_SERVER['SERVER_PROTOCOL'] ) : 'HTTP';
344            header( $protocol . ' 403 Forbidden', true, $status_code );
345            if ( 'ip block list' === $reason ) {
346                // Plain English: in standalone mode WordPress, and so translation, is not loaded yet.
347                die( 'Access to this site from your IP address has been blocked. If you manage this site, please log in for additional information. Otherwise, contact the site owner.' );
348            }
349            die( "rule $rule_id - reason $reason" );
350        }
351    }
352
353    /**
354     * Redirect.
355     *
356     * @param string $rule_id Rule id.
357     * @param string $url Url.
358     * @return never
359     */
360    public function redirect( $rule_id, $url ) {
361        error_log( "Jetpack WAF Redirected Request.\tRule:$rule_id\t$url" );
362        header( "Location: $url" );
363        exit( 0 );
364    }
365
366    /**
367     * Flag rule for removal.
368     *
369     * @param string $prop Prop.
370     * @param string $value Value.
371     */
372    public function flag_rule_for_removal( $prop, $value ) {
373        if ( 'id' === $prop ) {
374            $this->rules_to_remove['id'][ $value ] = true;
375        } else {
376            $this->rules_to_remove['tag'][ $value ] = true;
377        }
378    }
379
380    /**
381     * Flag target for removal.
382     *
383     * @param string $id_or_tag Id or tag.
384     * @param string $id_or_tag_value Id or tag value.
385     * @param string $name Name.
386     * @param string $prop Prop.
387     */
388    public function flag_target_for_removal( $id_or_tag, $id_or_tag_value, $name, $prop = null ) {
389        if ( null === $prop ) {
390            $this->targets_to_remove[ $id_or_tag ][ $id_or_tag_value ][ $name ] = true;
391        } elseif (
392            ! isset( $this->targets_to_remove[ $id_or_tag ][ $id_or_tag_value ][ $name ] )
393            // if the entire target is already being removed then it would be redundant to remove a single property.
394            || true !== $this->targets_to_remove[ $id_or_tag ][ $id_or_tag_value ][ $name ]
395        ) {
396            $this->targets_to_remove[ $id_or_tag ][ $id_or_tag_value ][ $name ][] = $prop;
397        }
398    }
399
400    /**
401     * Get variable value.
402     *
403     * @param string $key Key.
404     */
405    public function get_var( $key ) {
406        return $this->state[ $key ] ?? '';
407    }
408
409    /**
410     * Set variable value.
411     *
412     * @param string $key Key.
413     * @param string $value Value.
414     */
415    public function set_var( $key, $value ) {
416        $this->state[ $key ] = $value;
417    }
418
419    /**
420     * Increment variable.
421     *
422     * @param string $key Key.
423     * @param mixed  $value Value.
424     */
425    public function inc_var( $key, $value ) {
426        if ( ! isset( $this->state[ $key ] ) ) {
427            $this->state[ $key ] = 0;
428        }
429        $this->state[ $key ] += floatval( $value );
430    }
431
432    /**
433     * Decrement variable.
434     *
435     * @param string $key Key.
436     * @param mixed  $value Value.
437     */
438    public function dec_var( $key, $value ) {
439        if ( ! isset( $this->state[ $key ] ) ) {
440            $this->state[ $key ] = 0;
441        }
442        $this->state[ $key ] -= floatval( $value );
443    }
444
445    /**
446     * Unset variable.
447     *
448     * @param string $key Key.
449     */
450    public function unset_var( $key ) {
451        unset( $this->state[ $key ] );
452    }
453
454    /**
455     * A cache of metadata about the incoming request.
456     *
457     * @param string $key The type of metadata to request ('headers', 'request_method', etc.).
458     */
459    public function meta( $key ) {
460        if ( ! isset( $this->metadata[ $key ] ) ) {
461            $value = null;
462            switch ( $key ) {
463                case 'headers':
464                    $value = $this->request->get_headers();
465                    break;
466                case 'headers_names':
467                    $value = $this->args_names( $this->meta( 'headers' ) );
468                    break;
469                case 'request_method':
470                    $value = $this->request->get_method();
471                    break;
472                case 'request_protocol':
473                    $value = $this->request->get_protocol();
474                    break;
475                case 'request_uri':
476                    $value = $this->request->get_uri( false );
477                    break;
478                case 'request_uri_raw':
479                    $value = $this->request->get_uri( true );
480                    break;
481                case 'request_filename':
482                    $value = $this->request->get_filename();
483                    break;
484                case 'request_line':
485                    $value = sprintf(
486                        '%s %s %s',
487                        $this->request->get_method(),
488                        $this->request->get_uri( false ),
489                        $this->request->get_protocol()
490                    );
491                    break;
492                case 'request_basename':
493                    $value = $this->request->get_basename();
494                    break;
495                case 'request_body':
496                    $value = $this->request->get_body();
497                    break;
498                case 'query_string':
499                    $value = $this->request->get_query_string();
500                    break;
501                case 'args_get':
502                    $value = $this->request->get_get_vars();
503                    break;
504                case 'args_get_names':
505                    $value = $this->args_names( $this->meta( 'args_get' ) );
506                    break;
507                case 'args_post':
508                    $value = $this->request->get_post_vars( $this->get_body_processor() );
509                    break;
510                case 'args_post_names':
511                    $value = $this->args_names( $this->meta( 'args_post' ) );
512                    break;
513                case 'args':
514                    $value = array_merge( $this->meta( 'args_get' ), $this->meta( 'args_post' ) );
515                    break;
516                case 'args_names':
517                    $value = $this->args_names( $this->meta( 'args' ) );
518                    break;
519                case 'request_cookies':
520                    $value = $this->request->get_cookies();
521                    break;
522                case 'request_cookies_names':
523                    $value = $this->args_names( $this->meta( 'request_cookies' ) );
524                    break;
525                case 'files':
526                    $value = array();
527                    foreach ( $this->request->get_files() as $f ) {
528                        $value[] = array( $f['name'], $f['filename'] );
529                    }
530                    break;
531                case 'files_names':
532                    $value = $this->args_names( $this->meta( 'files' ) );
533                    break;
534                case 'matched_vars':
535                    $value = array_combine( $this->matched_vars_names, $this->matched_vars );
536                    break;
537                case 'matched_var':
538                    $value = array( $this->matched_var_name => $this->matched_var );
539                    break;
540                case 'matched_vars_names':
541                    $value = $this->matched_vars_names;
542                    break;
543                case 'matched_var_name':
544                    $value = array( $this->matched_var_name );
545                    break;
546            }
547            $this->metadata[ $key ] = $value;
548        }
549
550        return $this->metadata[ $key ];
551    }
552
553    /**
554     * State values.
555     *
556     * @param string $prefix Prefix.
557     */
558    private function state_values( $prefix ) {
559        $output = array();
560        $len    = strlen( $prefix );
561        foreach ( $this->state as $k => $v ) {
562            if ( 0 === stripos( $k, $prefix ) ) {
563                $output[ substr( $k, $len ) ] = $v;
564            }
565        }
566
567        return $output;
568    }
569
570    /**
571     * Get the body processor.
572     *
573     * @return string
574     */
575    private function get_body_processor() {
576        return $this->body_processor;
577    }
578
579    /**
580     * Set the body processor.
581     *
582     * @param string $processor Processor to set. Either 'URLENCODED' or 'JSON'.
583     *
584     * @return void
585     */
586    public function set_body_processor( $processor ) {
587        if ( $processor === 'URLENCODED' || $processor === 'JSON' ) {
588            $this->body_processor = $processor;
589        }
590    }
591
592    /**
593     * Change a string to all lowercase and replace spaces and underscores with dashes.
594     *
595     * @param string $name Name.
596     * @return string
597     */
598    public function normalize_header_name( $name ) {
599        return str_replace( array( ' ', '_' ), '-', strtolower( $name ) );
600    }
601
602    /**
603     * Get match-able values from a collection of targets.
604     *
605     * This function expects an associative array of target items, and returns an array of possible values from those targets that can be used to match against.
606     * The key is the lowercase target name (i.e. `args`, `request_headers`, etc) - see https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v3.x)#Variables
607     * The value is an associative array of options that define how to narrow down the returned values for that target if it's an array (ARGS, for example). The possible options are:
608     *   count:  If `true`, then the returned value will a count of how many matched targets were found, rather then the actual values of those targets.
609     *           For example, &ARGS_GET will return the number of keys the query string.
610     *   only:   If specified, then only values in that target that match the given key will be returned.
611     *           For example, ARGS_GET:id|ARGS_GET:/^name/ will only return the values for `$_GET['id']` and any key in `$_GET` that starts with `name`
612     *   except: If specified, then values in that target will be left out from the returned values (even if they were included in an `only` option)
613     *           For example, ARGS_GET|!ARGS_GET:z will return every value from `$_GET` except for `$_GET['z']`.
614     *
615     * This function will return an array of associative arrays. Each with:
616     *   name:   The target name that this value came from (i.e. the key in the input `$targets` argument )
617     *   source: For targets that are associative arrays (like ARGS), this will be the target name AND the key in that target (i.e. "args:z" for ARGS:z)
618     *   value:  The value that was found in the associated target.
619     *
620     * @param TargetBag $targets An assoc. array with keys that are target name(s) and values are options for how to process that target (include/exclude rules, whether to return values or counts).
621     * @return array{name: string, source: string, value: mixed}[]
622     */
623    public function normalize_targets( $targets ) {
624        $return = array();
625        foreach ( $targets as $k => $v ) {
626            $count_only = isset( $v['count'] ) ? self::NORMALIZE_ARRAY_COUNT : 0;
627            $only       = $v['only'] ?? array();
628            $except     = $v['except'] ?? array();
629            $_k         = strtolower( $k );
630            switch ( $_k ) {
631                case 'request_headers':
632                    $this->normalize_array_target(
633                        // get the headers that came in with this request
634                        $this->meta( 'headers' ),
635                        // ensure only and exclude filters are normalized
636                        array_map( array( $this->request, 'normalize_header_name' ), $only ),
637                        array_map( array( $this->request, 'normalize_header_name' ), $except ),
638                        $k,
639                        $return,
640                        // flags
641                        $count_only
642                    );
643                    continue 2;
644                case 'request_headers_names':
645                    $this->normalize_array_target( $this->meta( 'headers_names' ), $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
646                    continue 2;
647                case 'request_method':
648                case 'request_protocol':
649                case 'request_uri':
650                case 'request_uri_raw':
651                case 'request_filename':
652                case 'request_basename':
653                case 'request_body':
654                case 'query_string':
655                case 'request_line':
656                    $v = $this->meta( $_k );
657                    break;
658                case 'tx':
659                case 'ip':
660                    $this->normalize_array_target( $this->state_values( "$k." ), $only, $except, $k, $return, $count_only );
661                    continue 2;
662                case 'request_cookies':
663                case 'args':
664                case 'args_get':
665                case 'args_post':
666                case 'files':
667                    $this->normalize_array_target( $this->meta( $_k ), $only, $except, $k, $return, $count_only );
668                    continue 2;
669                case 'request_cookies_names':
670                case 'args_names':
671                case 'args_get_names':
672                case 'args_post_names':
673                case 'files_names':
674                    // get the "full" data (for 'args_names' get data for 'args') and stripe it down to just the key names
675                    $data = array_map(
676                        function ( $item ) {
677                            return $item[0]; },
678                        $this->meta( substr( $_k, 0, -6 ) )
679                    );
680                    $this->normalize_array_target( $data, $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
681                    continue 2;
682                case 'matched_var':
683                    $this->normalize_array_target( $this->meta( $k ), $only, $except, $k, $return, $count_only );
684                    continue 2;
685
686                case 'matched_var_name':
687                    $this->normalize_array_target( $this->meta( $k ), $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
688                    continue 2;
689
690                case 'matched_vars':
691                    $this->normalize_array_target( $this->meta( $k ), $only, $except, $k, $return, $count_only );
692                    continue 2;
693
694                case 'matched_vars_names':
695                    $this->normalize_array_target( $this->meta( $k ), $only, $except, $k, $return, $count_only | self::NORMALIZE_ARRAY_MATCH_VALUES );
696                    continue 2;
697
698                default:
699                    var_dump( 'Unknown target', $k, $v );
700                    exit( 0 );
701            }
702            $return[] = array(
703                'name'   => $k,
704                'value'  => $v,
705                'source' => $k,
706            );
707        }
708
709        return $return;
710    }
711
712    /**
713     * Reset matched vars after processing a rule.
714     *
715     * @return void
716     */
717    public function reset_matched_vars() {
718            $this->matched_vars       = array();
719            $this->matched_vars_names = array();
720            $this->matched_var        = '';
721            $this->matched_var_name   = '';
722            unset(
723                $this->metadata['matched_var'],
724                $this->metadata['matched_vars'],
725                $this->metadata['matched_vars_names'],
726                $this->metadata['matched_var_name']
727            );
728    }
729
730    /**
731     * Verifies if the IP from the current request is in an array.
732     *
733     * @param array $array Array of IP addresses to verify the request IP against.
734     * @return bool
735     */
736    public function is_ip_in_array( $array ) {
737        $real_ip      = $this->request->get_real_user_ip_address();
738        $array_length = count( $array );
739
740        for ( $i = 0; $i < $array_length; $i++ ) {
741            // Check if the IP matches a provided range or CIDR notation.
742            $range = strpos( $array[ $i ], '/' ) !== false ? array( $array[ $i ], null ) : explode( '-', $array[ $i ] );
743            if ( count( $range ) === 2 ) {
744                if ( IP_Utils::ip_address_is_in_range( $real_ip, $range[0], $range[1] ) ) {
745                    return true;
746                }
747                continue;
748            }
749
750            // Check if the IP is an exact match.
751            if ( $real_ip === $array[ $i ] ) {
752                return true;
753            }
754        }
755
756        return false;
757    }
758
759    /**
760     * Extract values from an associative array, potentially applying filters and/or counting results.
761     *
762     * @param array{0: string, 1: scalar}|scalar[] $source      The source assoc. array of values (i.e. $_GET, $_SERVER, etc.).
763     * @param string[]                             $only        Only include the values for these keys in the output.
764     * @param string[]                             $excl        Never include the values for these keys in the output.
765     * @param string                               $name        The name of this target (see https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v3.x)#Variables).
766     * @param array                                $results     Array to add output values to, will be modified by this method.
767     * @param int                                  $flags       Any of the NORMALIZE_ARRAY_* constants defined at the top of the class.
768     */
769    private function normalize_array_target( $source, $only, $excl, $name, &$results, $flags = 0 ) {
770        $output   = array();
771        $has_only = isset( $only[0] );
772        $has_excl = isset( $excl[0] );
773
774        foreach ( $source as $source_key => $source_val ) {
775            if ( is_array( $source_val ) ) {
776                // if $source_val looks like a tuple from flatten_array(), then use the tuple as the key and value
777                $source_key = $source_val[0];
778                $source_val = $source_val[1];
779            }
780            $filter_match = ( $flags & self::NORMALIZE_ARRAY_MATCH_VALUES ) > 0 ? $source_val : $source_key;
781            // if this key is on the "exclude" list, skip it
782            if ( $has_excl && $this->key_matches( $filter_match, $excl ) ) {
783                continue;
784            }
785            // if this key isn't in our "only" list, then skip it
786            if ( $has_only && ! $this->key_matches( $filter_match, $only ) ) {
787                continue;
788            }
789            // otherwise add this key/value to our output
790            $output[] = array( $source_key, $source_val );
791        }
792
793        if ( ( $flags & self::NORMALIZE_ARRAY_COUNT ) > 0 ) {
794            // If we've been told to just count the values, then just count them.
795            $results[] = array(
796                'name'   => (string) $name,
797                'value'  => count( $output ),
798                'source' => '&' . $name,
799            );
800        } else {
801            foreach ( $output as list( $item_name, $item_value ) ) {
802                $results[] = array(
803                    'name'   => (string) $item_name,
804                    'value'  => $item_value,
805                    'source' => "$name:$item_name",
806                );
807            }
808        }
809
810        return $results;
811    }
812
813    /**
814     * Given an array of tuples - probably from flatten_array() - return a new array
815     * consisting of only the first value (the key name) from each tuple.
816     *
817     * @param array{0:string, 1:scalar}[] $flat_array An array of tuples.
818     * @return string[]
819     */
820    private function args_names( $flat_array ) {
821        $names = array_map(
822            function ( $tuple ) {
823                return $tuple[0];
824            },
825            $flat_array
826        );
827        return array_unique( $names );
828    }
829
830    /**
831     * Return whether or not a given $input key matches one of the given $patterns.
832     *
833     * @param string   $input    Key name to test against patterns.
834     * @param string[] $patterns Patterns to test key name with.
835     * @return bool
836     */
837    private function key_matches( $input, $patterns ) {
838        foreach ( $patterns as $p ) {
839            if ( '/' === $p[0] ) {
840                if ( 1 === preg_match( $p, $input ) ) {
841                    return true;
842                }
843            } elseif ( 0 === strcasecmp( $p, $input ) ) {
844                return true;
845            }
846        }
847
848        return false;
849    }
850
851    /**
852     * Sanitize output generated from the request that was blocked.
853     *
854     * @param string $output Output to sanitize.
855     */
856    public function sanitize_output( $output ) {
857        $url_decoded_output   = rawurldecode( $output );
858        $html_entities_output = htmlentities( $url_decoded_output, ENT_QUOTES, 'UTF-8' );
859        // @phpcs:disable Squiz.Strings.DoubleQuoteUsage.NotRequired
860        $escapers     = array( "\\", "/", "\"", "\n", "\r", "\t", "\x08", "\x0c" );
861        $replacements = array( "\\\\", "\\/", "\\\"", "\\n", "\\r", "\\t", "\\f", "\\b" );
862        // @phpcs:enable Squiz.Strings.DoubleQuoteUsage.NotRequired
863
864        return( str_replace( $escapers, $replacements, $html_entities_output ) );
865    }
866}