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