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..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 @@ -1,4 +1,16 @@ array( + 'regexp' => '#^HTTP/[0-9.]+\z#', + 'default' => 'HTTP/1.0', + ), + ) + ); + + header( $protocol . ' ' . $code, true, $code ); $response = (object) [ 'error' => $error ]; + // 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. 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' ); + // 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}

" ); } @@ -42,9 +69,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 +85,11 @@ function send_error( $error, $code = 404 ) { // Set up action and request information. if ( defined( 'JSON_RESPONSE' ) && JSON_RESPONSE ) { + // 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 { + // 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, @@ -79,7 +109,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}\z/', + 'default' => '', + ), + ) +); // Validate the request. switch ( $action ) { @@ -94,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' ); } @@ -107,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' ); } @@ -140,6 +180,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..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 @@ -1,9 +1,30 @@ array( + 'regexp' => '/^[a-z_][a-z0-9_]*\z/i', + 'default' => false, + ), + ) + ); } else { $callback = false; } @@ -20,8 +41,10 @@ if ( $callback ) { header( 'Content-Type: text/javascript; charset=UTF-8' ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- validated callback, JSON payload. echo "$callback($response);"; } else { header( 'Content-Type: application/json; charset=UTF-8' ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON payload served as application/json. echo $response; } 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 bcba2ec784..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 @@ -1,9 +1,31 @@ array( + 'regexp' => '#^HTTP/[0-9.]+\z#', + 'default' => 'HTTP/1.0', + ), + ) + ); + + header( $protocol . ' 405 Method not allowed' ); + header( 'Allow: GET, HEAD, OPTIONS' ); header( 'Content-Type: text/plain' ); die( 'This API only serves GET requests.' ); @@ -15,8 +37,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}\z/', + '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..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 @@ -16,15 +16,19 @@ 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 { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The callback name doesn't change any state; the actions below verify a nonce. + $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_]*\z/i', $callback ) ) { $callback = false; } @@ -32,9 +36,11 @@ function api_send_json( $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 +52,45 @@ function api_send_json( $data ) { ) ); } -switch ( $_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_-]+\z/', + 'default' => '', + ), + ) +); + +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 = filter_var( + wp_unslash( $_REQUEST['theme'] ), + FILTER_VALIDATE_REGEXP, + array( + 'options' => array( + 'regexp' => '/^[a-z0-9_-]+\z/', + 'default' => '', + ), + ) + ); + + if ( ! $theme_slug ) { + api_send_json( array( 'error' => 'bad_request' ) ); + } - 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 );