From 19ec30053476c3a251658503e83519f86795d375 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 19:51:13 -0500 Subject: [PATCH 1/6] Events API: Validate and sanitize all request input at parse time. Coordinates are cast to floats, country/timezone/locale are validated against their expected formats, city names are stripped of control characters, and the user agent is captured once and documented as comparison-only. Also fixes two request-validation gaps: the scalar type check ran against $_GET while parse_request() reads $_REQUEST, and location_data was never verified to be an array, so malformed POST bodies could fatal. Nonce and unslash sniffs are disabled file-wide with justification: this is a standalone, unauthenticated endpoint where WordPress (and thus slashing and nonces) does not exist. Co-Authored-By: Claude Fable 5 (cherry picked from commit 90b6840ba5b67de0393a34508789dfbc1afc60e3) --- .../public_html/events/1.0/index.php | 98 ++++++++++++++++--- 1 file changed, 82 insertions(+), 16 deletions(-) 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..1abdb14ad4 100644 --- a/api.wordpress.org/public_html/events/1.0/index.php +++ b/api.wordpress.org/public_html/events/1.0/index.php @@ -2,6 +2,13 @@ namespace Dotorg\API\Events; use stdClass; +/* + * This is a standalone, unauthenticated, stateless API endpoint: WordPress is not loaded, + * so request data is never slashed, and there is no session or nonce infrastructure. + * + * phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + */ + /** * Main entry point */ @@ -127,32 +134,72 @@ function parse_request() { $location_args = array( 'restrict_by_country' => 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']; + // An ISO 3166-1 alpha-2 or alpha-3 country code. + $location_args['country'] = filter_var( + $_GET['country'], + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '/^[a-z]{2,3}$/i', + 'default' => '', + ), + ) + ); $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'] ) ) { + /* + * Values are scalar-checked in validate_request(); all downstream database + * access is prepared, and output is JSON-encoded. + * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + */ $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}$#', + '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}$/', + 'default' => '', + ), + ) + ); } if ( isset( $_REQUEST['ip'] ) ) { @@ -167,7 +214,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 : filter_var( $_SERVER['REMOTE_ADDR'] ?? '', FILTER_VALIDATE_IP ); } return $location_args; @@ -193,11 +240,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 +309,20 @@ function build_response( $location, $location_args ) { $error = 'temp-request-throttled'; } + /* + * Only used for prefix/substring comparisons, never output or stored. + * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + */ + $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']; + $event_args['number'] = intval( $_REQUEST['number'] ); } if ( ! empty( $location['latitude'] ) ) { @@ -285,18 +343,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 +389,15 @@ 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 ) { + /* + * Only used for a prefix comparison, never output or stored. + * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + */ + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + } + + return str_starts_with( $user_agent, 'WordPress/' ); } /** From 09bb4fee078738285eb9013094d5de6a6b9f75c2 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 20:36:42 -0500 Subject: [PATCH 2/6] Events API: Address review feedback on invalid input handling. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rejects invalid country codes with a 400 instead of silently degrading to an unrestricted query, casts the REMOTE_ADDR fallback to a string so a failed IP validation can't leak a boolean into the JSON response, and restores the inline phpcs ignores to single-line form — an annotation inside a multi-line block comment does not apply to the code following the comment. Co-Authored-By: Claude Fable 5 --- .../public_html/events/1.0/index.php | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) 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 1abdb14ad4..c99b80cbb1 100644 --- a/api.wordpress.org/public_html/events/1.0/index.php +++ b/api.wordpress.org/public_html/events/1.0/index.php @@ -144,26 +144,23 @@ function parse_request() { if ( isset( $_GET['country'] ) ) { // An ISO 3166-1 alpha-2 or alpha-3 country code. - $location_args['country'] = filter_var( + $country = filter_var( $_GET['country'], FILTER_VALIDATE_REGEXP, - array( - 'options' => array( - 'regexp' => '/^[a-z]{2,3}$/i', - 'default' => '', - ), - ) + array( 'options' => array( 'regexp' => '/^[a-z]{2,3}$/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'] ) ) { - /* - * Values are scalar-checked in validate_request(); all downstream database - * access is prepared, and output is JSON-encoded. - * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - */ + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Scalar-checked in validate_request(); DB access is prepared, output is JSON-encoded. $location_args = $_POST['location_data']; } @@ -214,7 +211,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 : filter_var( $_SERVER['REMOTE_ADDR'] ?? '', FILTER_VALIDATE_IP ); + $location_args['ip'] = $public_ip ? $public_ip : (string) filter_var( $_SERVER['REMOTE_ADDR'] ?? '', FILTER_VALIDATE_IP ); } return $location_args; @@ -309,10 +306,7 @@ function build_response( $location, $location_args ) { $error = 'temp-request-throttled'; } - /* - * Only used for prefix/substring comparisons, never output or stored. - * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - */ + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for prefix/substring comparisons, never output or stored. $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; if ( $location ) { @@ -390,10 +384,7 @@ function build_response( $location, $location_args ) { */ function is_client_core( $user_agent = null ) { if ( null === $user_agent ) { - /* - * Only used for a prefix comparison, never output or stored. - * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - */ + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for a prefix comparison, never output or stored. $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; } From 4383ca3993e0dcbfb9b66c302ed0cafd29e502b0 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 21:00:24 -0500 Subject: [PATCH 3/6] Events API: Add a file docblock carrying the phpcs justification. Co-Authored-By: Claude Fable 5 --- api.wordpress.org/public_html/events/1.0/index.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 c99b80cbb1..b27d7cdb57 100644 --- a/api.wordpress.org/public_html/events/1.0/index.php +++ b/api.wordpress.org/public_html/events/1.0/index.php @@ -1,14 +1,18 @@ Date: Wed, 12 Aug 2026 21:16:18 -0500 Subject: [PATCH 4/6] Events API: Preserve availability for benign malformed input. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty country parameter — e.g. echoed back from a stored location object — is treated as absent rather than rejected with a 400, which matches trunk; only genuinely malformed values are rejected. A non-numeric number parameter now falls back to the documented default of ten events instead of intval()'s zero producing an empty response (trunk's string/int min() comparison quirk effectively returned 100). Co-Authored-By: Claude Fable 5 --- api.wordpress.org/public_html/events/1.0/index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b27d7cdb57..7305b9fdc0 100644 --- a/api.wordpress.org/public_html/events/1.0/index.php +++ b/api.wordpress.org/public_html/events/1.0/index.php @@ -146,7 +146,7 @@ function parse_request() { $location_args['longitude'] = floatval( $_GET['longitude'] ); } - if ( isset( $_GET['country'] ) ) { + if ( isset( $_GET['country'] ) && '' !== $_GET['country'] ) { // An ISO 3166-1 alpha-2 or alpha-3 country code. $country = filter_var( $_GET['country'], @@ -319,7 +319,7 @@ function build_response( $location, $location_args ) { 'restrict_by_country' => $location_args['restrict_by_country'], ); - if ( isset( $_REQUEST['number'] ) ) { + if ( isset( $_REQUEST['number'] ) && is_numeric( $_REQUEST['number'] ) ) { $event_args['number'] = intval( $_REQUEST['number'] ); } From b653849a7273263e91135ea48c7065b655cd5531 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 21:33:59 -0500 Subject: [PATCH 5/6] Events API: Anchor validation regexes with \z. PCRE's $ end-anchor matches before a trailing newline, letting values like "us\n" pass validation. Co-Authored-By: Claude Fable 5 --- api.wordpress.org/public_html/events/1.0/index.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 7305b9fdc0..c916df03c2 100644 --- a/api.wordpress.org/public_html/events/1.0/index.php +++ b/api.wordpress.org/public_html/events/1.0/index.php @@ -151,7 +151,7 @@ function parse_request() { $country = filter_var( $_GET['country'], FILTER_VALIDATE_REGEXP, - array( 'options' => array( 'regexp' => '/^[a-z]{2,3}$/i' ) ) + array( 'options' => array( 'regexp' => '/^[a-z]{2,3}\z/i' ) ) ); if ( false === $country ) { @@ -182,7 +182,7 @@ function parse_request() { FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '#^[A-Za-z0-9/_+-]{1,50}$#', + 'regexp' => '#^[A-Za-z0-9/_+-]{1,50}\z#', 'default' => '', ), ) @@ -196,7 +196,7 @@ function parse_request() { FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '/^[A-Za-z0-9_-]{2,20}$/', + 'regexp' => '/^[A-Za-z0-9_-]{2,20}\z/', 'default' => '', ), ) @@ -783,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; From 8bdfae7d0c6d25015c21d6437e53a165a19567fd Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 21:41:27 -0500 Subject: [PATCH 6/6] Events API: Cover parse_request() input validation with unit tests. Table-driven tests for the validation added in this branch: coordinate float-casting and non-numeric rejection, country acceptance and the empty-value-as-absent rule, control-character stripping in location names, timezone and locale format validation including the trailing-newline case, IP fallback behavior, and the location_data override. The rejection paths that exit the process via send_bad_request() are left to the e2e suite. Co-Authored-By: Claude Fable 5 --- .../events/1.0/tests/Test_Parse_Request.php | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 api.wordpress.org/public_html/events/1.0/tests/Test_Parse_Request.php 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