From fe7ccd6d372cb02afb3f8bd6e40efc3409009541 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 20:05:24 -0500 Subject: [PATCH 1/8] Themes API: Validate actions and protocol input, document trusted output paths. API actions are validated against their expected format, the server protocol is validated before being echoed into status headers, and JSONP callbacks are sanitized at the source. The serialized/JSON response paths carry narrow, justified ignores since escaping would corrupt the payloads, and request parsing before WordPress loads is documented via justified file-level unslash/nonce disables. Co-Authored-By: Claude Fable 5 (cherry picked from commit 5c8b73f740980cc1047bcbba93c09ec636feff3a) --- .../public_html/themes/info/1.0/index.php | 62 +++++++++++++++++-- .../public_html/themes/info/1.1/index.php | 21 ++++++- .../public_html/themes/info/1.2/index.php | 40 +++++++++++- .../themes/theme-directory/1.0/index.php | 33 ++++++---- 4 files changed, 137 insertions(+), 19 deletions(-) diff --git a/api.wordpress.org/public_html/themes/info/1.0/index.php b/api.wordpress.org/public_html/themes/info/1.0/index.php index 0007b8492c..cd224afee7 100644 --- a/api.wordpress.org/public_html/themes/info/1.0/index.php +++ b/api.wordpress.org/public_html/themes/info/1.0/index.php @@ -2,6 +2,14 @@ namespace WordPressdotorg\API\Themes\Info; use function WordPressdotorg\API\load_wordpress; +/* + * This is a stateless public API endpoint, so there is no session or nonce infrastructure. + * The request is also parsed before `load_wordpress()` runs below, which means request data + * has not been slashed at that point. + * + * phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + */ + // This exposes the `load_wordpress()` function mentioned below. require dirname( dirname( dirname( __DIR__ ) ) ) . '/wp-init-ondemand.php'; @@ -17,19 +25,41 @@ function send_error( $error, $code = 404 ) { global $format; - header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' ' . $code, true, $code ); + $protocol = filter_var( + $_SERVER['SERVER_PROTOCOL'] ?? '', + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '#^HTTP/[0-9.]+$#', + 'default' => 'HTTP/1.0', + ), + ) + ); + + header( $protocol . ' ' . $code, true, $code ); $response = (object) [ 'error' => $error ]; + /* + * Only used for a substring comparison, never output or stored. + * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + */ + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + // Browsers get a nicer action not implemented error. if ( - 'GET' === $_SERVER['REQUEST_METHOD'] && - false === strpos( $_SERVER['HTTP_USER_AGENT'] ?? '', 'WordPress/' ) && + isset( $_SERVER['REQUEST_METHOD'] ) && 'GET' === $_SERVER['REQUEST_METHOD'] && + false === strpos( $user_agent, 'WordPress/' ) && false !== strpos( $error, 'Action not implemented.' ) ) { header( 'Content-Type: text/html; charset=utf-8' ); + /* + * Every caller passes a hard-coded message, one of which intentionally + * contains a link; WordPress and esc_html() are not loaded here. + * phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + */ die( "

{$error}

" ); } @@ -42,9 +72,10 @@ function send_error( $error, $code = 404 ) { } if ( 'php' === $format ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- serialized payload, not HTML. echo serialize( $response ); } else { - // JSON format + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON payload; no wp_json_encode() here. echo json_encode( $response ); } @@ -57,9 +88,19 @@ function send_error( $error, $code = 404 ) { // Set up action and request information. if ( defined( 'JSON_RESPONSE' ) && JSON_RESPONSE ) { + /* + * Individual fields are type-checked and sanitized by Themes_API; all + * database access runs through prepared WP_Query calls. + * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + */ $request = isset( $_REQUEST['request'] ) ? (object) $_REQUEST['request'] : ''; $format = 'json'; } else { + /* + * A serialized payload, screened for object injection below and unserialized + * with `allowed_classes` limited to stdClass. + * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + */ $post_request = isset( $_POST['request'] ) && is_string( $_POST['request'] ) ? $_POST['request'] : ''; if ( $post_request ) { // PHP Needs to get a non-urldecoded request, to avoid multibyte character malforming the request, @@ -79,7 +120,17 @@ function send_error( $error, $code = 404 ) { $format = 'php'; } -$action = $_REQUEST['action'] ?? ''; +// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- pre-existing; drives this endpoint's switch. +$action = filter_var( + $_REQUEST['action'] ?? '', + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '/^[a-z_]{1,32}$/', + 'default' => '', + ), + ) +); // Validate the request. switch ( $action ) { @@ -140,6 +191,7 @@ function send_error( $error, $code = 404 ) { $api->set_status_header(); +// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON or serialized payload, not HTML. echo $api->get_result( $format ); // Cache when a theme doesn't exist. See the validation handler above. diff --git a/api.wordpress.org/public_html/themes/info/1.1/index.php b/api.wordpress.org/public_html/themes/info/1.1/index.php index 7884e76418..0a96b18a73 100644 --- a/api.wordpress.org/public_html/themes/info/1.1/index.php +++ b/api.wordpress.org/public_html/themes/info/1.1/index.php @@ -1,9 +1,26 @@ array( + 'regexp' => '#^HTTP/[0-9.]+$#', + 'default' => 'HTTP/1.0', + ), + ) + ); + + header( $protocol . ' 405 Method not allowed' ); header( 'Allow: GET' ); header( 'Content-Type: text/plain' ); @@ -15,8 +39,20 @@ // Support "flat" requests, ie. no '?request[slug]=..` needed, just '?slug=...' if ( ! isset( $_GET['request'] ) ) { + // 1.2 only supports GET requests. + $requested_action = filter_var( + $_GET['action'] ?? '', + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '/^[a-z_]{1,32}$/', + 'default' => '', + ), + ) + ); + $_GET = $_REQUEST = array( - 'action' => $_GET['action'] ?? '', // 1.2 only supports GET requests + 'action' => $requested_action, 'request' => array_diff_key( $_GET, [ 'action' => false, 'callback' => false ] ), ); } diff --git a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php index 24d8d86a2f..fffc4920c1 100644 --- a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php +++ b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php @@ -16,25 +16,32 @@ require dirname( dirname( dirname( __DIR__ ) ) ) . '/wp-init.php'; function api_send_json( $data ) { + $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; + // Allow cross-domain calls from *.wordpress.org - if ( isset( $_SERVER['HTTP_ORIGIN'] ) && preg_match( '!^https?://([^.]+\.)?wordpress\.org/?$!i', $_SERVER['HTTP_ORIGIN'] ) ) { - header( 'Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN'] ); + if ( $origin && preg_match( '!^https?://([^.]+\.)?wordpress\.org/?$!i', $origin ) ) { + header( 'Access-Control-Allow-Origin: ' . $origin ); header( 'Access-Control-Allow-Credentials: true' ); // Allow cookies to be used. } - if ( isset( $_GET['callback'] ) ) { - $callback = preg_replace( '/[^a-z0-9_]/i', '', $_GET['callback'] ); - } else { - $callback = false; - } + /* + * The JSONP callback name doesn't change any state; the actions below + * verify a nonce. + * phpcs:ignore WordPress.Security.NonceVerification.Recommended + */ + $callback = isset( $_GET['callback'] ) + ? preg_replace( '/[^a-z0-9_]/i', '', sanitize_text_field( wp_unslash( $_GET['callback'] ) ) ) + : false; $json = wp_json_encode( $data ); if ( $callback ) { header( 'Content-Type:application/javascript; charset=' . get_option( 'blog_charset' ) ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- validated callback, JSON payload. echo "$callback( $json );"; } else { header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON payload served as application/json. echo $json; } die(); @@ -46,18 +53,22 @@ function api_send_json( $data ) { ) ); } -switch ( $_REQUEST['action'] ) { +$requested_action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; + +switch ( $requested_action ) { case 'add-favorite': case 'remove-favorite': - if ( ! isset( $_REQUEST['theme'] ) || ! isset( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce( $_REQUEST['_wpnonce'], 'modify-theme-favorite' ) ) { + $nonce = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : ''; + + if ( ! isset( $_REQUEST['theme'] ) || ! wp_verify_nonce( $nonce, 'modify-theme-favorite' ) ) { api_send_json( array( 'error' => 'bad_request' ) ); } - $theme_slug = wp_unslash( $_REQUEST['theme'] ); + $theme_slug = sanitize_key( wp_unslash( $_REQUEST['theme'] ) ); - if ( 'add-favorite' == $_REQUEST['action'] ) { + if ( 'add-favorite' == $requested_action ) { $result = wporg_themes_add_favorite( $theme_slug ); } else { $result = wporg_themes_remove_favorite( $theme_slug ); From 6e028d574058440d5205e69a88e86ae340916aba Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 20:39:15 -0500 Subject: [PATCH 2/8] Themes API: Address review feedback on callback and action validation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSONP callbacks that are not valid JavaScript identifiers (e.g. starting with a digit) now fall back to plain JSON instead of emitting invalid script. The theme-directory action and theme parameters are validated against their expected formats and rejected when malformed, rather than sanitize_key() silently transforming a malformed value into a valid one — which had made `add-favorite@@` executable where trunk rejected it. The 1.2 endpoint now rejects every non-GET/HEAD method, not just POST. Inline ignores are restored to single-line form, since 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/themes/info/1.0/index.php | 23 ++--------- .../public_html/themes/info/1.1/index.php | 5 +++ .../public_html/themes/info/1.2/index.php | 2 +- .../themes/theme-directory/1.0/index.php | 38 ++++++++++++++----- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/api.wordpress.org/public_html/themes/info/1.0/index.php b/api.wordpress.org/public_html/themes/info/1.0/index.php index cd224afee7..1e41009927 100644 --- a/api.wordpress.org/public_html/themes/info/1.0/index.php +++ b/api.wordpress.org/public_html/themes/info/1.0/index.php @@ -42,10 +42,7 @@ function send_error( $error, $code = 404 ) { 'error' => $error ]; - /* - * Only used for a substring comparison, never output or stored. - * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - */ + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for a substring comparison, never output or stored. $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; // Browsers get a nicer action not implemented error. @@ -55,11 +52,7 @@ function send_error( $error, $code = 404 ) { false !== strpos( $error, 'Action not implemented.' ) ) { header( 'Content-Type: text/html; charset=utf-8' ); - /* - * Every caller passes a hard-coded message, one of which intentionally - * contains a link; WordPress and esc_html() are not loaded here. - * phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - */ + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Every caller passes a hard-coded message, one of which intentionally contains a link; esc_html() is not loaded here. die( "

{$error}

" ); } @@ -88,19 +81,11 @@ function send_error( $error, $code = 404 ) { // Set up action and request information. if ( defined( 'JSON_RESPONSE' ) && JSON_RESPONSE ) { - /* - * Individual fields are type-checked and sanitized by Themes_API; all - * database access runs through prepared WP_Query calls. - * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - */ + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Fields are type-checked and sanitized by Themes_API; DB access uses prepared WP_Query calls. $request = isset( $_REQUEST['request'] ) ? (object) $_REQUEST['request'] : ''; $format = 'json'; } else { - /* - * A serialized payload, screened for object injection below and unserialized - * with `allowed_classes` limited to stdClass. - * phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - */ + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Screened for object injection below; unserialized with `allowed_classes` limited to stdClass. $post_request = isset( $_POST['request'] ) && is_string( $_POST['request'] ) ? $_POST['request'] : ''; if ( $post_request ) { // PHP Needs to get a non-urldecoded request, to avoid multibyte character malforming the request, diff --git a/api.wordpress.org/public_html/themes/info/1.1/index.php b/api.wordpress.org/public_html/themes/info/1.1/index.php index 0a96b18a73..dcd610ebc2 100644 --- a/api.wordpress.org/public_html/themes/info/1.1/index.php +++ b/api.wordpress.org/public_html/themes/info/1.1/index.php @@ -21,6 +21,11 @@ '', filter_var( $_GET['callback'], FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW ) ); + + // A callback that is not a valid JavaScript identifier falls back to plain JSON. + if ( $callback && ! preg_match( '/^[a-z_]/i', $callback ) ) { + $callback = false; + } } else { $callback = false; } diff --git a/api.wordpress.org/public_html/themes/info/1.2/index.php b/api.wordpress.org/public_html/themes/info/1.2/index.php index 5f6c3afab0..a52fa8277d 100644 --- a/api.wordpress.org/public_html/themes/info/1.2/index.php +++ b/api.wordpress.org/public_html/themes/info/1.2/index.php @@ -14,7 +14,7 @@ */ // Version 1.2+ only accepts GET requests -if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] === 'POST' ) { +if ( isset( $_SERVER['REQUEST_METHOD'] ) && ! in_array( $_SERVER['REQUEST_METHOD'], array( 'GET', 'HEAD' ), true ) ) { $protocol = filter_var( $_SERVER['SERVER_PROTOCOL'] ?? '', FILTER_VALIDATE_REGEXP, diff --git a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php index fffc4920c1..ac4357c8f5 100644 --- a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php +++ b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php @@ -24,14 +24,13 @@ function api_send_json( $data ) { header( 'Access-Control-Allow-Credentials: true' ); // Allow cookies to be used. } - /* - * The JSONP callback name doesn't change any state; the actions below - * verify a nonce. - * phpcs:ignore WordPress.Security.NonceVerification.Recommended - */ - $callback = isset( $_GET['callback'] ) - ? preg_replace( '/[^a-z0-9_]/i', '', sanitize_text_field( wp_unslash( $_GET['callback'] ) ) ) - : false; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The callback name doesn't change any state; the actions below verify a nonce. + $callback = isset( $_GET['callback'] ) ? preg_replace( '/[^a-z0-9_]/i', '', sanitize_text_field( wp_unslash( $_GET['callback'] ) ) ) : false; + + // A callback that is not a valid JavaScript identifier falls back to plain JSON. + if ( $callback && ! preg_match( '/^[a-z_]/i', $callback ) ) { + $callback = false; + } $json = wp_json_encode( $data ); @@ -53,7 +52,17 @@ function api_send_json( $data ) { ) ); } -$requested_action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; +// Reject rather than transform: a malformed action must not become a valid one. +$requested_action = filter_var( + wp_unslash( $_REQUEST['action'] ?? '' ), + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '/^[a-z0-9_-]+$/', + 'default' => '', + ), + ) +); switch ( $requested_action ) { case 'add-favorite': @@ -66,7 +75,16 @@ function api_send_json( $data ) { ) ); } - $theme_slug = sanitize_key( wp_unslash( $_REQUEST['theme'] ) ); + $theme_slug = filter_var( + wp_unslash( $_REQUEST['theme'] ), + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '/^[a-z0-9-]+$/', + 'default' => '', + ), + ) + ); if ( 'add-favorite' == $requested_action ) { $result = wporg_themes_add_favorite( $theme_slug ); From 81ed94b7421cd4ef2624c4d278bced89564d11f5 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 20:51:53 -0500 Subject: [PATCH 3/8] Themes API: Fold the phpcs justification comments into the file docblocks. Co-Authored-By: Claude Fable 5 --- api.wordpress.org/public_html/themes/info/1.1/index.php | 6 ++---- api.wordpress.org/public_html/themes/info/1.2/index.php | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/api.wordpress.org/public_html/themes/info/1.1/index.php b/api.wordpress.org/public_html/themes/info/1.1/index.php index dcd610ebc2..0e41655a78 100644 --- a/api.wordpress.org/public_html/themes/info/1.1/index.php +++ b/api.wordpress.org/public_html/themes/info/1.1/index.php @@ -2,15 +2,13 @@ /** * Themes API 1.1 endpoint: JSON wrapper around the 1.0 API. * - * @package WordPressdotorg\API\Themes - */ - -/* * This is a stateless public API endpoint, so there is no session or nonce infrastructure. * The request is also read before the 1.0 endpoint loads WordPress, which means request * data has not been slashed at that point. * * phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + * + * @package WordPressdotorg\API\Themes */ header( 'Access-Control-Allow-Origin: *' ); diff --git a/api.wordpress.org/public_html/themes/info/1.2/index.php b/api.wordpress.org/public_html/themes/info/1.2/index.php index a52fa8277d..07e5496f83 100644 --- a/api.wordpress.org/public_html/themes/info/1.2/index.php +++ b/api.wordpress.org/public_html/themes/info/1.2/index.php @@ -2,15 +2,13 @@ /** * Themes API 1.2 endpoint: GET-only wrapper with flat request support. * - * @package WordPressdotorg\API\Themes - */ - -/* * This is a stateless public API endpoint, so there is no session or nonce infrastructure. * The request is also read before the 1.0 endpoint loads WordPress, which means request * data has not been slashed at that point. * * phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.MissingUnslash + * + * @package WordPressdotorg\API\Themes */ // Version 1.2+ only accepts GET requests From 45ff222a7166675037713235cf3698c6c96d8722 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 20:55:34 -0500 Subject: [PATCH 4/8] Themes API: Validate JSONP callbacks in a single pass. Replaces the strip-then-check-first-character dance with one identifier validation; a callback that isn't a valid JavaScript identifier falls back to plain JSON instead of being partially salvaged into a different (wrong) function name. Co-Authored-By: Claude Fable 5 --- .../public_html/themes/info/1.1/index.php | 19 ++++++++++--------- .../themes/theme-directory/1.0/index.php | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/api.wordpress.org/public_html/themes/info/1.1/index.php b/api.wordpress.org/public_html/themes/info/1.1/index.php index 0e41655a78..343218e5e4 100644 --- a/api.wordpress.org/public_html/themes/info/1.1/index.php +++ b/api.wordpress.org/public_html/themes/info/1.1/index.php @@ -14,16 +14,17 @@ header( 'Access-Control-Allow-Origin: *' ); if ( isset( $_GET['callback'] ) && is_string( $_GET['callback'] ) ) { - $callback = preg_replace( - '/[^a-z0-9_]/i', - '', - filter_var( $_GET['callback'], FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW ) - ); - // A callback that is not a valid JavaScript identifier falls back to plain JSON. - if ( $callback && ! preg_match( '/^[a-z_]/i', $callback ) ) { - $callback = false; - } + $callback = filter_var( + $_GET['callback'], + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '/^[a-z_][a-z0-9_]*$/i', + 'default' => false, + ), + ) + ); } else { $callback = false; } diff --git a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php index ac4357c8f5..739c244b5c 100644 --- a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php +++ b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php @@ -25,10 +25,10 @@ function api_send_json( $data ) { } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The callback name doesn't change any state; the actions below verify a nonce. - $callback = isset( $_GET['callback'] ) ? preg_replace( '/[^a-z0-9_]/i', '', sanitize_text_field( wp_unslash( $_GET['callback'] ) ) ) : false; + $callback = isset( $_GET['callback'] ) ? sanitize_text_field( wp_unslash( $_GET['callback'] ) ) : ''; // A callback that is not a valid JavaScript identifier falls back to plain JSON. - if ( $callback && ! preg_match( '/^[a-z_]/i', $callback ) ) { + if ( ! preg_match( '/^[a-z_][a-z0-9_]*$/i', $callback ) ) { $callback = false; } From dff704c72da9b225ae1fd995a0d48cbaa2ef72b1 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 21:02:08 -0500 Subject: [PATCH 5/8] Themes API: Add a file docblock to the 1.0 endpoint carrying the phpcs justification. Co-Authored-By: Claude Fable 5 --- .../public_html/themes/info/1.0/index.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/api.wordpress.org/public_html/themes/info/1.0/index.php b/api.wordpress.org/public_html/themes/info/1.0/index.php index 1e41009927..dfcacf524c 100644 --- a/api.wordpress.org/public_html/themes/info/1.0/index.php +++ b/api.wordpress.org/public_html/themes/info/1.0/index.php @@ -1,15 +1,19 @@ Date: Wed, 12 Aug 2026 21:14:03 -0500 Subject: [PATCH 6/8] Themes API: Reject invalid theme slugs instead of storing empty favorites. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invalid or array `theme` parameter was coerced to an empty string or false by the validation fallback and still written to the user's favorites meta with a success response; it now returns bad_request. The slug pattern also allows underscores, which sanitize_title_with_dashes() preserves and the themes info endpoint already accepts. The 1.2 method guard no longer rejects OPTIONS — trunk served preflights — and the Allow header lists the permitted methods. Co-Authored-By: Claude Fable 5 --- .../public_html/themes/info/1.2/index.php | 4 ++-- .../public_html/themes/theme-directory/1.0/index.php | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/api.wordpress.org/public_html/themes/info/1.2/index.php b/api.wordpress.org/public_html/themes/info/1.2/index.php index 07e5496f83..d6d9c27799 100644 --- a/api.wordpress.org/public_html/themes/info/1.2/index.php +++ b/api.wordpress.org/public_html/themes/info/1.2/index.php @@ -12,7 +12,7 @@ */ // Version 1.2+ only accepts GET requests -if ( isset( $_SERVER['REQUEST_METHOD'] ) && ! in_array( $_SERVER['REQUEST_METHOD'], array( 'GET', 'HEAD' ), true ) ) { +if ( isset( $_SERVER['REQUEST_METHOD'] ) && ! in_array( $_SERVER['REQUEST_METHOD'], array( 'GET', 'HEAD', 'OPTIONS' ), true ) ) { $protocol = filter_var( $_SERVER['SERVER_PROTOCOL'] ?? '', FILTER_VALIDATE_REGEXP, @@ -25,7 +25,7 @@ ); header( $protocol . ' 405 Method not allowed' ); - header( 'Allow: GET' ); + header( 'Allow: GET, HEAD, OPTIONS' ); header( 'Content-Type: text/plain' ); die( 'This API only serves GET requests.' ); diff --git a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php index 739c244b5c..0993e4b161 100644 --- a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php +++ b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php @@ -80,12 +80,20 @@ function api_send_json( $data ) { FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '/^[a-z0-9-]+$/', + 'regexp' => '/^[a-z0-9_-]+$/', 'default' => '', ), ) ); + if ( ! $theme_slug ) { + api_send_json( + array( + 'error' => 'bad_request', + ) + ); + } + if ( 'add-favorite' == $requested_action ) { $result = wporg_themes_add_favorite( $theme_slug ); } else { From 98bb3b1332f97ef290075b12e8d02ec553fd8047 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 21:19:58 -0500 Subject: [PATCH 7/8] Themes API: Compact the bad_request response to one line. Co-Authored-By: Claude Fable 5 --- .../public_html/themes/theme-directory/1.0/index.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php index 0993e4b161..04cc27ba1b 100644 --- a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php +++ b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php @@ -87,11 +87,7 @@ function api_send_json( $data ) { ); if ( ! $theme_slug ) { - api_send_json( - array( - 'error' => 'bad_request', - ) - ); + api_send_json( array( 'error' => 'bad_request' ) ); } if ( 'add-favorite' == $requested_action ) { From 6f2ae8e069ef730d8386dbe922869bddff52cc53 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 21:35:16 -0500 Subject: [PATCH 8/8] Themes API: Anchor validation regexes with \z. PCRE's $ end-anchor matches before a trailing newline, letting values like "twentytwenty\n" pass slug and protocol validation. Co-Authored-By: Claude Fable 5 --- api.wordpress.org/public_html/themes/info/1.0/index.php | 8 ++++---- api.wordpress.org/public_html/themes/info/1.1/index.php | 2 +- api.wordpress.org/public_html/themes/info/1.2/index.php | 4 ++-- .../public_html/themes/theme-directory/1.0/index.php | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/api.wordpress.org/public_html/themes/info/1.0/index.php b/api.wordpress.org/public_html/themes/info/1.0/index.php index dfcacf524c..90bd9d9939 100644 --- a/api.wordpress.org/public_html/themes/info/1.0/index.php +++ b/api.wordpress.org/public_html/themes/info/1.0/index.php @@ -34,7 +34,7 @@ function send_error( $error, $code = 404 ) { FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '#^HTTP/[0-9.]+$#', + 'regexp' => '#^HTTP/[0-9.]+\z#', 'default' => 'HTTP/1.0', ), ) @@ -115,7 +115,7 @@ function send_error( $error, $code = 404 ) { FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '/^[a-z_]{1,32}$/', + 'regexp' => '/^[a-z_]{1,32}\z/', 'default' => '', ), ) @@ -134,7 +134,7 @@ function send_error( $error, $code = 404 ) { } foreach ( $slugs as $slug ) { - if ( ! $slug || ! is_string( $slug ) || ! preg_match( '/^[a-z0-9-_]+$/', $slug ) ) { + if ( ! $slug || ! is_string( $slug ) || ! preg_match( '/^[a-z0-9-_]+\z/', $slug ) ) { send_error( 'Invalid slugs provided' ); } @@ -147,7 +147,7 @@ function send_error( $error, $code = 404 ) { if ( ! $slug ) { send_error( 'Slug not provided' ); } - if ( ! is_string( $slug ) || ! preg_match( '/^[a-z0-9-_]+$/', $slug ) ) { + if ( ! is_string( $slug ) || ! preg_match( '/^[a-z0-9-_]+\z/', $slug ) ) { send_error( 'Invalid slug provided' ); } diff --git a/api.wordpress.org/public_html/themes/info/1.1/index.php b/api.wordpress.org/public_html/themes/info/1.1/index.php index 343218e5e4..2f067a681d 100644 --- a/api.wordpress.org/public_html/themes/info/1.1/index.php +++ b/api.wordpress.org/public_html/themes/info/1.1/index.php @@ -20,7 +20,7 @@ FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '/^[a-z_][a-z0-9_]*$/i', + 'regexp' => '/^[a-z_][a-z0-9_]*\z/i', 'default' => false, ), ) diff --git a/api.wordpress.org/public_html/themes/info/1.2/index.php b/api.wordpress.org/public_html/themes/info/1.2/index.php index d6d9c27799..89b8eb60db 100644 --- a/api.wordpress.org/public_html/themes/info/1.2/index.php +++ b/api.wordpress.org/public_html/themes/info/1.2/index.php @@ -18,7 +18,7 @@ FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '#^HTTP/[0-9.]+$#', + 'regexp' => '#^HTTP/[0-9.]+\z#', 'default' => 'HTTP/1.0', ), ) @@ -43,7 +43,7 @@ FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '/^[a-z_]{1,32}$/', + 'regexp' => '/^[a-z_]{1,32}\z/', 'default' => '', ), ) diff --git a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php index 04cc27ba1b..7c5d9c82e4 100644 --- a/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php +++ b/api.wordpress.org/public_html/themes/theme-directory/1.0/index.php @@ -28,7 +28,7 @@ function api_send_json( $data ) { $callback = isset( $_GET['callback'] ) ? sanitize_text_field( wp_unslash( $_GET['callback'] ) ) : ''; // A callback that is not a valid JavaScript identifier falls back to plain JSON. - if ( ! preg_match( '/^[a-z_][a-z0-9_]*$/i', $callback ) ) { + if ( ! preg_match( '/^[a-z_][a-z0-9_]*\z/i', $callback ) ) { $callback = false; } @@ -58,7 +58,7 @@ function api_send_json( $data ) { FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '/^[a-z0-9_-]+$/', + 'regexp' => '/^[a-z0-9_-]+\z/', 'default' => '', ), ) @@ -80,7 +80,7 @@ function api_send_json( $data ) { FILTER_VALIDATE_REGEXP, array( 'options' => array( - 'regexp' => '/^[a-z0-9_-]+$/', + 'regexp' => '/^[a-z0-9_-]+\z/', 'default' => '', ), )