From ac8d456aed6484eafc593143ffdb4cdad29215dd Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 20:05:24 -0500 Subject: [PATCH 1/5] Trac, GitHub & HelpScout endpoints: Validate signatures and sanitize request input. GitHub and HelpScout webhook signatures are format-validated before the constant-time comparison, event headers and Trac hints are run through sanitize_key()/sanitize_text_field(), the Trac oembed URL is stripped of control characters ahead of the existing host allowlist, HTML error echoes are escaped, and Trac API exception messages are escaped at the throw site. The mentions handler now rejects non-string and non-object payloads instead of fataling further down. Signature- and secret-authenticated handlers document their auth mechanism in justified file-level nonce disables. Co-Authored-By: Claude Fable 5 (cherry picked from commit e9417364beed6532b15e9a4237154eee60a79229) --- .../public_html/dotorg/github/activity.php | 17 +++++++++--- .../public_html/dotorg/helpscout/common.php | 16 ++++++++++-- .../dotorg/helpscout/plugins-themes.php | 4 +-- .../public_html/dotorg/helpscout/webhook.php | 2 +- .../dotorg/trac/mentions-handler.php | 21 +++++++++++++++ .../public_html/dotorg/trac/oembed/index.php | 20 +++++++++++--- .../public_html/dotorg/trac/pr/class-trac.php | 2 +- .../public_html/dotorg/trac/pr/index.php | 14 ++++++---- .../public_html/dotorg/trac/pr/webhook.php | 26 ++++++++++++++++--- 9 files changed, 100 insertions(+), 22 deletions(-) diff --git a/api.wordpress.org/public_html/dotorg/github/activity.php b/api.wordpress.org/public_html/dotorg/github/activity.php index f8ca79e7cc..0844caaa89 100644 --- a/api.wordpress.org/public_html/dotorg/github/activity.php +++ b/api.wordpress.org/public_html/dotorg/github/activity.php @@ -43,7 +43,16 @@ function get_signed_payload_or_die() { $payload = file_get_contents( 'php://input' ); // Validate that the request came from GitHub. - $sent_signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256']; + $sent_signature = (string) filter_var( + wp_unslash( $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '' ), + FILTER_VALIDATE_REGEXP, + [ + 'options' => [ + 'regexp' => '/^sha256=[0-9a-f]{64}$/i', + 'default' => '', + ], + ] + ); $expected_signature = 'sha256=' . hash_hmac( 'sha256', $payload, constant( 'GH_ACTIVITY_WEBHOOK_SECRET' ) ); if ( ! hash_equals( $expected_signature, $sent_signature ) ) { @@ -71,7 +80,7 @@ function github_user_to_user_id( $user ) { return $user_id ? intval( $user_id ) : false; } -$event = $_SERVER['HTTP_X_GITHUB_EVENT']; +$event = sanitize_key( wp_unslash( $_SERVER['HTTP_X_GITHUB_EVENT'] ?? '' ) ); $payload = get_signed_payload_or_die(); // Ignore anything on private repos. @@ -163,7 +172,7 @@ function github_user_to_user_id( $user ) { case 'issues': if ( ! in_array( $payload->action, [ 'opened', 'edited', 'closed', 'deleted' ] ) ) { header( 'HTTP/1.0 422 Unprocessable Entity', true, 422 ); - die( "NO; $event:{$payload->action} not required." ); + die( esc_html( "NO; $event:{$payload->action} not required." ) ); } // Update the Title & Description if it's edited. Just to keep everything in sync. @@ -215,7 +224,7 @@ function github_user_to_user_id( $user ) { if ( ! in_array( $payload->action, [ 'opened', 'reopened', 'edited', 'closed' ] ) ) { header( 'HTTP/1.0 422 Unprocessable Entity', true, 422 ); - die( "NO; $event:{$payload->action} not required." ); + die( esc_html( "NO; $event:{$payload->action} not required." ) ); } // Update the Title & Description if it's edited. Just to keep everything in sync. diff --git a/api.wordpress.org/public_html/dotorg/helpscout/common.php b/api.wordpress.org/public_html/dotorg/helpscout/common.php index 92fc0c12f3..937479502d 100644 --- a/api.wordpress.org/public_html/dotorg/helpscout/common.php +++ b/api.wordpress.org/public_html/dotorg/helpscout/common.php @@ -27,8 +27,20 @@ function get_request() { // HelpScout sends json data in the POST, so grab it from the input directly. $HTTP_RAW_POST_DATA = file_get_contents( 'php://input' ); - // Check the signature matches. - if ( ! is_from_helpscout( $HTTP_RAW_POST_DATA, $_SERVER['HTTP_X_HELPSCOUT_SIGNATURE'] ?? '' ) ) { + // Check the signature matches. It's a base64 encoded HMAC, allowing for the URL-safe alphabet. + $signature = (string) filter_var( + wp_unslash( $_SERVER['HTTP_X_HELPSCOUT_SIGNATURE'] ?? '' ), + FILTER_VALIDATE_REGEXP, + [ + 'options' => [ + 'regexp' => '!^[A-Za-z0-9+/=_-]+$!', + 'default' => '', + ], + ] + ); + + // phpcs:ignore PHPCompatibility.Variables.RemovedPredefinedGlobalVariables -- Local variable named after the old global, assigned from php://input above. + if ( ! is_from_helpscout( $HTTP_RAW_POST_DATA, $signature ) ) { exit; } diff --git a/api.wordpress.org/public_html/dotorg/helpscout/plugins-themes.php b/api.wordpress.org/public_html/dotorg/helpscout/plugins-themes.php index 5ca6231eb9..810056609b 100644 --- a/api.wordpress.org/public_html/dotorg/helpscout/plugins-themes.php +++ b/api.wordpress.org/public_html/dotorg/helpscout/plugins-themes.php @@ -66,7 +66,7 @@ ] ); if ( $post_ids ) { - echo '

' . ucwords( $type ) . ' mentioned in this email:

'; + echo '

' . esc_html( ucwords( $type ) ) . ' mentioned in this email:

'; display_items( $post_ids ); @@ -84,7 +84,7 @@ $items = get_user_items( $user ); if ( $items ) { $url = add_query_arg( [ 'post_type' => $repo_post_types[ $type ], 'author' => $user->ID ], admin_url( 'edit.php' ) ); - echo '

' . ucwords( $type ) . ' owned by this user:

'; + echo '

' . esc_html( ucwords( $type ) ) . ' owned by this user:

'; display_items( $items ); diff --git a/api.wordpress.org/public_html/dotorg/helpscout/webhook.php b/api.wordpress.org/public_html/dotorg/helpscout/webhook.php index c2ff332d00..4a6be57bc8 100644 --- a/api.wordpress.org/public_html/dotorg/helpscout/webhook.php +++ b/api.wordpress.org/public_html/dotorg/helpscout/webhook.php @@ -8,7 +8,7 @@ // $request is the validated HelpScout request. $request = get_request(); -$event = $_SERVER['HTTP_X_HELPSCOUT_EVENT'] ?? ''; +$event = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_HELPSCOUT_EVENT'] ?? '' ) ); // Warm the caches. get_email_thread( $request->id, true ); diff --git a/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php b/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php index 151b1008a4..9fe0e9d5da 100644 --- a/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php +++ b/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php @@ -1,4 +1,16 @@ Temporarily Unavailable'; wp_cache_set( $cache_key, $output, 'trac-oembed', MINUTE_IN_SECONDS ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static HTML markup defined above; this endpoint serves text/html. die( $output ); } @@ -324,4 +337,5 @@ function send() { wp_cache_set( $cache_key, $data, 'trac-oembed', HOUR_IN_SECONDS ); +// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Complete HTML document built by DOMDocument above; this endpoint serves text/html and escaping it would destroy the markup. echo $data; \ No newline at end of file diff --git a/api.wordpress.org/public_html/dotorg/trac/pr/class-trac.php b/api.wordpress.org/public_html/dotorg/trac/pr/class-trac.php index 6cb9e65a62..f9b3eddfab 100644 --- a/api.wordpress.org/public_html/dotorg/trac/pr/class-trac.php +++ b/api.wordpress.org/public_html/dotorg/trac/pr/class-trac.php @@ -148,7 +148,7 @@ protected function _api( $method, $params ) { $json = $this->trac_json_deobjectify( $json ); } elseif ( $json && isset( $json->error ) ) { - throw new \Exception( 'JSON Error: ' . $json->error->code . ' ' . $json->error->message ); + throw new \Exception( 'JSON Error: ' . esc_html( $json->error->code ) . ' ' . esc_html( $json->error->message ) ); } elseif ( ! $json ) { throw new \Exception( 'Trac API Error: Trac Unavailable.' ); } diff --git a/api.wordpress.org/public_html/dotorg/trac/pr/index.php b/api.wordpress.org/public_html/dotorg/trac/pr/index.php index 5010171352..886eeeb501 100644 --- a/api.wordpress.org/public_html/dotorg/trac/pr/index.php +++ b/api.wordpress.org/public_html/dotorg/trac/pr/index.php @@ -1,15 +1,19 @@ [ + 'regexp' => '/^sha1=[0-9a-f]{40}$/i', + 'default' => '', + ], + ] + ); $expected_signature = 'sha1=' . hash_hmac( 'sha1', $HTTP_RAW_POST_DATA, GH_PRBOT_WEBHOOK_SECRET ); if ( ! hash_equals( $expected_signature, $sent_signature ) ) { @@ -33,11 +50,12 @@ function verify_signature() { $payload = json_decode( $HTTP_RAW_POST_DATA ); -if ( ! empty( $_GET['trac'] ) ) { - define( 'WEBHOOK_TRAC_HINT', $_GET['trac'] ); +$trac_hint = sanitize_key( wp_unslash( $_GET['trac'] ?? '' ) ); +if ( $trac_hint ) { + define( 'WEBHOOK_TRAC_HINT', $trac_hint ); } -switch ( $_SERVER['HTTP_X_GITHUB_EVENT'] ) { +switch ( $_SERVER['HTTP_X_GITHUB_EVENT'] ?? '' ) { // Pull Request case 'pull_request': From ea27fb5cc92a62884f41d39cab5ed6f6c18ddbb3 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 20:42:03 -0500 Subject: [PATCH 2/5] GitHub PR webhook: Normalize the event header before dispatch. Runs HTTP_X_GITHUB_EVENT through sanitize_key()/wp_unslash() before the switch, matching how the sibling activity webhook handles the same header. Co-Authored-By: Claude Fable 5 --- api.wordpress.org/public_html/dotorg/trac/pr/webhook.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.wordpress.org/public_html/dotorg/trac/pr/webhook.php b/api.wordpress.org/public_html/dotorg/trac/pr/webhook.php index 444860dadc..8f1189a093 100644 --- a/api.wordpress.org/public_html/dotorg/trac/pr/webhook.php +++ b/api.wordpress.org/public_html/dotorg/trac/pr/webhook.php @@ -55,7 +55,7 @@ function verify_signature() { define( 'WEBHOOK_TRAC_HINT', $trac_hint ); } -switch ( $_SERVER['HTTP_X_GITHUB_EVENT'] ?? '' ) { +switch ( sanitize_key( wp_unslash( $_SERVER['HTTP_X_GITHUB_EVENT'] ?? '' ) ) ) { // Pull Request case 'pull_request': From 229c49cc193f78e6a459af740dabd014eec2b794 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 20:52:16 -0500 Subject: [PATCH 3/5] Trac endpoints: Fold the phpcs justification comments into the file docblocks. Co-Authored-By: Claude Fable 5 --- .../public_html/dotorg/trac/mentions-handler.php | 6 ++---- api.wordpress.org/public_html/dotorg/trac/oembed/index.php | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php b/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php index 9fe0e9d5da..9d7e825d64 100644 --- a/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php +++ b/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php @@ -2,14 +2,12 @@ /** * Trac comment mentions handler: creates notifications for mentioned users. * - * @package WordPressdotorg\API\Trac - */ - -/* * This endpoint is called by Trac, not by a browser: requests are authenticated by the * shared URL_SECRET__MENTIONS secret posted alongside the payload, not by a user session. * * phpcs:disable WordPress.Security.NonceVerification + * + * @package WordPressdotorg\API\Trac */ define( 'BLOCKED', 0 ); diff --git a/api.wordpress.org/public_html/dotorg/trac/oembed/index.php b/api.wordpress.org/public_html/dotorg/trac/oembed/index.php index 9e2ccd015b..39d9be13cf 100644 --- a/api.wordpress.org/public_html/dotorg/trac/oembed/index.php +++ b/api.wordpress.org/public_html/dotorg/trac/oembed/index.php @@ -16,14 +16,12 @@ * * Please do not abuse this API, otherwise an API KEY will become required. * - * @package WordPressdotorg\API\Trac - */ - -/* * This is a public, unauthenticated, stateless oEmbed endpoint: requests are anonymous * GET requests from arbitrary sites, so there is no session or nonce to verify. * * phpcs:disable WordPress.Security.NonceVerification + * + * @package WordPressdotorg\API\Trac */ include dirname( dirname( dirname( __DIR__ ) ) ) . '/wp-init.php'; From d72c73eb063986dc0acb8c9d2b9ab257c9805317 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 12 Aug 2026 21:03:13 -0500 Subject: [PATCH 4/5] Trac PR endpoints: Add file docblocks carrying the phpcs justifications. Co-Authored-By: Claude Fable 5 --- api.wordpress.org/public_html/dotorg/trac/pr/index.php | 10 +++++++--- .../public_html/dotorg/trac/pr/webhook.php | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/api.wordpress.org/public_html/dotorg/trac/pr/index.php b/api.wordpress.org/public_html/dotorg/trac/pr/index.php index 886eeeb501..ec89771b31 100644 --- a/api.wordpress.org/public_html/dotorg/trac/pr/index.php +++ b/api.wordpress.org/public_html/dotorg/trac/pr/index.php @@ -1,13 +1,17 @@ Date: Wed, 12 Aug 2026 21:30:13 -0500 Subject: [PATCH 5/5] Trac, GitHub & HelpScout endpoints: Address review findings on auth and escaping. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR-bot webhook now fails closed with a 500 when its secret constant is undefined, matching the activity webhook, instead of treating every request as authenticated. The mentions handler compares its shared secret with hash_equals() on the unslashed value — the slashed !== comparison would silently never match a secret containing quotes or backslashes, and wasn't constant-time. HelpScout's raw-body variable loses its needless global and its legacy name. Exception messages and text/plain diagnostics are no longer HTML-escaped — their only consumers are catch sites and plain-text logs where entities corrupt the output — carrying justified ignores instead. Signature regexes anchor with \z so a trailing newline can't pass validation. Co-Authored-By: Claude Fable 5 --- .../public_html/dotorg/github/activity.php | 8 +++++--- .../public_html/dotorg/helpscout/common.php | 11 ++++------- .../public_html/dotorg/trac/mentions-handler.php | 2 +- .../public_html/dotorg/trac/pr/class-trac.php | 3 ++- .../public_html/dotorg/trac/pr/webhook.php | 5 +++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/api.wordpress.org/public_html/dotorg/github/activity.php b/api.wordpress.org/public_html/dotorg/github/activity.php index 0844caaa89..7ab9d5f1cc 100644 --- a/api.wordpress.org/public_html/dotorg/github/activity.php +++ b/api.wordpress.org/public_html/dotorg/github/activity.php @@ -48,7 +48,7 @@ function get_signed_payload_or_die() { FILTER_VALIDATE_REGEXP, [ 'options' => [ - 'regexp' => '/^sha256=[0-9a-f]{64}$/i', + 'regexp' => '/^sha256=[0-9a-f]{64}\z/i', 'default' => '', ], ] @@ -172,7 +172,8 @@ function github_user_to_user_id( $user ) { case 'issues': if ( ! in_array( $payload->action, [ 'opened', 'edited', 'closed', 'deleted' ] ) ) { header( 'HTTP/1.0 422 Unprocessable Entity', true, 422 ); - die( esc_html( "NO; $event:{$payload->action} not required." ) ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain-text response body; the endpoint sends Content-Type: text/plain. + die( "NO; $event:{$payload->action} not required." ); } // Update the Title & Description if it's edited. Just to keep everything in sync. @@ -224,7 +225,8 @@ function github_user_to_user_id( $user ) { if ( ! in_array( $payload->action, [ 'opened', 'reopened', 'edited', 'closed' ] ) ) { header( 'HTTP/1.0 422 Unprocessable Entity', true, 422 ); - die( esc_html( "NO; $event:{$payload->action} not required." ) ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain-text response body; the endpoint sends Content-Type: text/plain. + die( "NO; $event:{$payload->action} not required." ); } // Update the Title & Description if it's edited. Just to keep everything in sync. diff --git a/api.wordpress.org/public_html/dotorg/helpscout/common.php b/api.wordpress.org/public_html/dotorg/helpscout/common.php index 937479502d..ef30866683 100644 --- a/api.wordpress.org/public_html/dotorg/helpscout/common.php +++ b/api.wordpress.org/public_html/dotorg/helpscout/common.php @@ -22,10 +22,8 @@ function load_wordpress( $wp_init_host = '' ) { * Retrieve the incoming payload, and verify it's from HelpScout. */ function get_request() { - global $HTTP_RAW_POST_DATA; - // HelpScout sends json data in the POST, so grab it from the input directly. - $HTTP_RAW_POST_DATA = file_get_contents( 'php://input' ); + $raw_post_data = file_get_contents( 'php://input' ); // Check the signature matches. It's a base64 encoded HMAC, allowing for the URL-safe alphabet. $signature = (string) filter_var( @@ -33,19 +31,18 @@ function get_request() { FILTER_VALIDATE_REGEXP, [ 'options' => [ - 'regexp' => '!^[A-Za-z0-9+/=_-]+$!', + 'regexp' => '!^[A-Za-z0-9+/=_-]+\z!', 'default' => '', ], ] ); - // phpcs:ignore PHPCompatibility.Variables.RemovedPredefinedGlobalVariables -- Local variable named after the old global, assigned from php://input above. - if ( ! is_from_helpscout( $HTTP_RAW_POST_DATA, $signature ) ) { + if ( ! is_from_helpscout( $raw_post_data, $signature ) ) { exit; } // get the info from HS. - return json_decode( $HTTP_RAW_POST_DATA ); + return json_decode( $raw_post_data ); } // function to verify signature from HelpScout diff --git a/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php b/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php index 9d7e825d64..cd93618418 100644 --- a/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php +++ b/api.wordpress.org/public_html/dotorg/trac/mentions-handler.php @@ -17,7 +17,7 @@ require dirname( dirname( __DIR__ ) ) . '/wp-init.php'; require dirname( dirname( __DIR__ ) ) . '/includes/slack-config.php'; -if ( ! isset( $_POST['secret'] ) || $_POST['secret'] !== \Dotorg\Slack\Trac\URL_SECRET__MENTIONS ) { +if ( ! is_string( $_POST['secret'] ?? null ) || ! hash_equals( \Dotorg\Slack\Trac\URL_SECRET__MENTIONS, wp_unslash( $_POST['secret'] ) ) ) { exit; } diff --git a/api.wordpress.org/public_html/dotorg/trac/pr/class-trac.php b/api.wordpress.org/public_html/dotorg/trac/pr/class-trac.php index f9b3eddfab..adfdda2d31 100644 --- a/api.wordpress.org/public_html/dotorg/trac/pr/class-trac.php +++ b/api.wordpress.org/public_html/dotorg/trac/pr/class-trac.php @@ -148,7 +148,8 @@ protected function _api( $method, $params ) { $json = $this->trac_json_deobjectify( $json ); } elseif ( $json && isset( $json->error ) ) { - throw new \Exception( 'JSON Error: ' . esc_html( $json->error->code ) . ' ' . esc_html( $json->error->message ) ); + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Discarded by every catch site or logged as plain text; escaping would corrupt the log line. + throw new \Exception( 'JSON Error: ' . $json->error->code . ' ' . $json->error->message ); } elseif ( ! $json ) { throw new \Exception( 'Trac API Error: Trac Unavailable.' ); } diff --git a/api.wordpress.org/public_html/dotorg/trac/pr/webhook.php b/api.wordpress.org/public_html/dotorg/trac/pr/webhook.php index b53c3ff8c8..b88ea43db9 100644 --- a/api.wordpress.org/public_html/dotorg/trac/pr/webhook.php +++ b/api.wordpress.org/public_html/dotorg/trac/pr/webhook.php @@ -24,7 +24,8 @@ function verify_signature() { // Validate that the request came from GitHub. if ( ! defined( 'GH_PRBOT_WEBHOOK_SECRET' ) ) { - return; + header( 'HTTP/1.0 500 Internal Server Error', true, 500 ); + die( 'Webhook secret not configured.' ); } $sent_signature = (string) filter_var( @@ -32,7 +33,7 @@ function verify_signature() { FILTER_VALIDATE_REGEXP, [ 'options' => [ - 'regexp' => '/^sha1=[0-9a-f]{40}$/i', + 'regexp' => '/^sha1=[0-9a-f]{40}\z/i', 'default' => '', ], ]