Skip to content

Commit acfe215

Browse files
authored
fix: OAuth bearer resolver fatal before init (#127) (#128)
* test(oauth): pin bearer resolve fatal before wp_rewrite exists Any plugin calling is_user_logged_in() during plugins_loaded fires determine_current_user before wp-settings.php has created $wp_rewrite. On a genuine Bearer MCP request the resolver reaches its RFC 8707 audience check, resource_id() calls rest_url(), and get_rest_url() fatals on the missing global — killing the request and the connector. Three tests, committed red with the exact customer stack (using_index_permalinks() on null via class-saddle-oauth.php:183): resolution must work early, resource_id() must be byte-identical before and after init on all three permalink shapes, and being early must not skip the audience check. Refs #127 * fix(oauth): resolve bearer tokens before wp_rewrite exists resource_id() now routes through rest_url_early(): with the rewrite global present it defers to rest_url() untouched; without it, it replicates core get_rest_url(null, $path, 'rest') exactly from pre-init-available state. The replica must be byte-identical because the value is compared against the RFC 8707 resource stored on every access token — divergence would refuse every token as invalid_token. The only substitution is using_index_permalinks(), a pure function of the permalink_structure option and the hard-coded index.php default. Store::find() had already hardened the DB half of this window with raw wpdb; this closes the URL half. issuer()/endpoint() stay on plain rest_url() — audit shows no pre-init caller. Refs #127
1 parent ae53a7f commit acfe215

3 files changed

Lines changed: 169 additions & 1 deletion

File tree

includes/oauth/class-saddle-oauth.php

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,77 @@ public static function set_enabled( $enabled ) {
180180
* @return string
181181
*/
182182
public static function resource_id() {
183-
return untrailingslashit( rest_url( ltrim( Saddle_MCP::REST_NAMESPACE . Saddle_MCP::ROUTE, '/' ) ) );
183+
return untrailingslashit( self::rest_url_early( ltrim( Saddle_MCP::REST_NAMESPACE . Saddle_MCP::ROUTE, '/' ) ) );
184+
}
185+
186+
/**
187+
* `rest_url()` that also works before `init`.
188+
*
189+
* The bearer resolver runs on `determine_current_user`, which another
190+
* plugin can fire during `plugins_loaded` by calling `is_user_logged_in()`
191+
* — before wp-settings.php has created `$GLOBALS['wp_rewrite']`, which
192+
* core's `get_rest_url()` dereferences unconditionally whenever a
193+
* permalink structure is set. This is the same pre-init window
194+
* {@see Saddle_OAuth_Store::find()} documents for the DB path; this
195+
* hardens the URL path. Any new pre-init caller of `rest_url()` must come
196+
* through here.
197+
*
198+
* When the global exists this defers to core untouched. When it does not,
199+
* it replicates core's `get_rest_url( null, $path, 'rest' )` exactly —
200+
* including the `rest_url` filter with core's argument shape — because
201+
* the result is compared against the RFC 8707 `resource` stored on every
202+
* access token, and any divergence would refuse every token on the site
203+
* as `invalid_token`. The only substitution is
204+
* `WP_Rewrite::using_index_permalinks()`, which is a pure function of the
205+
* `permalink_structure` option and the hard-coded `index.php` default;
206+
* anything customizing `WP_Rewrite::$index` does so on the real instance,
207+
* which only exists once the branch below defers to core anyway.
208+
*
209+
* @param string $path REST route below the REST prefix.
210+
* @return string Full URL to the endpoint.
211+
*/
212+
private static function rest_url_early( $path ) {
213+
if ( ( $GLOBALS['wp_rewrite'] ?? null ) instanceof WP_Rewrite ) {
214+
return rest_url( $path );
215+
}
216+
217+
$path = '/' . ltrim( (string) $path, '/' );
218+
219+
// Core's multisite leg, get_blog_option( null, … ), reads the current
220+
// site's option — identical to get_option() here. Truthiness, not
221+
// '' !==, to mirror core.
222+
$structure = get_option( 'permalink_structure' );
223+
224+
if ( $structure ) {
225+
$prefix = rest_get_url_prefix();
226+
if ( preg_match( '#^/*index\.php#', (string) $structure ) ) {
227+
$prefix = 'index.php/' . $prefix;
228+
}
229+
$url = get_home_url( null, $prefix, 'rest' ) . $path;
230+
} else {
231+
$url = trailingslashit( get_home_url( null, '', 'rest' ) );
232+
// Core appends index.php to dodge an nginx redirect that only
233+
// allows HTTP/1.0 methods; substr instead of str_ends_with for
234+
// the PHP 7.4 floor.
235+
if ( 'index.php' !== substr( $url, -9 ) ) {
236+
$url .= 'index.php';
237+
}
238+
$url = add_query_arg( 'rest_route', $path, $url );
239+
}
240+
241+
if ( is_ssl() && isset( $_SERVER['SERVER_NAME'] ) ) {
242+
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Mirrors core's get_rest_url() host comparison byte for byte; the strict equality against the parsed home host is the validation.
243+
if ( wp_parse_url( get_home_url( null ), PHP_URL_HOST ) === $_SERVER['SERVER_NAME'] ) {
244+
$url = set_url_scheme( $url, 'https' );
245+
}
246+
}
247+
248+
if ( is_admin() && force_ssl_admin() ) {
249+
$url = set_url_scheme( $url, 'https' );
250+
}
251+
252+
/** This filter is documented in wp-includes/rest-api.php */
253+
return apply_filters( 'rest_url', $url, $path, null, 'rest' );
184254
}
185255

186256
/**

readme.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ If you happen to have the separate MCP Adapter plugin active, Saddle notices and
143143

144144
= 1.0.0 =
145145
* Initial public release.
146+
* Fixed: on sites where another plugin checks who is signed in very early in the request — several SEO plugins do — every ChatGPT request crashed before Saddle could examine its token, so the connection failed with a sign-in error forever while the same build worked elsewhere. The token check now works no matter how early in the request it runs.
146147
* Fixed: the connection check could report that sign-ins were working on a server that was actually blocking half of them. Apps you connect with a pasted key send one kind of sign-in header and apps that sign in through Saddle — ChatGPT is the one that can only connect that way — send another, and some servers pass the first and drop the second. The check now tests both, says which one is being blocked, and offers the same one-click fix, which always covered both.
147148
* Connection details and health now shows how each request signed in, so "the key was rejected" and "no key ever arrived" stop looking identical. They are the same error message and they need opposite fixes — one is reconnecting the app, the other is a word with your host.
148149
* Fixed: the request recorder was logging its own screen refreshing, which pushed the requests you were trying to capture out of the list within about two minutes. It now records only real app traffic, and keeps four times as much of it.

tests/oauth-bearer-test.php

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,103 @@ public function test_cleartext_transport_refuses_the_token() {
169169
remove_filter( 'saddle_oauth_readiness_ssl', '__return_false' );
170170
}
171171

172+
/* ------------------------------------------------------------------
173+
* Pre-init resolution
174+
*
175+
* determine_current_user is not a REST-time hook: any plugin calling
176+
* is_user_logged_in() during plugins_loaded fires it before
177+
* wp-settings.php has created $GLOBALS['wp_rewrite'] — the same window
178+
* Saddle_OAuth_Store::find() documents for the DB path. The resolver
179+
* must authenticate there too, not just avoid the fatal: core memoizes
180+
* whatever user it resolves for the rest of the request, so a refusal
181+
* here is a dead connector exactly like a crash.
182+
* --------------------------------------------------------------- */
183+
184+
/**
185+
* Run $fn in the state determine_current_user fires in when another
186+
* plugin checks the user during plugins_loaded — before wp-settings.php
187+
* has created $wp_rewrite.
188+
*
189+
* try/finally, per call: WP_UnitTestCase never restores this global, so
190+
* a failing assertion must not leak a nulled instance into later tests.
191+
*
192+
* @param callable $fn What to run without the rewrite global.
193+
* @return mixed Whatever $fn returns.
194+
*/
195+
private function before_wp_rewrite_exists( $fn ) {
196+
$saved = $GLOBALS['wp_rewrite'];
197+
$GLOBALS['wp_rewrite'] = null;
198+
try {
199+
return $fn();
200+
} finally {
201+
$GLOBALS['wp_rewrite'] = $saved;
202+
}
203+
}
204+
205+
public function test_resolve_works_before_wp_rewrite_exists() {
206+
$this->issue_token();
207+
208+
$user = $this->before_wp_rewrite_exists(
209+
function () {
210+
return Saddle_OAuth_Bearer::resolve( false );
211+
}
212+
);
213+
214+
$this->assertSame(
215+
$this->admin,
216+
$user,
217+
'is_user_logged_in() during plugins_loaded fires determine_current_user before $wp_rewrite exists; a valid token must resolve there, not fatal or 401.'
218+
);
219+
}
220+
221+
public function test_resource_id_is_identical_before_and_after_init() {
222+
$structures = array(
223+
'pretty' => '/%postname%/',
224+
'index' => '/index.php/%postname%/',
225+
'plain' => '',
226+
);
227+
228+
foreach ( $structures as $label => $structure ) {
229+
// set_permalink_structure(), not update_option(): rest_url()
230+
// reads the live instance, and a stale one would make this
231+
// compare the replica against the wrong baseline.
232+
$this->set_permalink_structure( $structure );
233+
234+
$post_init = Saddle_OAuth::resource_id();
235+
$pre_init = $this->before_wp_rewrite_exists(
236+
function () {
237+
return Saddle_OAuth::resource_id();
238+
}
239+
);
240+
241+
$this->assertSame(
242+
$post_init,
243+
$pre_init,
244+
sprintf( 'resource_id() diverged on %s permalinks — every stored token audience would stop matching.', $label )
245+
);
246+
}
247+
248+
// Leave the live instance matching what set_up put in the option,
249+
// so state cannot leak into whichever test runs next.
250+
$this->set_permalink_structure( '/%postname%/' );
251+
}
252+
253+
public function test_a_foreign_token_is_still_refused_before_wp_rewrite_exists() {
254+
$this->issue_token();
255+
256+
$record = Saddle_OAuth_Store::get_access_token( $this->token );
257+
update_post_meta( $record['id'], '_saddle_resource', 'https://elsewhere.example/wp-json/saddle/v1/mcp' );
258+
259+
$this->assertFalse(
260+
$this->before_wp_rewrite_exists(
261+
function () {
262+
return Saddle_OAuth_Bearer::resolve( false );
263+
}
264+
),
265+
'Being early is not a reason to skip the RFC 8707 audience check.'
266+
);
267+
}
268+
172269
/* ------------------------------------------------------------------
173270
* Confinement
174271
* --------------------------------------------------------------- */

0 commit comments

Comments
 (0)