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..7eefffbd5c 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'];
+ if (
+ isset( $_GET['latitude'], $_GET['longitude'] ) &&
+ is_numeric( $_GET['latitude'] ) && is_numeric( $_GET['longitude'] )
+ ) {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- is_numeric()-checked above; kept as strings because core compares the echoed values with ===.
+ $location_args['latitude'] = $_GET['latitude'];
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- is_numeric()-checked above; kept as strings because core compares the echoed values with ===.
$location_args['longitude'] = $_GET['longitude'];
}
- if ( isset( $_GET['country'] ) ) {
- $location_args['country'] = $_GET['country'];
+ if ( isset( $_GET['country'] ) && '' !== $_GET['country'] ) {
+ // An ISO 3166-1 alpha-2 country code, which is what events are stored with.
+ $country = filter_var(
+ $_GET['country'],
+ FILTER_VALIDATE_REGEXP,
+ array( 'options' => array( 'regexp' => '/^[a-z]{2}\z/i' ) )
+ );
+
+ if ( false === $country ) {
+ send_bad_request( 'country must be an ISO 3166-1 alpha-2 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 +217,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;
@@ -192,19 +242,33 @@ function validate_request() {
'ip',
];
+ $get_only = array( 'latitude', 'longitude', 'country' );
+
foreach ( $must_be_strings as $field ) {
- if ( isset( $_GET[ $field ] ) && ! is_scalar( $_GET[ $field ] ) ) {
+ // Check the superglobal that `parse_request()` reads the field from.
+ $source = in_array( $field, $get_only, true ) ? $_GET : $_REQUEST;
+
+ if ( isset( $source[ $field ] ) && ! is_scalar( $source[ $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 ) {
+ // phpcs:ignore WordPress.Security -- Public unauthenticated endpoint; the values are only validated here, never used or output.
+ foreach ( $_POST['location_data'] as $key => $value ) {
if ( ! is_scalar( $value ) ) {
// The key is omitted from the message because it is unsanitized request input.
send_bad_request( 'location_data values must be of type string.' );
}
+
+ // An invalid code would otherwise skip the country filter in `get_events()`.
+ if ( 'country' === $key && '' !== (string) $value && ! preg_match( '/^[a-z]{2}\z/i', (string) $value ) ) {
+ send_bad_request( 'country must be an ISO 3166-1 alpha-2 country code.' );
+ }
}
}
}
@@ -215,8 +279,8 @@ function validate_request() {
* The `Content-Type` is set explicitly, because the header that `send_response()` sets is only
* reached on the success path, and PHP would otherwise default this body to `text/html`.
*
- * `wp_json_encode()` is intentionally not used here; it is loaded by `bootstrap()`, which runs
- * after the request is validated.
+ * `wp_json_encode()` is intentionally not used here; it is loaded by `bootstrap()`, which has
+ * not run yet when `validate_request()` rejects a request.
*
* @param string $reason The reason the request was rejected. Must not contain any unescaped
* request input.
@@ -257,14 +321,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'] = (int) $_REQUEST['number'];
}
if ( ! empty( $location['latitude'] ) ) {
@@ -290,13 +357,13 @@ function build_response( $location, $location_args ) {
$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 +398,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 +794,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 +872,6 @@ function guess_location_from_country( $location_name ) {
return $country;
}
-
/**
* Get the country that corresponds to the given country name
*
@@ -910,7 +981,7 @@ function get_events( $args = array() ) {
}
// Allow queries for limiting to specific countries.
- if ( $args['restrict_by_country'] && ! empty( $args['country'] ) && preg_match( '![a-z]{2}!i', $args['country'] ) ) {
+ if ( $args['restrict_by_country'] && ! empty( $args['country'] ) && preg_match( '/^[a-z]{2}\z/i', $args['country'] ) ) {
$wheres[] = '`country` = %s';
$sql_values[] = $args['country'];
}
@@ -1081,7 +1152,7 @@ function build_sticky_wordcamp_query( $request_args, $distance ) {
}
// Allow queries for limiting to specific countries.
- if ( $request_args['restrict_by_country'] && ! empty( $request_args['country'] ) && preg_match( '![a-z]{2}!i', $request_args['country'] ) ) {
+ if ( $request_args['restrict_by_country'] && ! empty( $request_args['country'] ) && preg_match( '/^[a-z]{2}\z/i', $request_args['country'] ) ) {
$where[] = '`country` = %s';
$values[] = $request_args['country'];
}
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..c6f6d5c5b8
--- /dev/null
+++ b/api.wordpress.org/public_html/events/1.0/tests/Test_Parse_Request.php
@@ -0,0 +1,307 @@
+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 passed through unchanged. Core compares the
+ * echoed values to the ones it sent with a strict type check.
+ *
+ * @covers ::parse_request
+ */
+ public function test_valid_coordinates_are_preserved_as_strings(): 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( string $latitude, string $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 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( string $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' ),
+ );
+ }
+
+ /**
+ * 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( string $timezone, string $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( string $locale, string $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