Skip to content

Commit e7a249d

Browse files
garysmurrayclaude
andcommitted
fix: address Codex review — app-password collision + PHP_AUTH route scoping
Two real bugs surfaced by Codex's review of the Basic-auth migration: P1.1: app-password collision (production-breaking, conditional) On any site where `WP_Application_Passwords::is_in_use()` returns true (i.e. any merchant has ever provisioned an app password — for any user, any plugin), `wp_validate_application_password` runs at `determine_current_user` priority 20 against our `ck_xxx` username. `get_user_by('login', 'ck_xxx')` returns false → WP fires `application_password_failed_authentication` with an `invalid_username` WP_Error → `rest_application_password_collect_status` stores it on `$wp_rest_application_password_status` → `rest_application_password_check_errors` returns 401 from `rest_authentication_errors` before our route permission callback ever runs. The original smoke test passed because the wp-env had zero app passwords, so `is_in_use()` returned false and the chain short- circuited. A ticking time bomb. Fix: filter `application_password_is_api_request` to return false for `/wp-json/hey-woo/mcp`. Inside `wp_authenticate_application_password`, the `! $is_api_request` early-return then prevents the error global from ever being set. Surgical — every other REST route keeps WP's normal app-password handling. Verified against a live wp-env with an app password provisioned: Basic Auth + read-only WC key + MCP initialize returns 200, not 401. P1.2: route-scope bypass on mod_php (real, exploitable) `enforce_setup_key_route_scope` calls `extract_request_credential`, which only read `HTTP_AUTHORIZATION`. On Apache + mod_php (and many fastcgi setups) Apache strips the raw header and populates `PHP_AUTH_USER` / `PHP_AUTH_PW` directly. WC's auth reads the split form, so it would authenticate the setup credential against any WC REST surface (e.g. /wc/v3/orders), but our scope filter would see '' and skip the restriction — defeating the credential's privacy boundary on those SAPIs. Fix: add a `PHP_AUTH_USER` / `PHP_AUTH_PW` branch at the top of `extract_request_credential`, before the raw-header parse. Tests: - 2 new tests on `test-rest-api-key.php` pin the deny + allow paths for the route-scope filter via the PHP_AUTH split form. These would have caught the P1.2 bug; the existing tests only exercised HTTP_AUTHORIZATION. - 4 new tests on `test-wc-auth-route-scoping.php`: pin that `enable_wc_auth_for_our_routes` excludes the MCP route (which the recent narrowing introduced); pin that `exclude_mcp_route_from_app_password_auth` denies the MCP route, passes through every other REST route, and is a no-op when the incoming value is already false. 288 PHPUnit tests pass (up from 282). PHPCS clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent da6b7da commit e7a249d

4 files changed

Lines changed: 212 additions & 3 deletions

File tree

includes/class-plugin.php

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,23 @@ private function init_hooks() {
166166

167167
// Register our own MCP server when the adapter initializes. Owns the
168168
// `/wp-json/hey-woo/mcp` endpoint with a curated tool/resource/prompt
169-
// surface and a custom auth callback that handles X-MCP-API-Key (the
170-
// header `mcp-wordpress-remote` sends).
169+
// surface and a custom auth callback that authenticates ck:cs Basic
170+
// Auth against `wp_woocommerce_api_keys`.
171171
add_action( 'mcp_adapter_init', array( $this, 'register_mcp_server' ) );
172+
173+
// Tell WP not to run application-password auth on our MCP route.
174+
// Without this, on any site where any app password exists
175+
// (`WP_Application_Passwords::is_in_use()` returns true), WP's
176+
// app-password handler runs at `determine_current_user` priority
177+
// 20, fails with `invalid_username` for our `ck_xxx` user, stores
178+
// the error on `$wp_rest_application_password_status`, and
179+
// `rest_application_password_check_errors` returns 401 before our
180+
// route permission callback ever runs. Excluding the MCP route
181+
// from `application_password_is_api_request` makes the early
182+
// return inside `wp_authenticate_application_password` fire — no
183+
// error global, no 401 — so our own callback can authenticate
184+
// the WC API key cleanly.
185+
add_filter( 'application_password_is_api_request', array( $this, 'exclude_mcp_route_from_app_password_auth' ) );
172186
}
173187

174188
/**
@@ -266,6 +280,40 @@ public function enable_wc_auth_for_our_routes( $is_request ) {
266280
return $is_request;
267281
}
268282

283+
/**
284+
* Tell WP not to treat the Hey Woo MCP route as an "API request" for
285+
* the purposes of application-password auth. See the rationale on
286+
* the `application_password_is_api_request` filter registration in
287+
* `init_hooks()` for the full chain — short version: the WC consumer
288+
* key (`ck_xxx`) is not a WP user login, and the app-password
289+
* handler turns that into a 401 on `rest_authentication_errors`
290+
* before our route permission callback runs.
291+
*
292+
* Filters very narrowly — only `/wp-json/hey-woo/mcp` and any
293+
* sub-routes the transport may add. Every other REST route still
294+
* gets WP's normal app-password handling.
295+
*
296+
* @param bool $is_api_request What WP would otherwise consider this request.
297+
* @return bool
298+
*/
299+
public function exclude_mcp_route_from_app_password_auth( $is_api_request ) {
300+
if ( ! $is_api_request ) {
301+
return $is_api_request;
302+
}
303+
304+
$route = $this->current_rest_route();
305+
if ( '' === $route ) {
306+
return $is_api_request;
307+
}
308+
309+
$mcp_prefix = self::MCP_SERVER_NS . '/' . self::MCP_SERVER_ROUTE;
310+
if ( 0 === strpos( $route, $mcp_prefix ) ) {
311+
return false;
312+
}
313+
314+
return $is_api_request;
315+
}
316+
269317
/**
270318
* Resolve the REST route the current request targets, regardless of
271319
* whether the install uses pretty (/wp-json/<route>) or plain

includes/setup/class-setup-page.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,17 @@ private static function extract_request_credential() {
541541
return wp_unslash( (string) $_SERVER['HTTP_X_MCP_API_KEY'] );
542542
}
543543

544-
// 2. HTTP Basic auth — username:password = ck_xxx:cs_xxx.
544+
// 2a. PHP_AUTH_USER + PHP_AUTH_PW — the split form Apache/mod_php
545+
// (and many fastcgi setups) expose Basic auth as. WC's auth reads
546+
// these directly, so the route-scope filter must too — otherwise
547+
// a request bearing our setup credential against a non-MCP route
548+
// (e.g. /wc/v3/orders) would slip past the scope check on any
549+
// SAPI that doesn't surface HTTP_AUTHORIZATION.
550+
if ( ! empty( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
551+
return wp_unslash( (string) $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( (string) $_SERVER['PHP_AUTH_PW'] );
552+
}
553+
554+
// 2b. HTTP Basic auth (raw header form) — username:password = ck_xxx:cs_xxx.
545555
$auth = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? wp_unslash( (string) $_SERVER['HTTP_AUTHORIZATION'] ) : '';
546556
if ( 0 === stripos( $auth, 'Basic ' ) ) {
547557
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding HTTP Basic auth header per RFC 7617; strict mode rejects invalid input.

tests/integration/test-rest-api-key.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,69 @@ public function test_setup_key_is_allowed_on_mcp_route() {
554554
}
555555
}
556556

557+
/**
558+
* Apache + mod_php (and many fastcgi setups) expose Basic auth via
559+
* `PHP_AUTH_USER` / `PHP_AUTH_PW` and strip `HTTP_AUTHORIZATION`.
560+
* WC's auth reads the split form directly, so the route-scope
561+
* filter must too — otherwise a request bearing the setup
562+
* credential as Basic auth against a non-MCP route (e.g.
563+
* /wc/v3/orders) would be authenticated by WC and slip past the
564+
* scope check on those SAPIs, defeating the credential's privacy
565+
* boundary.
566+
*
567+
* Pin the deny-by-PHP_AUTH path explicitly so the regression
568+
* surfaces if the credential extractor is ever narrowed back to
569+
* HTTP_AUTHORIZATION-only.
570+
*/
571+
public function test_setup_key_is_rejected_on_non_mcp_routes_with_php_auth_basic() {
572+
$helper = new RestApiKey();
573+
$state = $helper->get_or_create();
574+
list( $username, $password ) = RestApiKey::split_credential( $state['credential'] );
575+
576+
$original_server = $_SERVER;
577+
try {
578+
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- test fixture mutates $_SERVER directly.
579+
unset( $_SERVER['HTTP_X_MCP_API_KEY'], $_SERVER['HTTP_AUTHORIZATION'] );
580+
$_SERVER['PHP_AUTH_USER'] = $username;
581+
$_SERVER['PHP_AUTH_PW'] = $password;
582+
$_SERVER['REQUEST_URI'] = '/wp-json/wc/v3/orders';
583+
$result = SetupPage::enforce_setup_key_route_scope( null );
584+
585+
$this->assertInstanceOf( \WP_Error::class, $result );
586+
$this->assertSame( 'hey_woo_route_restricted', $result->get_error_code() );
587+
$this->assertSame( 403, $result->get_error_data()['status'] ?? 0 );
588+
} finally {
589+
$_SERVER = $original_server;
590+
// phpcs:enable WordPress.Security.NonceVerification.Recommended
591+
}
592+
}
593+
594+
/**
595+
* Companion to the deny test above — the credential is allowed on
596+
* the MCP route when delivered as PHP_AUTH_USER/PHP_AUTH_PW too.
597+
* Pins the same SAPI shape on the allow side.
598+
*/
599+
public function test_setup_key_is_allowed_on_mcp_route_with_php_auth_basic() {
600+
$helper = new RestApiKey();
601+
$state = $helper->get_or_create();
602+
list( $username, $password ) = RestApiKey::split_credential( $state['credential'] );
603+
604+
$original_server = $_SERVER;
605+
try {
606+
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- test fixture mutates $_SERVER directly.
607+
unset( $_SERVER['HTTP_X_MCP_API_KEY'], $_SERVER['HTTP_AUTHORIZATION'] );
608+
$_SERVER['PHP_AUTH_USER'] = $username;
609+
$_SERVER['PHP_AUTH_PW'] = $password;
610+
$_SERVER['REQUEST_URI'] = '/wp-json/hey-woo/mcp';
611+
$result = SetupPage::enforce_setup_key_route_scope( null );
612+
613+
$this->assertNull( $result, 'No restriction error on the allowed MCP route.' );
614+
} finally {
615+
$_SERVER = $original_server;
616+
// phpcs:enable WordPress.Security.NonceVerification.Recommended
617+
}
618+
}
619+
557620
/**
558621
* The route-scope filter is a no-op when the request isn't using
559622
* our credential at all. Anything else would inadvertently

tests/integration/test-wc-auth-route-scoping.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,4 +229,92 @@ public function test_query_string_with_owned_substring_does_not_grant_scope() {
229229
'A query string mentioning a hey-woo/ path must not opt the underlying /wp/v2/posts request into WC auth.'
230230
);
231231
}
232+
233+
/**
234+
* The MCP route is intentionally *outside* WC's auth scope. WC's
235+
* `check_user_permissions` enforces a per-method read/write split
236+
* that would 401 every MCP POST against a read-only key, so the
237+
* MCP transport handles auth itself via its own permission
238+
* callback. This pins the narrowing — if the prefix match ever
239+
* widens back to `hey-woo/`, MCP requests with read-only keys
240+
* would start 401-ing.
241+
*/
242+
public function test_mcp_route_is_outside_wc_auth_scope() {
243+
$this->set_pretty_permalink_request( '/hey-woo/mcp' );
244+
$this->assertFalse(
245+
$this->check(),
246+
'Pretty permalinks: hey-woo/mcp must NOT opt into WC auth (the MCP transport authenticates itself).'
247+
);
248+
249+
$this->set_plain_permalink_request( '/hey-woo/mcp' );
250+
$this->assertFalse(
251+
$this->check(),
252+
'Plain permalinks: hey-woo/mcp must NOT opt into WC auth.'
253+
);
254+
}
255+
256+
/**
257+
* `Plugin::exclude_mcp_route_from_app_password_auth` is the second
258+
* half of the auth picture. Without it, on any site where a
259+
* `WP_Application_Passwords` row exists,
260+
* `wp_authenticate_application_password()` runs at
261+
* `determine_current_user` priority 20 against our `ck_xxx`
262+
* username, fails with `invalid_username`, stores the error on the
263+
* `$wp_rest_application_password_status` global, and
264+
* `rest_application_password_check_errors` returns 401 before our
265+
* route permission callback ever runs. Returning false here makes
266+
* `wp_authenticate_application_password()` early-return without
267+
* touching the global, so the MCP transport's own callback runs
268+
* cleanly.
269+
*
270+
* Pin the deny path so a regression doesn't silently re-open the
271+
* 401 cliff for any merchant who has app passwords in use.
272+
*/
273+
public function test_mcp_route_excluded_from_app_password_auth() {
274+
$plugin = \HeyWoo\Plugin::instance();
275+
276+
$this->set_pretty_permalink_request( '/hey-woo/mcp' );
277+
$this->assertFalse(
278+
$plugin->exclude_mcp_route_from_app_password_auth( true ),
279+
'Pretty permalinks: hey-woo/mcp must opt out of app-password auth.'
280+
);
281+
282+
$this->set_plain_permalink_request( '/hey-woo/mcp' );
283+
$this->assertFalse(
284+
$plugin->exclude_mcp_route_from_app_password_auth( true ),
285+
'Plain permalinks: hey-woo/mcp must opt out of app-password auth.'
286+
);
287+
}
288+
289+
/**
290+
* The app-password exclusion must be surgical — every other REST
291+
* route (including our own `hey-woo/v1/...` REST controllers) keeps
292+
* WP's normal app-password handling. Anything else would silently
293+
* disable a documented WP feature on unrelated routes.
294+
*/
295+
public function test_app_password_auth_passes_through_for_non_mcp_routes() {
296+
$plugin = \HeyWoo\Plugin::instance();
297+
298+
foreach ( array( '/hey-woo/v1/store/profile', '/wp/v2/posts', '/wc/v3/orders', '/wp-abilities/v1/abilities/wc-analytics/get-revenue-summary/run' ) as $route ) {
299+
$this->set_pretty_permalink_request( $route );
300+
$this->assertTrue(
301+
$plugin->exclude_mcp_route_from_app_password_auth( true ),
302+
"Route {$route} must keep WP's app-password handling."
303+
);
304+
}
305+
}
306+
307+
/**
308+
* Pass-through when WP would not have considered the request an
309+
* API request anyway. We only ever flip true → false; we never
310+
* coerce false → true.
311+
*/
312+
public function test_app_password_filter_passes_through_when_already_false() {
313+
$plugin = \HeyWoo\Plugin::instance();
314+
$this->set_pretty_permalink_request( '/hey-woo/mcp' );
315+
$this->assertFalse(
316+
$plugin->exclude_mcp_route_from_app_password_auth( false ),
317+
'When the incoming value is already false, the filter is a no-op pass-through.'
318+
);
319+
}
232320
}

0 commit comments

Comments
 (0)