<?php
/**
 * HTTP request representation specific for the WAF.
 *
 * @package automattic/jetpack-waf
 */

namespace Automattic\Jetpack\Waf;

require_once __DIR__ . '/functions.php';

<<<PHAN
@phan-type RequestFile = array{ name: string, filename: string }
PHAN;

/**
 * Request representation.
 */
class Waf_Request {
	/**
	 * The request URL, broken into three pieces: the host, the filename, and the query string
	 *
	 * @example for `https://wordpress.com/index.php?myvar=red`
	 *          $this->url = [ 'https://wordpress.com', '/index.php', '?myvar=red' ]
	 * @var array{0: string, 1: string, 2: string}|null
	 */
	protected $url = null;

	/**
	 * Trusted proxies.
	 *
	 * @var array List of trusted proxy IP addresses.
	 */
	private $trusted_proxies = array();

	/**
	 * Trusted headers.
	 *
	 * @var array List of headers to trust from the trusted proxies.
	 */
	private $trusted_headers = array();

	/**
	 * Sets the list of IP addresses for the proxies to trust. Trusted headers will only be accepted as the
	 * user IP address from these IP adresses.
	 *
	 * Popular choices include:
	 * - 192.168.0.1
	 * - 10.0.0.1
	 *
	 * @param array $proxies List of proxy IP addresses.
	 * @return void
	 */
	public function set_trusted_proxies( $proxies ) {
		$this->trusted_proxies = (array) $proxies;
	}

	/**
	 * Sets the list of headers to be trusted from the proxies. These headers will only be taken into account
	 * if the request comes from a trusted proxy as configured with set_trusted_proxies().
	 *
	 * Popular choices include:
	 * - HTTP_CLIENT_IP
	 * - HTTP_X_FORWARDED_FOR
	 * - HTTP_X_FORWARDED
	 * - HTTP_X_CLUSTER_CLIENT_IP
	 * - HTTP_FORWARDED_FOR
	 * - HTTP_FORWARDED
	 *
	 * @param array $headers List of HTTP header strings.
	 * @return void
	 */
	public function set_trusted_headers( $headers ) {
		$this->trusted_headers = (array) $headers;
	}

	/**
	 * Determines the users real IP address based on the settings passed to set_trusted_proxies() and
	 * set_trusted_headers() before. On CLI, this will be null.
	 *
	 * @return string|null
	 */
	public function get_real_user_ip_address() {
		$remote_addr = ! empty( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		if ( in_array( $remote_addr, $this->trusted_proxies, true ) ) {
			$ip_by_header = $this->get_ip_by_header( array_merge( $this->trusted_headers, array( 'REMOTE_ADDR' ) ) );
			if ( ! empty( $ip_by_header ) ) {
				return $ip_by_header;
			}
		}

		return $remote_addr;
	}

	/**
	 * Iterates through a given list of HTTP headers and attempts to get the IP address from the header that
	 * a proxy sends along. Make sure you trust the IP address before calling this method.
	 *
	 * @param array $headers The list of headers to check.
	 * @return string|null
	 */
	private function get_ip_by_header( $headers ) {
		foreach ( $headers as $key ) {
			if ( isset( $_SERVER[ $key ] ) ) {
				foreach ( explode( ',', wp_unslash( $_SERVER[ $key ] ) ) as $ip ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- filter_var is applied below.
					$ip = trim( $ip );

					if ( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) {
						return $ip;
					}
				}
			}
		}

		return null;
	}

	/**
	 * Returns the headers that were sent with this request
	 *
	 * @return array{0: string, 1: scalar}[]
	 */
	public function get_headers() {
		$value              = array();
		$has_content_type   = false;
		$has_content_length = false;
		foreach ( $_SERVER as $k => $v ) {
			$k = strtolower( $k );
			if ( 'http_' === substr( $k, 0, 5 ) ) {
				$value[] = array( $this->normalize_header_name( substr( $k, 5 ) ), $v );
			} elseif ( 'content_type' === $k && '' !== $v ) {
				$has_content_type = true;
				$value[]          = array( 'content-type', $v );
			} elseif ( 'content_length' === $k && '' !== $v ) {
				$has_content_length = true;
				$value[]            = array( 'content-length', $v );
			}
		}
		if ( ! $has_content_type ) {
			// default Content-Type per RFC 7231 section 3.1.5.5.
			$value[] = array( 'content-type', 'application/octet-stream' );
		}
		if ( ! $has_content_length ) {
			$value[] = array( 'content-length', '0' );
		}

		return $value;
	}

	/**
	 * Returns the value of a specific header that was sent with this request
	 *
	 * @param string $name The name of the header to retrieve.
	 * @return string
	 */
	public function get_header( $name ) {
		$name = $this->normalize_header_name( $name );
		foreach ( $this->get_headers() as list( $header_name, $header_value ) ) {
			if ( $header_name === $name ) {
				return $header_value;
			}
		}
		return '';
	}

	/**
	 * Change a header name to all-lowercase and replace spaces and underscores with dashes.
	 *
	 * @param string $name The header name to normalize.
	 * @return string
	 */
	public function normalize_header_name( $name ) {
		return str_replace( array( ' ', '_' ), '-', strtolower( $name ) );
	}

	/**
	 * Get the method for this request (GET, POST, etc).
	 *
	 * @return string
	 */
	public function get_method() {
		return isset( $_SERVER['REQUEST_METHOD'] )
			? filter_var( wp_unslash( $_SERVER['REQUEST_METHOD'] ), FILTER_DEFAULT )
			: '';
	}

	/**
	 * Get the protocol for this request (HTTP, HTTPS, etc)
	 *
	 * @return string
	 */
	public function get_protocol() {
		return isset( $_SERVER['SERVER_PROTOCOL'] )
			? filter_var( wp_unslash( $_SERVER['SERVER_PROTOCOL'] ), FILTER_DEFAULT )
			: '';
	}

	/**
	 * Returns the URL parts for this request.
	 *
	 * @see $this->url
	 * @return array{0: string, 1: string, 2: string}
	 */
	protected function get_url() {
		if ( null !== $this->url ) {
			return $this->url;
		}

		$uri = isset( $_SERVER['REQUEST_URI'] ) ? filter_var( wp_unslash( $_SERVER['REQUEST_URI'] ), FILTER_DEFAULT ) : '/';
		if ( false !== strpos( $uri, '?' ) ) {
			// remove the query string (we'll pull it from elsewhere later)
			$uri = urldecode( substr( $uri, 0, strpos( $uri, '?' ) ) );
		} else {
			$uri = urldecode( $uri );
		}
		$query_string = isset( $_SERVER['QUERY_STRING'] ) ? '?' . filter_var( wp_unslash( $_SERVER['QUERY_STRING'] ), FILTER_DEFAULT ) : '';
		if ( 1 === preg_match( '/^https?:\/\//', $uri ) ) {
			// sometimes $_SERVER[REQUEST_URI] already includes the full domain name
			$uri_host  = substr( $uri, 0, strpos( $uri, '/', 8 ) );
			$uri_path  = substr( $uri, strlen( $uri_host ) );
			$this->url = array( $uri_host, $uri_path, $query_string );
		} else {
			// otherwise build the URI manually
			$uri_scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] )
				? 'https'
				: 'http';
			$uri_host   = isset( $_SERVER['HTTP_HOST'] )
				? filter_var( wp_unslash( $_SERVER['HTTP_HOST'] ), FILTER_DEFAULT )
				: (
					isset( $_SERVER['SERVER_NAME'] )
						? filter_var( wp_unslash( $_SERVER['SERVER_NAME'] ), FILTER_DEFAULT )
						: ''
				);
			$uri_port   = isset( $_SERVER['SERVER_PORT'] )
				? filter_var( wp_unslash( $_SERVER['SERVER_PORT'] ), FILTER_SANITIZE_NUMBER_INT )
				: '';
			// we only need to include the port if it's non-standard
			if ( $uri_port && ( 'http' === $uri_scheme && '80' !== $uri_port || 'https' === $uri_scheme && '443' !== $uri_port ) ) {
				$uri_port = ':' . $uri_port;
			} else {
				$uri_port = '';
			}
			$this->url = array(
				$uri_scheme . '://' . $uri_host . $uri_port,
				$uri,
				$query_string,
			);
		}
		return $this->url;
	}

	/**
	 * Get the requested URI
	 *
	 * @param boolean $include_host If true, the scheme and domain will be included in the returned string (i.e. 'https://wordpress.com/index.php).
	 *                              If false, only the requested URI path will be returned (i.e. '/index.php').
	 * @return string
	 */
	public function get_uri( $include_host = false ) {
		list( $host, $file, $query ) = $this->get_url();

		return ( $include_host ? $host : '' ) . $file . $query;
	}

	/**
	 * Return the filename part of the request
	 *
	 * @example for 'https://wordpress.com/some/page?id=5', return '/some/page'
	 * @return string
	 */
	public function get_filename() {
		return $this->get_url()[1];
	}

	/**
	 * Return the basename part of the request
	 *
	 * @example for 'https://wordpress.com/some/page.php?id=5', return 'page.php'
	 * @return string
	 */
	public function get_basename() {
		// Get the filename part of the request
		$filename = $this->get_filename();
		// Normalize slashes
		$filename = str_replace( '\\', '/', $filename );
		// Remove trailing slashes
		$filename = rtrim( $filename, '/' );
		// Return the basename
		$offset = strrpos( $filename, '/' );
		return $offset !== false ? substr( $filename, $offset + 1 ) : $filename;
	}

	/**
	 * Return the query string. If present, it will be prefixed with '?'. Otherwise, it will be an empty string.
	 *
	 * @return string
	 */
	public function get_query_string() {
		return $this->get_url()[2];
	}

	/**
	 * Returns the request body.
	 *
	 * @return string
	 */
	public function get_body() {
		$body = file_get_contents( 'php://input' );
		return false === $body ? '' : $body;
	}

	/**
	 * Returns the cookies
	 *
	 * @return array{string, scalar}[]
	 */
	public function get_cookies() {
		return flatten_array( $_COOKIE );
	}

	/**
	 * Returns the GET variables
	 *
	 * @return array{string, scalar}[]
	 */
	public function get_get_vars() {
		return flatten_array( $_GET );
	}

	/**
	 * Returns the POST variables from a JSON body
	 *
	 * @return array{string, scalar}[]
	 */
	private function get_json_post_vars() {
		$decoded_json = json_decode( $this->get_body(), true ) ?? array();
		return flatten_array( $decoded_json, 'json', true );
	}

	/**
	 * Returns the POST variables from a urlencoded body
	 *
	 * @return array{string, scalar}[]
	 */
	private function get_urlencoded_post_vars() {
		parse_str( $this->get_body(), $params );
		return flatten_array( $params );
	}

	/**
	 * Returns the POST variables
	 *
	 * @param string $body_processor Manually specifiy the method to use to process the body. Options are 'URLENCODED' and 'JSON'.
	 *
	 * @return array{string, scalar}[]
	 */
	public function get_post_vars( string $body_processor = '' ) {
		$content_type = $this->get_header( 'content-type' );

		// If the body processor is specified by the rules file, trust it.
		if ( 'URLENCODED' === $body_processor ) {
			return $this->get_urlencoded_post_vars();
		}
		if ( 'JSON' === $body_processor ) {
			return $this->get_json_post_vars();
		}

		// Otherwise, use $_POST if it's not empty.
		if ( ! empty( $_POST ) ) {
			return flatten_array( $_POST );
		}

		// Lastly, try to parse the body based on the content type.
		if ( strpos( $content_type, 'application/json' ) !== false ) {
			return $this->get_json_post_vars();
		}
		if ( strpos( $content_type, 'application/x-www-form-urlencoded' ) !== false ) {
			return $this->get_urlencoded_post_vars();
		}

		// Don't try to parse any other content types.
		return array();
	}

	/**
	 * Returns the files that were uploaded with this request (i.e. what's in the $_FILES superglobal)
	 *
	 * @return RequestFile[]
	 */
	public function get_files() {
		$files = array();
		foreach ( $_FILES as $field_name => $arr ) {
			// flatten the values in case we were given inputs with brackets
			foreach ( flatten_array( $arr ) as list( $arr_key, $arr_value ) ) {
				if ( $arr_key === 'name' ) {
					// if this file was a simple (non-nested) name and unique, then just add it.
					$files[] = array(
						'name'     => $field_name,
						'filename' => $arr_value,
					);
				} elseif ( 'name[' === substr( $arr_key, 0, 5 ) ) {
					// otherwise this was a file with a nested name and/or multiple files with the same name
					$files[] = array(
						'name'     => $field_name . substr( $arr_key, 4 ),
						'filename' => $arr_value,
					);
				}
			}
		}
		return $files;
	}
}
