From fd4a2fff6011739aacf6d8761de4f8baa9669ae5 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 16:27:45 +0200 Subject: [PATCH 1/6] chore: bump connect-sdk-php to v1.4.0-beta.8 for status challenge verifier --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 3cb1bf5..0064237 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "license": "GPL-2.0-or-later", "require": { "php": ">=8.1", - "getsupertab/connect-sdk-php": "1.4.0-beta.6" + "getsupertab/connect-sdk-php": "1.4.0-beta.8" }, "require-dev": { "automattic/vipwpcs": "^3.0", diff --git a/composer.lock b/composer.lock index bebee17..5efffdc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d90e97d565c18b9691457946a1327a8d", + "content-hash": "e5b4f7f264d7e71e5dfc75b8585f7cc7", "packages": [ { "name": "firebase/php-jwt", @@ -74,16 +74,16 @@ }, { "name": "getsupertab/connect-sdk-php", - "version": "v1.4.0-beta.6", + "version": "v1.4.0-beta.8", "source": { "type": "git", "url": "https://github.com/getsupertab/connect-sdk-php.git", - "reference": "d60880a7fb3e2e708fece8be4bfdea0f79c044cb" + "reference": "207a04f1d3de8c383ed62d4cecc24934d83d4dcb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsupertab/connect-sdk-php/zipball/d60880a7fb3e2e708fece8be4bfdea0f79c044cb", - "reference": "d60880a7fb3e2e708fece8be4bfdea0f79c044cb", + "url": "https://api.github.com/repos/getsupertab/connect-sdk-php/zipball/207a04f1d3de8c383ed62d4cecc24934d83d4dcb", + "reference": "207a04f1d3de8c383ed62d4cecc24934d83d4dcb", "shasum": "" }, "require": { @@ -125,9 +125,9 @@ ], "support": { "issues": "https://github.com/getsupertab/connect-sdk-php/issues", - "source": "https://github.com/getsupertab/connect-sdk-php/tree/v1.4.0-beta.6" + "source": "https://github.com/getsupertab/connect-sdk-php/tree/v1.4.0-beta.8" }, - "time": "2026-07-01T04:08:50+00:00" + "time": "2026-07-13T12:06:08+00:00" } ], "packages-dev": [ From df59d352e657df90d189f08ee71722990c196f75 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 16:33:30 +0200 Subject: [PATCH 2/6] feat: serve /.well-known/supertab/status self-report endpoint --- src/class-plugin.php | 2 +- src/class-status-handler.php | 260 +++++++++++++++++++++++++++++++++++ tests/StatusHandlerTest.php | 202 +++++++++++++++++++++++++++ tests/wp-stubs.php | 61 +++++++- 4 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 src/class-status-handler.php create mode 100644 tests/StatusHandlerTest.php diff --git a/src/class-plugin.php b/src/class-plugin.php index aa21890..c126b99 100644 --- a/src/class-plugin.php +++ b/src/class-plugin.php @@ -187,7 +187,7 @@ private function init_bot_protection( Settings $settings, HttpClientInterface $h * * @return EnforcementMode */ - private static function get_enforcement_mode(): EnforcementMode { + public static function get_enforcement_mode(): EnforcementMode { $default = EnforcementMode::OBSERVE; if ( defined( 'SUPERTAB_CONNECT_ENFORCEMENT_MODE' ) ) { diff --git a/src/class-status-handler.php b/src/class-status-handler.php new file mode 100644 index 0000000..12d0d1a --- /dev/null +++ b/src/class-status-handler.php @@ -0,0 +1,260 @@ +settings = $settings; + $this->api_base_url = $api_base_url; + $this->http_client = $http_client; + $this->verify_challenge = $verify_challenge; + } + + /** + * Register hooks. Priority 5 runs before Bot_Protection (9) so a probe + * never reaches bot detection or analytics. + * + * @return void + */ + public function register(): void { + add_action( 'parse_request', array( $this, 'maybe_handle_request' ), 5 ); + } + + /** + * Serve the status endpoint if the current request matches. + * + * @param \WP $wp The WordPress environment instance. + * @return void + */ + public function maybe_handle_request( \WP $wp ): void { + if ( self::REQUEST_STATUS_PATH !== $wp->request ) { + return; + } + + $response = $this->build_response( $this->get_authorization_header(), $this->get_request_origin() ); + + $this->send_json( $response['status'], $response['body'] ); + } + + /** + * Build the status response for the given credentials. + * + * A request carrying a valid backend-minted challenge (ES256 JWT with + * purpose "status-probe", aud = the request origin) gets the live config; + * anything else gets a minimal 404 decoy. + * + * @param string $authorization_header Raw Authorization header value. + * @param string $expected_audience Request origin (scheme://host[:port]). + * @return array{status: int, body: string} + */ + public function build_response( string $authorization_header, string $expected_audience ): array { + $token = str_starts_with( $authorization_header, 'Bearer ' ) + ? substr( $authorization_header, 7 ) + : ''; + + $verified = '' !== $token + && '' !== $expected_audience + && $this->verify_challenge( $token, $expected_audience ); + + if ( ! $verified ) { + return array( + 'status' => 404, + 'body' => (string) wp_json_encode( array( 'supertab' => true ) ), + ); + } + + // Mirrors the gate in Plugin::init(): bot protection (and with it, + // event reporting) only runs when both conditions hold. + $protection_active = $this->settings->has_merchant_api_key() + && $this->settings->is_bot_protection_enabled(); + + return array( + 'status' => 200, + 'body' => (string) wp_json_encode( + array( + 'runtime' => null, + 'sdkVersion' => HttpClient::resolveVersion(), + 'component' => array( + 'kind' => self::COMPONENT_KIND, + 'version' => SUPERTAB_CONNECT_VERSION, + ), + 'enforcement' => $protection_active ? Plugin::get_enforcement_mode()->value : 'disabled', + 'eventReporting' => $protection_active, + ) + ), + ); + } + + /** + * Verify the challenge token, never letting a failure escape. + * + * @param string $token The challenge JWT. + * @param string $expected_audience The expected audience (request origin). + * @return bool + */ + private function verify_challenge( string $token, string $expected_audience ): bool { + try { + if ( null !== $this->verify_challenge ) { + return (bool) ( $this->verify_challenge )( $token, $expected_audience ); + } + + return $this->get_verifier()->verify( $token, $expected_audience ); + } catch ( \Throwable $e ) { + return false; + } + } + + /** + * Lazily build the SDK verifier so normal requests pay no setup cost. + * + * @return StatusChallengeVerifier + */ + private function get_verifier(): StatusChallengeVerifier { + if ( null === $this->verifier ) { + $debug = defined( 'WP_DEBUG' ) && WP_DEBUG; + + $this->verifier = new StatusChallengeVerifier( + new JwksProvider( $this->api_base_url, $this->http_client, $debug, new WP_Transient_Cache() ), + $debug + ); + } + + return $this->verifier; + } + + /** + * Read the Authorization header, with the common Apache CGI fallback. + * + * @return string + */ + private function get_authorization_header(): string { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below via sanitize_text_field()/wp_unslash(). + $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; + + return sanitize_text_field( wp_unslash( (string) $header ) ); + } + + /** + * Derive the request origin (scheme://host[:port]) used as the expected + * challenge audience. Empty string when the host is unavailable. + * + * @return string + */ + private function get_request_origin(): string { + $host = isset( $_SERVER['HTTP_HOST'] ) + ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_HOST'] ) ) + : ''; + + if ( '' === $host ) { + return ''; + } + + return ( is_ssl() ? 'https' : 'http' ) . '://' . $host; + } + + /** + * Send a JSON response and terminate. + * + * @param int $status_code HTTP status code. + * @param string $body JSON body (already encoded). + * @return void + */ + private function send_json( int $status_code, string $body ): void { + status_header( $status_code ); + header( 'Content-Type: application/json' ); + header( 'Cache-Control: no-store' ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON produced by wp_json_encode() in build_response(). + echo $body; + exit; + } +} diff --git a/tests/StatusHandlerTest.php b/tests/StatusHandlerTest.php new file mode 100644 index 0000000..1d386ca --- /dev/null +++ b/tests/StatusHandlerTest.php @@ -0,0 +1,202 @@ +settings = new Settings(); + $this->http_client = $this->createMock( HttpClientInterface::class ); + } + + protected function tearDown(): void { + wp_stubs_reset(); + parent::tearDown(); + } + + private function create_handler( ?\Closure $verify_challenge = null ): Status_Handler { + return new Status_Handler( + $this->settings, + 'https://api-connect.sbx.supertab.co', + $this->http_client, + $verify_challenge + ); + } + + private function enable_bot_protection(): void { + $this->settings->save( 'test-key', 'urn:supertab:website:test' ); + $this->settings->set_bot_protection_enabled( true ); + } + + public function test_missing_authorization_returns_decoy(): void { + $handler = $this->create_handler( static fn (): bool => true ); + $response = $handler->build_response( '', 'https://example.com' ); + + $this->assertSame( 404, $response['status'] ); + $this->assertSame( array( 'supertab' => true ), json_decode( $response['body'], true ) ); + } + + public function test_non_bearer_authorization_returns_decoy_without_verifying(): void { + $called = false; + $handler = $this->create_handler( + static function () use ( &$called ): bool { + $called = true; + return true; + } + ); + + $response = $handler->build_response( 'License some-token', 'https://example.com' ); + + $this->assertSame( 404, $response['status'] ); + $this->assertFalse( $called, 'Verifier must not run without a Bearer token.' ); + } + + public function test_invalid_challenge_returns_decoy(): void { + $handler = $this->create_handler( static fn (): bool => false ); + $response = $handler->build_response( 'Bearer bad-token', 'https://example.com' ); + + $this->assertSame( 404, $response['status'] ); + $this->assertSame( array( 'supertab' => true ), json_decode( $response['body'], true ) ); + } + + public function test_throwing_verifier_returns_decoy(): void { + $handler = $this->create_handler( + static function (): bool { + throw new \RuntimeException( 'JWKS fetch failed' ); + } + ); + + $response = $handler->build_response( 'Bearer token', 'https://example.com' ); + + $this->assertSame( 404, $response['status'] ); + } + + public function test_empty_audience_returns_decoy_without_verifying(): void { + $called = false; + $handler = $this->create_handler( + static function () use ( &$called ): bool { + $called = true; + return true; + } + ); + + $response = $handler->build_response( 'Bearer token', '' ); + + $this->assertSame( 404, $response['status'] ); + $this->assertFalse( $called, 'Verifier must not run without an audience.' ); + } + + public function test_verifier_receives_token_and_audience(): void { + $seen = array(); + $handler = $this->create_handler( + static function ( string $token, string $audience ) use ( &$seen ): bool { + $seen = array( $token, $audience ); + return false; + } + ); + + $handler->build_response( 'Bearer the-token', 'https://example.com' ); + + $this->assertSame( array( 'the-token', 'https://example.com' ), $seen ); + } + + public function test_valid_challenge_returns_status_payload(): void { + $this->enable_bot_protection(); + + $handler = $this->create_handler( static fn (): bool => true ); + $response = $handler->build_response( 'Bearer good-token', 'https://example.com' ); + + $this->assertSame( 200, $response['status'] ); + + $payload = json_decode( $response['body'], true ); + + $this->assertNull( $payload['runtime'] ); + $this->assertIsString( $payload['sdkVersion'] ); + $this->assertNotSame( '', $payload['sdkVersion'] ); + $this->assertSame( + array( + 'kind' => 'wordpress-plugin', + 'version' => SUPERTAB_CONNECT_VERSION, + ), + $payload['component'] + ); + $this->assertSame( 'observe', $payload['enforcement'] ); + $this->assertTrue( $payload['eventReporting'] ); + } + + public function test_disabled_bot_protection_reports_disabled(): void { + $this->settings->save( 'test-key', 'urn:supertab:website:test' ); + // Bot protection flag left off. + + $handler = $this->create_handler( static fn (): bool => true ); + $response = $handler->build_response( 'Bearer good-token', 'https://example.com' ); + + $payload = json_decode( $response['body'], true ); + + $this->assertSame( 200, $response['status'] ); + $this->assertSame( 'disabled', $payload['enforcement'] ); + $this->assertFalse( $payload['eventReporting'] ); + } + + public function test_missing_credentials_reports_disabled(): void { + $this->settings->set_bot_protection_enabled( true ); + // No merchant API key saved. + + $handler = $this->create_handler( static fn (): bool => true ); + $response = $handler->build_response( 'Bearer good-token', 'https://example.com' ); + + $payload = json_decode( $response['body'], true ); + + $this->assertSame( 'disabled', $payload['enforcement'] ); + $this->assertFalse( $payload['eventReporting'] ); + } + + public function test_ignores_non_status_requests(): void { + $called = false; + $handler = $this->create_handler( + static function () use ( &$called ): bool { + $called = true; + return true; + } + ); + + $wp = new \WP(); + $wp->request = 'some-other-page'; + + // Returns without responding; an exit here would kill the whole suite. + $handler->maybe_handle_request( $wp ); + + $this->assertFalse( $called, 'Verifier must not run for other paths.' ); + } + + public function test_register_hooks_parse_request_before_bot_protection(): void { + global $wp_test_actions; + + $handler = $this->create_handler(); + $handler->register(); + + $this->assertCount( 1, $wp_test_actions ); + $this->assertSame( 'parse_request', $wp_test_actions[0]['hook'] ); + $this->assertSame( array( $handler, 'maybe_handle_request' ), $wp_test_actions[0]['callback'] ); + $this->assertSame( 5, $wp_test_actions[0]['priority'], 'Must run before Bot_Protection (priority 9).' ); + } +} diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 0d6282d..95f8ba0 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -70,13 +70,14 @@ | */ -global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls; +global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_actions; $wp_test_options = []; $wp_test_transients = []; $wp_test_headers_sent = []; $wp_test_status_code = 200; $wp_test_http_calls = []; +$wp_test_actions = []; global $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls; @@ -92,7 +93,7 @@ * Reset all in-memory stores. Call in setUp()/tearDown(). */ function wp_stubs_reset(): void { - global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls; + global $wp_test_options, $wp_test_transients, $wp_test_headers_sent, $wp_test_status_code, $wp_test_http_calls, $wp_test_scheduled_events, $wp_test_cleared_hooks, $wp_test_unscheduled_hooks, $wp_test_schedule_result, $wp_test_doing_cron, $wp_test_as_enqueue_calls, $wp_test_as_unschedule_calls, $wp_test_actions; $wp_test_options = []; $wp_test_transients = []; $wp_test_headers_sent = []; @@ -105,6 +106,7 @@ function wp_stubs_reset(): void { $wp_test_doing_cron = false; $wp_test_as_enqueue_calls = []; $wp_test_as_unschedule_calls = []; + $wp_test_actions = []; } /* @@ -243,6 +245,12 @@ function esc_html( string $text ): string { if ( ! function_exists( 'add_action' ) ) { function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool { + global $wp_test_actions; + $wp_test_actions[] = [ + 'hook' => $hook, + 'callback' => $callback, + 'priority' => $priority, + ]; return true; } } @@ -253,6 +261,55 @@ function add_filter( string $hook, $callback, int $priority = 10, int $accepted_ } } +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + return $value; + } +} + +/* +|-------------------------------------------------------------------------- +| Misc WordPress Functions +|-------------------------------------------------------------------------- +*/ + +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, int $options = 0, int $depth = 512 ) { + return json_encode( $data, $options, $depth ); + } +} + +if ( ! function_exists( 'sanitize_text_field' ) ) { + function sanitize_text_field( string $str ): string { + return trim( preg_replace( '/[\r\n\t ]+/', ' ', $str ) ); + } +} + +if ( ! function_exists( 'wp_unslash' ) ) { + function wp_unslash( $value ) { + return is_string( $value ) ? stripslashes( $value ) : $value; + } +} + +if ( ! function_exists( 'is_ssl' ) ) { + function is_ssl(): bool { + return true; + } +} + +/* +|-------------------------------------------------------------------------- +| WP Environment Class +|-------------------------------------------------------------------------- +*/ + +if ( ! class_exists( 'WP' ) ) { + class WP { + /** @var string */ + public $request = ''; + } +} + /* |-------------------------------------------------------------------------- | Scheduling (WP-Cron + Action Scheduler) Stubs From 49029ffecce47752658a88e6dbd11789ca7edf90 Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 16:38:24 +0200 Subject: [PATCH 3/6] feat: register status endpoint handler unconditionally --- src/class-plugin.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/class-plugin.php b/src/class-plugin.php index c126b99..320a08c 100644 --- a/src/class-plugin.php +++ b/src/class-plugin.php @@ -79,6 +79,9 @@ public function init(): void { $license_handler = new RSL_License_Handler( $settings, SUPERTAB_CONNECT_API_BASE_URL, $http_client ); $license_handler->register(); + $status_handler = new Status_Handler( $settings, SUPERTAB_CONNECT_API_BASE_URL, $http_client ); + $status_handler->register(); + $analytics_enabled = $settings->has_merchant_api_key() && $settings->is_bot_protection_enabled(); $dispatcher = null; From 13a788c2d41af1b254146b23ef34af05ad4f644c Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 16:42:13 +0200 Subject: [PATCH 4/6] test: prove well-known status path reaches parse_request --- tests/integration/StatusRoutingTest.php | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/integration/StatusRoutingTest.php diff --git a/tests/integration/StatusRoutingTest.php b/tests/integration/StatusRoutingTest.php new file mode 100644 index 0000000..887da8d --- /dev/null +++ b/tests/integration/StatusRoutingTest.php @@ -0,0 +1,38 @@ +request from the URL path (see LicenseRoutingTest). + $this->set_permalink_structure( '/%postname%/' ); + } + + public function test_well_known_status_path_resolves_to_parse_request(): void { + // The status handler exit()s after responding; drop parse_request + // callbacks so go_to() completes and the parsed path is inspectable. + remove_all_actions( 'parse_request' ); + + $this->go_to( home_url( '/.well-known/supertab/status' ) ); + + $this->assertSame( '.well-known/supertab/status', $GLOBALS['wp']->request ); + } +} From d2ef29d01d8b76c79129c86405125b41414e4d7a Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Mon, 13 Jul 2026 16:58:59 +0200 Subject: [PATCH 5/6] fix: serve SDK RespondResult instead of leaking its headers onto the next page --- src/class-bot-protection.php | 60 +++++++++++++++++++++++++++++++++--- tests/BotProtectionTest.php | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/class-bot-protection.php b/src/class-bot-protection.php index 2355813..ae811a6 100644 --- a/src/class-bot-protection.php +++ b/src/class-bot-protection.php @@ -17,6 +17,7 @@ } use Supertab\Connect\Result\BlockResult; +use Supertab\Connect\Result\RespondResult; use Supertab\Connect\SupertabConnect; /** @@ -101,6 +102,11 @@ public function maybe_handle_request( \WP $wp ): void { return; } + if ( $result instanceof RespondResult ) { + $this->send_respond_response( $result ); + return; + } + $this->signal_headers = $result->headers; } @@ -147,15 +153,59 @@ private function is_path_active( string $request_path ): bool { */ private function send_block_response( BlockResult $result ): void { status_header( $result->status ); + $this->send_headers( $result->headers ); + + echo esc_html( $result->body ); + exit; + } + + /** + * Send the SDK's own response (e.g. the status endpoint) and terminate. + * + * @param RespondResult $result The respond result from the SDK. + * @return void + */ + private function send_respond_response( RespondResult $result ): void { + status_header( $result->status ); + $this->send_headers( $result->headers ); + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON produced by the SDK itself for its status endpoint, must be served verbatim. + echo $result->body; + exit; + } - foreach ( $result->headers as $name => $value ) { - if ( ! preg_match( '/^[a-zA-Z0-9-]+$/', $name ) ) { + /** + * Sanitize and emit response headers. + * + * Rejects header names containing anything other than letters, digits, + * and hyphens, and strips CR/LF from values to prevent header injection. + * + * @param array $headers Raw headers from an SDK result. + * @return void + */ + private function send_headers( array $headers ): void { + foreach ( $this->filter_safe_headers( $headers ) as $name => $value ) { + header( "{$name}: {$value}" ); + } + } + + /** + * Filter out unsafe header names and strip CR/LF from values. + * + * @param array $headers Raw headers from an SDK result. + * @return array Sanitized headers safe to pass to header(). + */ + private function filter_safe_headers( array $headers ): array { + $safe_headers = array(); + + foreach ( $headers as $name => $value ) { + if ( ! preg_match( '/^[a-zA-Z0-9-]+$/', (string) $name ) ) { continue; } - header( str_replace( array( "\r", "\n" ), '', "{$name}: {$value}" ) ); + + $safe_headers[ $name ] = str_replace( array( "\r", "\n" ), '', (string) $value ); } - echo esc_html( $result->body ); - exit; + return $safe_headers; } } diff --git a/tests/BotProtectionTest.php b/tests/BotProtectionTest.php index 15c7472..7098274 100644 --- a/tests/BotProtectionTest.php +++ b/tests/BotProtectionTest.php @@ -27,6 +27,13 @@ class BotProtectionTest extends TestCase { */ private \ReflectionMethod $is_path_active; + /** + * Reflection method for filter_safe_headers. + * + * @var \ReflectionMethod + */ + private \ReflectionMethod $filter_safe_headers; + /** * Bot_Protection instance (constructed without a real SDK). * @@ -52,6 +59,10 @@ protected function setUp(): void { // Make is_path_active accessible. $this->is_path_active = $ref->getMethod( 'is_path_active' ); $this->is_path_active->setAccessible( true ); + + // Make filter_safe_headers accessible. + $this->filter_safe_headers = $ref->getMethod( 'filter_safe_headers' ); + $this->filter_safe_headers->setAccessible( true ); } protected function tearDown(): void { @@ -144,4 +155,48 @@ public function test_wildcard_pattern_with_trailing_slash_matches(): void { $this->settings->set_active_paths( array( 'blog/*/' ) ); $this->assertTrue( $this->is_active( 'blog/my-post' ) ); } + + /** + * Invoke filter_safe_headers with the given raw headers. + * + * @param array $headers Raw headers to sanitize. + * @return array + */ + private function filter_headers( array $headers ): array { + return $this->filter_safe_headers->invoke( $this->bot, $headers ); + } + + public function test_filter_safe_headers_keeps_well_formed_headers(): void { + $headers = array( + 'X-Supertab-Signal' => 'bot', + 'Content-Type' => 'application/json', + ); + + $this->assertSame( $headers, $this->filter_headers( $headers ) ); + } + + public function test_filter_safe_headers_drops_invalid_header_names(): void { + $headers = array( + 'X-Valid-Header' => 'ok', + 'Invalid Header' => 'dropped-space', + 'Invalid:Header' => 'dropped-colon', + "Invalid\r\nHeader" => 'dropped-crlf-in-name', + ); + + $this->assertSame( + array( 'X-Valid-Header' => 'ok' ), + $this->filter_headers( $headers ) + ); + } + + public function test_filter_safe_headers_strips_crlf_from_values(): void { + $headers = array( + 'X-Supertab-Signal' => "bot\r\nX-Injected: evil", + ); + + $this->assertSame( + array( 'X-Supertab-Signal' => 'botX-Injected: evil' ), + $this->filter_headers( $headers ) + ); + } } From 6d90d1eca57a181eb16604d5f18eb666ab6efa8e Mon Sep 17 00:00:00 2001 From: Tom Stark Date: Tue, 14 Jul 2026 11:18:17 +0200 Subject: [PATCH 6/6] chore: bump connect-sdk-php to v1.4.0-beta.9 for php-sdk component identity --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 0064237..3c5c6a8 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "license": "GPL-2.0-or-later", "require": { "php": ">=8.1", - "getsupertab/connect-sdk-php": "1.4.0-beta.8" + "getsupertab/connect-sdk-php": "1.4.0-beta.9" }, "require-dev": { "automattic/vipwpcs": "^3.0", diff --git a/composer.lock b/composer.lock index 5efffdc..907fc56 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e5b4f7f264d7e71e5dfc75b8585f7cc7", + "content-hash": "0797e65a85e4ad5676db4fafb2d27bd8", "packages": [ { "name": "firebase/php-jwt", @@ -74,16 +74,16 @@ }, { "name": "getsupertab/connect-sdk-php", - "version": "v1.4.0-beta.8", + "version": "v1.4.0-beta.9", "source": { "type": "git", "url": "https://github.com/getsupertab/connect-sdk-php.git", - "reference": "207a04f1d3de8c383ed62d4cecc24934d83d4dcb" + "reference": "ad5459a7f73d8a50f284b4dee6182b33b96f08eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsupertab/connect-sdk-php/zipball/207a04f1d3de8c383ed62d4cecc24934d83d4dcb", - "reference": "207a04f1d3de8c383ed62d4cecc24934d83d4dcb", + "url": "https://api.github.com/repos/getsupertab/connect-sdk-php/zipball/ad5459a7f73d8a50f284b4dee6182b33b96f08eb", + "reference": "ad5459a7f73d8a50f284b4dee6182b33b96f08eb", "shasum": "" }, "require": { @@ -125,9 +125,9 @@ ], "support": { "issues": "https://github.com/getsupertab/connect-sdk-php/issues", - "source": "https://github.com/getsupertab/connect-sdk-php/tree/v1.4.0-beta.8" + "source": "https://github.com/getsupertab/connect-sdk-php/tree/v1.4.0-beta.9" }, - "time": "2026-07-13T12:06:08+00:00" + "time": "2026-07-14T08:41:59+00:00" } ], "packages-dev": [