diff --git a/api.wordpress.org/public_html/events/1.0/index.php b/api.wordpress.org/public_html/events/1.0/index.php index b5cffc7480..c916df03c2 100644 --- a/api.wordpress.org/public_html/events/1.0/index.php +++ b/api.wordpress.org/public_html/events/1.0/index.php @@ -1,4 +1,15 @@ false ); // If a precise location is known, use a GET request. The values here should come from the `location` key of the result of a POST request. - if ( isset( $_GET['latitude'], $_GET['longitude'] ) ) { - $location_args['latitude'] = $_GET['latitude']; - $location_args['longitude'] = $_GET['longitude']; + if ( + isset( $_GET['latitude'], $_GET['longitude'] ) && + is_numeric( $_GET['latitude'] ) && is_numeric( $_GET['longitude'] ) + ) { + $location_args['latitude'] = floatval( $_GET['latitude'] ); + $location_args['longitude'] = floatval( $_GET['longitude'] ); } - if ( isset( $_GET['country'] ) ) { - $location_args['country'] = $_GET['country']; + if ( isset( $_GET['country'] ) && '' !== $_GET['country'] ) { + // An ISO 3166-1 alpha-2 or alpha-3 country code. + $country = filter_var( + $_GET['country'], + FILTER_VALIDATE_REGEXP, + array( 'options' => array( 'regexp' => '/^[a-z]{2,3}\z/i' ) ) + ); + + if ( false === $country ) { + send_bad_request( 'country must be an ISO 3166-1 alpha-2 or alpha-3 country code.' ); + } + + $location_args['country'] = $country; $location_args['restrict_by_country'] = true; } // If a precise location is not known, create a POST request with a bunch of data which can be used to determine a precise location for future GET requests. if ( isset( $_POST['location_data'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Scalar-checked in validate_request(); DB access is prepared, output is JSON-encoded. $location_args = $_POST['location_data']; } // Simplified parameters for lookup by location (city) name, with optional timezone and locale params for extra context. if ( isset( $_REQUEST['location'] ) ) { - $location_args['location_name'] = trim( $_REQUEST['location'] ); + $location_args['location_name'] = trim( + filter_var( $_REQUEST['location'], FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW ) + ); } if ( isset( $_REQUEST['timezone'] ) ) { - $location_args['timezone'] = $_REQUEST['timezone']; + // An IANA timezone identifier, e.g. `America/New_York` or `Etc/GMT+5`. + $location_args['timezone'] = filter_var( + $_REQUEST['timezone'], + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '#^[A-Za-z0-9/_+-]{1,50}\z#', + 'default' => '', + ), + ) + ); } if ( isset( $_REQUEST['locale'] ) ) { - $location_args['locale'] = $_REQUEST['locale']; + // A locale identifier, e.g. `en_US` or `pt_PT_ao90`. + $location_args['locale'] = filter_var( + $_REQUEST['locale'], + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '/^[A-Za-z0-9_-]{2,20}\z/', + 'default' => '', + ), + ) + ); } if ( isset( $_REQUEST['ip'] ) ) { @@ -167,7 +215,7 @@ function parse_request() { FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); - $location_args['ip'] = $public_ip ? $public_ip : $_SERVER['REMOTE_ADDR']; + $location_args['ip'] = $public_ip ? $public_ip : (string) filter_var( $_SERVER['REMOTE_ADDR'] ?? '', FILTER_VALIDATE_IP ); } return $location_args; @@ -193,11 +241,16 @@ function validate_request() { ]; foreach ( $must_be_strings as $field ) { - if ( isset( $_GET[ $field ] ) && ! is_scalar( $_GET[ $field ] ) ) { + // Check `$_REQUEST` because `parse_request()` accepts some of these fields from either method. + if ( isset( $_REQUEST[ $field ] ) && ! is_scalar( $_REQUEST[ $field ] ) ) { send_bad_request( $field . ' must be of type string.' ); } } + if ( isset( $_POST['location_data'] ) && ! is_array( $_POST['location_data'] ) ) { + send_bad_request( 'location_data must be an array.' ); + } + if ( ! empty( $_POST['location_data'] ) ) { // phpcs:ignore WordPress.Security -- Public unauthenticated endpoint; the value is only type-checked here, never used or output. foreach ( $_POST['location_data'] as $value ) { @@ -257,14 +310,17 @@ function build_response( $location, $location_args ) { $error = 'temp-request-throttled'; } + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for prefix/substring comparisons, never output or stored. + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + if ( $location ) { $event_args = array( - 'is_client_core' => is_client_core( $_SERVER['HTTP_USER_AGENT'] ), + 'is_client_core' => is_client_core( $user_agent ), 'restrict_by_country' => $location_args['restrict_by_country'], ); - if ( isset( $_REQUEST['number'] ) ) { - $event_args['number'] = $_REQUEST['number']; + if ( isset( $_REQUEST['number'] ) && is_numeric( $_REQUEST['number'] ) ) { + $event_args['number'] = intval( $_REQUEST['number'] ); } if ( ! empty( $location['latitude'] ) ) { @@ -285,18 +341,18 @@ function build_response( $location, $location_args ) { $events = get_events( $event_args ); - //$events = maybe_add_wp15_promo( $events, $_SERVER['HTTP_USER_AGENT'], time() ); + // $events = maybe_add_wp15_promo( $events, $user_agent, time() ); $events = maybe_add_regional_wordcamps( $events, get_regional_wordcamp_data(), - $_SERVER['HTTP_USER_AGENT'], + $user_agent, time(), $location ); - $events = pin_next_online_wordcamp( $events, $_SERVER['HTTP_USER_AGENT'], time(), $location['country'] ?? '' ); - $events = pin_next_workshop_discussion_group( $events, $_SERVER['HTTP_USER_AGENT'] ); + $events = pin_next_online_wordcamp( $events, $user_agent, time(), $location['country'] ?? '' ); + $events = pin_next_workshop_discussion_group( $events, $user_agent ); $events = pin_one_off_events( $events, time() ); $events = remove_duplicate_events( $events ); @@ -331,7 +387,12 @@ function build_response( $location, $location_args ) { * @return bool */ function is_client_core( $user_agent = null ) { - return str_starts_with( $user_agent ?? $_SERVER['HTTP_USER_AGENT'], 'WordPress/' ); + if ( null === $user_agent ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for a prefix comparison, never output or stored. + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + } + + return str_starts_with( $user_agent, 'WordPress/' ); } /** @@ -722,7 +783,7 @@ function get_country_code_from_locale( $locale ) { return null; } - preg_match( '/^[a-z]+[-_]([a-z]+)$/i', $locale, $match ); + preg_match( '/^[a-z]+[-_]([a-z]+)\z/i', $locale, $match ); $country_code = $match[1] ?? null; @@ -800,7 +861,6 @@ function guess_location_from_country( $location_name ) { return $country; } - /** * Get the country that corresponds to the given country name * diff --git a/api.wordpress.org/public_html/events/1.0/tests/Test_Parse_Request.php b/api.wordpress.org/public_html/events/1.0/tests/Test_Parse_Request.php new file mode 100644 index 0000000000..e62ac222a0 --- /dev/null +++ b/api.wordpress.org/public_html/events/1.0/tests/Test_Parse_Request.php @@ -0,0 +1,305 @@ +backup_get = $_GET; + $this->backup_post = $_POST; + $this->backup_request = $_REQUEST; + $this->backup_server = $_SERVER; + + $_GET = array(); + $_POST = array(); + $_REQUEST = array(); + } + + /** + * Restores the superglobals. + */ + public function tearDown(): void { + $_GET = $this->backup_get; + $_POST = $this->backup_post; + $_REQUEST = $this->backup_request; + $_SERVER = $this->backup_server; + + parent::tearDown(); + } + + /** + * A request without parameters should produce only the defaults. + * + * @covers ::parse_request + */ + public function test_no_input_yields_defaults(): void { + $args = parse_request(); + + $this->assertSame( array( 'restrict_by_country' => false ), $args ); + } + + /** + * Numeric coordinates should be cast to floats. + * + * @covers ::parse_request + */ + public function test_valid_coordinates_are_cast_to_floats(): void { + $_GET['latitude'] = '52.52'; + $_GET['longitude'] = '13.4'; + + $args = parse_request(); + + $this->assertSame( 52.52, $args['latitude'] ); + $this->assertSame( 13.4, $args['longitude'] ); + } + + /** + * Non-numeric coordinates should be ignored entirely. + * + * @covers ::parse_request + * + * @dataProvider dataprovider_invalid_coordinates + * + * @param string $latitude The latitude request value. + * @param string $longitude The longitude request value. + */ + public function test_invalid_coordinates_are_ignored( $latitude, $longitude ): void { + $_GET['latitude'] = $latitude; + $_GET['longitude'] = $longitude; + + $args = parse_request(); + + $this->assertArrayNotHasKey( 'latitude', $args ); + $this->assertArrayNotHasKey( 'longitude', $args ); + } + + /** + * Data provider of invalid coordinate pairs. + * + * @return array + */ + public static function dataprovider_invalid_coordinates(): array { + return array( + 'non-numeric latitude' => array( 'abc', '13.4' ), + 'non-numeric longitude' => array( '52.52', 'def' ), + 'both non-numeric' => array( 'abc', 'def' ), + ); + } + + /** + * ISO 3166-1 alpha-2 and alpha-3 codes should be accepted verbatim. + * + * @covers ::parse_request + * + * @dataProvider dataprovider_valid_countries + * + * @param string $country The country request value. + */ + public function test_valid_country_is_accepted( $country ): void { + $_GET['country'] = $country; + + $args = parse_request(); + + $this->assertSame( $country, $args['country'] ); + $this->assertTrue( $args['restrict_by_country'] ); + } + + /** + * Data provider of valid country codes. + * + * @return array + */ + public static function dataprovider_valid_countries(): array { + return array( + 'alpha-2 lowercase' => array( 'de' ), + 'alpha-2 uppercase' => array( 'US' ), + 'alpha-3' => array( 'DEU' ), + ); + } + + /** + * An empty country parameter should behave as if it were absent. + * + * @covers ::parse_request + */ + public function test_empty_country_is_treated_as_absent(): void { + $_GET['country'] = ''; + + $args = parse_request(); + + $this->assertArrayNotHasKey( 'country', $args ); + $this->assertFalse( $args['restrict_by_country'] ); + } + + /** + * Location names should be trimmed and stripped of control characters. + * + * @covers ::parse_request + */ + public function test_location_name_is_trimmed_and_stripped_of_control_characters(): void { + $_REQUEST['location'] = " Ber\x01lin\n"; + + $args = parse_request(); + + $this->assertSame( 'Berlin', $args['location_name'] ); + } + + /** + * Timezones should be validated against the IANA identifier format. + * + * @covers ::parse_request + * + * @dataProvider dataprovider_timezones + * + * @param string $timezone The timezone request value. + * @param string $expected The expected parsed value. + */ + public function test_timezone_validation( $timezone, $expected ): void { + $_REQUEST['timezone'] = $timezone; + + $args = parse_request(); + + $this->assertSame( $expected, $args['timezone'] ); + } + + /** + * Data provider of timezone values. + * + * @return array + */ + public static function dataprovider_timezones(): array { + return array( + 'iana identifier' => array( 'America/New_York', 'America/New_York' ), + 'etc offset' => array( 'Etc/GMT+5', 'Etc/GMT+5' ), + 'invalid character' => array( 'America/New York', '' ), + 'trailing newline' => array( "America/New_York\n", '' ), + ); + } + + /** + * Locales should be validated against the WordPress locale format. + * + * @covers ::parse_request + * + * @dataProvider dataprovider_locales + * + * @param string $locale The locale request value. + * @param string $expected The expected parsed value. + */ + public function test_locale_validation( $locale, $expected ): void { + $_REQUEST['locale'] = $locale; + + $args = parse_request(); + + $this->assertSame( $expected, $args['locale'] ); + } + + /** + * Data provider of locale values. + * + * @return array + */ + public static function dataprovider_locales(): array { + return array( + 'simple' => array( 'en_US', 'en_US' ), + 'variant' => array( 'pt_PT_ao90', 'pt_PT_ao90' ), + 'invalid' => array( 'en US