Skip to content
55 changes: 48 additions & 7 deletions api.wordpress.org/public_html/themes/info/1.0/index.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
<?php
/**
* Themes API 1.0 endpoint: theme information and queries for WordPress clients.
*
* 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
*
* @package WordPressdotorg\API\Themes
*/

namespace WordPressdotorg\API\Themes\Info;
use function WordPressdotorg\API\load_wordpress;

Expand All @@ -17,19 +29,34 @@
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.]+\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( "<p>{$error}</p>" );
}

Expand All @@ -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 );
}

Expand All @@ -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,
Expand All @@ -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 ) {
Expand All @@ -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' );
}

Expand All @@ -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' );
}

Expand Down Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion api.wordpress.org/public_html/themes/info/1.1/index.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
<?php
/**
* Themes API 1.1 endpoint: JSON wrapper around the 1.0 API.
*
* 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: *' );

if ( isset( $_GET['callback'] ) && is_string( $_GET['callback'] ) ) {
$callback = preg_replace( '/[^a-z0-9_]/i', '', $_GET['callback'] );
// A callback that is not a valid JavaScript identifier falls back to plain JSON.
$callback = filter_var(
$_GET['callback'],
FILTER_VALIDATE_REGEXP,
array(
'options' => array(
'regexp' => '/^[a-z_][a-z0-9_]*\z/i',
'default' => false,
),
)
);
} else {
$callback = false;
}
Expand All @@ -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;
}
42 changes: 38 additions & 4 deletions api.wordpress.org/public_html/themes/info/1.2/index.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
<?php
/**
* Themes API 1.2 endpoint: GET-only wrapper with flat request support.
*
* 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
if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] === 'POST' ) {
header( $_SERVER['SERVER_PROTOCOL'] . ' 405 Method not allowed' );
header( 'Allow: GET' );
if ( isset( $_SERVER['REQUEST_METHOD'] ) && ! in_array( $_SERVER['REQUEST_METHOD'], array( 'GET', 'HEAD', 'OPTIONS' ), true ) ) {
$protocol = filter_var(
Comment thread
obenland marked this conversation as resolved.
$_SERVER['SERVER_PROTOCOL'] ?? '',
FILTER_VALIDATE_REGEXP,
array(
'options' => 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.' );
Expand All @@ -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 ] ),
);
}
Expand Down
51 changes: 42 additions & 9 deletions api.wordpress.org/public_html/themes/theme-directory/1.0/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,31 @@
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;
}

$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();
Expand All @@ -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 );
Expand Down