Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.37% covered (success)
92.37%
109 / 118
85.71% covered (warning)
85.71%
18 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Waf_Request
92.31% covered (success)
92.31%
108 / 117
85.71% covered (warning)
85.71%
18 / 21
67.98
0.00% covered (danger)
0.00%
0 / 1
 set_trusted_proxies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_trusted_headers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_real_user_ip_address
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get_ip_by_header
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 get_headers
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 get_header
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 normalize_header_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_method
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_protocol
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_url
81.82% covered (warning)
81.82%
27 / 33
0.00% covered (danger)
0.00%
0 / 1
17.54
 get_uri
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_filename
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_basename
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_query_string
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_body
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 get_cookies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_get_vars
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_json_post_vars
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_urlencoded_post_vars
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_post_vars
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 get_files
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * HTTP request representation specific for the WAF.
4 *
5 * @package automattic/jetpack-waf
6 */
7
8namespace Automattic\Jetpack\Waf;
9
10require_once __DIR__ . '/functions.php';
11
12<<<'PHAN'
13@phan-type RequestFile = array{ name: string, filename: string }
14PHAN;
15
16/**
17 * Request representation.
18 */
19class Waf_Request {
20    /**
21     * The request URL, broken into three pieces: the host, the filename, and the query string
22     *
23     * @example for `https://wordpress.com/index.php?myvar=red`
24     *          $this->url = [ 'https://wordpress.com', '/index.php', '?myvar=red' ]
25     * @var array{0: string, 1: string, 2: string}|null
26     */
27    protected $url = null;
28
29    /**
30     * Trusted proxies.
31     *
32     * @var array List of trusted proxy IP addresses.
33     */
34    private $trusted_proxies = array();
35
36    /**
37     * Trusted headers.
38     *
39     * @var array List of headers to trust from the trusted proxies.
40     */
41    private $trusted_headers = array();
42
43    /**
44     * Sets the list of IP addresses for the proxies to trust. Trusted headers will only be accepted as the
45     * user IP address from these IP adresses.
46     *
47     * Popular choices include:
48     * - 192.168.0.1
49     * - 10.0.0.1
50     *
51     * @param array $proxies List of proxy IP addresses.
52     * @return void
53     */
54    public function set_trusted_proxies( $proxies ) {
55        $this->trusted_proxies = (array) $proxies;
56    }
57
58    /**
59     * Sets the list of headers to be trusted from the proxies. These headers will only be taken into account
60     * if the request comes from a trusted proxy as configured with set_trusted_proxies().
61     *
62     * Popular choices include:
63     * - HTTP_CLIENT_IP
64     * - HTTP_X_FORWARDED_FOR
65     * - HTTP_X_FORWARDED
66     * - HTTP_X_CLUSTER_CLIENT_IP
67     * - HTTP_FORWARDED_FOR
68     * - HTTP_FORWARDED
69     *
70     * @param array $headers List of HTTP header strings.
71     * @return void
72     */
73    public function set_trusted_headers( $headers ) {
74        $this->trusted_headers = (array) $headers;
75    }
76
77    /**
78     * Determines the users real IP address based on the settings passed to set_trusted_proxies() and
79     * set_trusted_headers() before. On CLI, this will be null.
80     *
81     * @return string|null
82     */
83    public function get_real_user_ip_address() {
84        $remote_addr = ! empty( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
85
86        if ( in_array( $remote_addr, $this->trusted_proxies, true ) ) {
87            $ip_by_header = $this->get_ip_by_header( array_merge( $this->trusted_headers, array( 'REMOTE_ADDR' ) ) );
88            if ( ! empty( $ip_by_header ) ) {
89                return $ip_by_header;
90            }
91        }
92
93        return $remote_addr;
94    }
95
96    /**
97     * Iterates through a given list of HTTP headers and attempts to get the IP address from the header that
98     * a proxy sends along. Make sure you trust the IP address before calling this method.
99     *
100     * @param array $headers The list of headers to check.
101     * @return string|null
102     */
103    private function get_ip_by_header( $headers ) {
104        foreach ( $headers as $key ) {
105            if ( isset( $_SERVER[ $key ] ) ) {
106                foreach ( explode( ',', wp_unslash( $_SERVER[ $key ] ) ) as $ip ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- filter_var is applied below.
107                    $ip = trim( $ip );
108
109                    if ( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) {
110                        return $ip;
111                    }
112                }
113            }
114        }
115
116        return null;
117    }
118
119    /**
120     * Returns the headers that were sent with this request
121     *
122     * @return array{0: string, 1: scalar}[]
123     */
124    public function get_headers() {
125        $value              = array();
126        $has_content_length = false;
127        foreach ( $_SERVER as $k => $v ) {
128            $k = strtolower( $k );
129            if ( 'http_' === substr( $k, 0, 5 ) ) {
130                $value[] = array( $this->normalize_header_name( substr( $k, 5 ) ), $v );
131            } elseif ( 'content_type' === $k && '' !== $v ) {
132                $value[] = array( 'content-type', $v );
133            } elseif ( 'content_length' === $k && '' !== $v ) {
134                $has_content_length = true;
135                $value[]            = array( 'content-length', $v );
136            }
137        }
138        if ( ! $has_content_length ) {
139            $value[] = array( 'content-length', '0' );
140        }
141
142        return $value;
143    }
144
145    /**
146     * Returns the value of a specific header that was sent with this request
147     *
148     * @param string $name The name of the header to retrieve.
149     * @return string
150     */
151    public function get_header( $name ) {
152        $name = $this->normalize_header_name( $name );
153        foreach ( $this->get_headers() as list( $header_name, $header_value ) ) {
154            if ( $header_name === $name ) {
155                return $header_value;
156            }
157        }
158        return '';
159    }
160
161    /**
162     * Change a header name to all-lowercase and replace spaces and underscores with dashes.
163     *
164     * @param string $name The header name to normalize.
165     * @return string
166     */
167    public function normalize_header_name( $name ) {
168        return str_replace( array( ' ', '_' ), '-', strtolower( $name ) );
169    }
170
171    /**
172     * Get the method for this request (GET, POST, etc).
173     *
174     * @return string
175     */
176    public function get_method() {
177        return isset( $_SERVER['REQUEST_METHOD'] )
178            ? filter_var( wp_unslash( $_SERVER['REQUEST_METHOD'] ), FILTER_DEFAULT )
179            : '';
180    }
181
182    /**
183     * Get the protocol for this request (HTTP, HTTPS, etc)
184     *
185     * @return string
186     */
187    public function get_protocol() {
188        return isset( $_SERVER['SERVER_PROTOCOL'] )
189            ? filter_var( wp_unslash( $_SERVER['SERVER_PROTOCOL'] ), FILTER_DEFAULT )
190            : '';
191    }
192
193    /**
194     * Returns the URL parts for this request.
195     *
196     * @see $this->url
197     * @return array{0: string, 1: string, 2: string}
198     */
199    protected function get_url() {
200        if ( null !== $this->url ) {
201            return $this->url;
202        }
203
204        $uri = isset( $_SERVER['REQUEST_URI'] ) ? filter_var( wp_unslash( $_SERVER['REQUEST_URI'] ), FILTER_DEFAULT ) : '/';
205        if ( false !== strpos( $uri, '?' ) ) {
206            // remove the query string (we'll pull it from elsewhere later)
207            $uri = urldecode( substr( $uri, 0, strpos( $uri, '?' ) ) );
208        } else {
209            $uri = urldecode( $uri );
210        }
211        $query_string = isset( $_SERVER['QUERY_STRING'] ) ? '?' . filter_var( wp_unslash( $_SERVER['QUERY_STRING'] ), FILTER_DEFAULT ) : '';
212        if ( 1 === preg_match( '/^https?:\/\//', $uri ) ) {
213            // sometimes $_SERVER[REQUEST_URI] already includes the full domain name
214            $uri_host  = substr( $uri, 0, strpos( $uri, '/', 8 ) );
215            $uri_path  = substr( $uri, strlen( $uri_host ) );
216            $this->url = array( $uri_host, $uri_path, $query_string );
217        } else {
218            // otherwise build the URI manually
219            $uri_scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] )
220                ? 'https'
221                : 'http';
222            $uri_host   = isset( $_SERVER['HTTP_HOST'] )
223                ? filter_var( wp_unslash( $_SERVER['HTTP_HOST'] ), FILTER_DEFAULT )
224                : (
225                    isset( $_SERVER['SERVER_NAME'] )
226                        ? filter_var( wp_unslash( $_SERVER['SERVER_NAME'] ), FILTER_DEFAULT )
227                        : ''
228                );
229            $uri_port   = isset( $_SERVER['SERVER_PORT'] )
230                ? filter_var( wp_unslash( $_SERVER['SERVER_PORT'] ), FILTER_SANITIZE_NUMBER_INT )
231                : '';
232            // we only need to include the port if it's non-standard
233            if ( $uri_port && ( 'http' === $uri_scheme && '80' !== $uri_port || 'https' === $uri_scheme && '443' !== $uri_port ) ) {
234                $uri_port = ':' . $uri_port;
235            } else {
236                $uri_port = '';
237            }
238            $this->url = array(
239                $uri_scheme . '://' . $uri_host . $uri_port,
240                $uri,
241                $query_string,
242            );
243        }
244        return $this->url;
245    }
246
247    /**
248     * Get the requested URI
249     *
250     * @param boolean $include_host If true, the scheme and domain will be included in the returned string (i.e. 'https://wordpress.com/index.php).
251     *                              If false, only the requested URI path will be returned (i.e. '/index.php').
252     * @return string
253     */
254    public function get_uri( $include_host = false ) {
255        list( $host, $file, $query ) = $this->get_url();
256
257        return ( $include_host ? $host : '' ) . $file . $query;
258    }
259
260    /**
261     * Return the filename part of the request
262     *
263     * @example for 'https://wordpress.com/some/page?id=5', return '/some/page'
264     * @return string
265     */
266    public function get_filename() {
267        return $this->get_url()[1];
268    }
269
270    /**
271     * Return the basename part of the request
272     *
273     * @example for 'https://wordpress.com/some/page.php?id=5', return 'page.php'
274     * @return string
275     */
276    public function get_basename() {
277        // Get the filename part of the request
278        $filename = $this->get_filename();
279        // Normalize slashes
280        $filename = str_replace( '\\', '/', $filename );
281        // Remove trailing slashes
282        $filename = rtrim( $filename, '/' );
283        // Return the basename
284        $offset = strrpos( $filename, '/' );
285        return $offset !== false ? substr( $filename, $offset + 1 ) : $filename;
286    }
287
288    /**
289     * Return the query string. If present, it will be prefixed with '?'. Otherwise, it will be an empty string.
290     *
291     * @return string
292     */
293    public function get_query_string() {
294        return $this->get_url()[2];
295    }
296
297    /**
298     * Returns the request body.
299     *
300     * @return string
301     */
302    public function get_body() {
303        $body = file_get_contents( 'php://input' );
304        return false === $body ? '' : $body;
305    }
306
307    /**
308     * Returns the cookies
309     *
310     * @return array{string, scalar}[]
311     */
312    public function get_cookies() {
313        return flatten_array( $_COOKIE );
314    }
315
316    /**
317     * Returns the GET variables
318     *
319     * @return array{string, scalar}[]
320     */
321    public function get_get_vars() {
322        return flatten_array( $_GET );
323    }
324
325    /**
326     * Returns the POST variables from a JSON body
327     *
328     * @return array{string, scalar}[]
329     */
330    private function get_json_post_vars() {
331        $decoded_json = json_decode( $this->get_body(), true ) ?? array();
332        return flatten_array( $decoded_json, 'json', true );
333    }
334
335    /**
336     * Returns the POST variables from a urlencoded body
337     *
338     * @return array{string, scalar}[]
339     */
340    private function get_urlencoded_post_vars() {
341        parse_str( $this->get_body(), $params );
342        return flatten_array( $params );
343    }
344
345    /**
346     * Returns the POST variables
347     *
348     * @param string $body_processor Manually specifiy the method to use to process the body. Options are 'URLENCODED' and 'JSON'.
349     *
350     * @return array{string, scalar}[]
351     */
352    public function get_post_vars( string $body_processor = '' ) {
353        $content_type = $this->get_header( 'content-type' );
354
355        // If the body processor is specified by the rules file, trust it.
356        if ( 'URLENCODED' === $body_processor ) {
357            return $this->get_urlencoded_post_vars();
358        }
359        if ( 'JSON' === $body_processor ) {
360            return $this->get_json_post_vars();
361        }
362
363        // Otherwise, use $_POST if it's not empty.
364        if ( ! empty( $_POST ) ) {
365            return flatten_array( $_POST );
366        }
367
368        // Lastly, try to parse the body based on the content type.
369        if ( strpos( $content_type, 'application/json' ) !== false ) {
370            return $this->get_json_post_vars();
371        }
372        if ( strpos( $content_type, 'application/x-www-form-urlencoded' ) !== false ) {
373            return $this->get_urlencoded_post_vars();
374        }
375
376        // Don't try to parse any other content types.
377        return array();
378    }
379
380    /**
381     * Returns the files that were uploaded with this request (i.e. what's in the $_FILES superglobal)
382     *
383     * @return RequestFile[]
384     */
385    public function get_files() {
386        $files = array();
387        foreach ( $_FILES as $field_name => $arr ) {
388            // flatten the values in case we were given inputs with brackets
389            foreach ( flatten_array( $arr ) as list( $arr_key, $arr_value ) ) {
390                if ( $arr_key === 'name' ) {
391                    // if this file was a simple (non-nested) name and unique, then just add it.
392                    $files[] = array(
393                        'name'     => $field_name,
394                        'filename' => $arr_value,
395                    );
396                } elseif ( 'name[' === substr( $arr_key, 0, 5 ) ) {
397                    // otherwise this was a file with a nested name and/or multiple files with the same name
398                    $files[] = array(
399                        'name'     => $field_name . substr( $arr_key, 4 ),
400                        'filename' => $arr_value,
401                    );
402                }
403            }
404        }
405        return $files;
406    }
407}