Skip to content

Commit 850680f

Browse files
committed
fix(connection): probe Bearer too, so a half-stripped host stops reading healthy
self_check() could report auth_header: ok on a site where every OAuth client was being refused, because probe_headers() sent exactly one loopback and it carried Basic. Basic is the scheme that survives where others do not. Apache and LiteSpeed consume an RFC 7617 header natively into PHP_AUTH_USER, so it reaches PHP even on setups that never forward the raw Authorization header, and a firewall rule can be written against Bearer alone. On such a host every pasted-key client works and every app that signs in through Saddle gets 401 — which reads from outside as "the AI app is broken" rather than "my server drops a header", and which Saddle's own health check was confirming. That matters because the two paths have disjoint client populations. Application Passwords carry Basic: Claude Code, Claude Desktop, Cursor, VS Code. OAuth carries Bearer, and ChatGPT has no field for a custom header, so Bearer is the only way it can ever connect. "Claude works, so the header is fine" is the wrong inference and this is the code that was licensing it. So: a second loopback carrying Bearer, judged on the scheme that arrived rather than on arrival alone (a host that rewrites an unrecognised Authorization header would otherwise pass a test it should fail), a bearer_header field, a bearer_header_stripped status ranked below the both-stripped case, and htaccess_fixable extended — the rule Saddle already generates forwards the header whatever scheme it carries, so it was always the fix for both. The public probe route gains `scheme`, its first non-boolean. The contract test pinning its key set caught that, as designed; it is updated deliberately, and the value is a closed enum naming the shape of what the caller itself just sent, never any part of it. Closes #121
1 parent ddd82c0 commit 850680f

6 files changed

Lines changed: 280 additions & 48 deletions

File tree

admin/build/index.asset.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '8404554d959f53432915');
1+
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '19a62f538f16b0465fe9');

admin/build/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

admin/src/components/ConnectionHealth.jsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,18 +122,39 @@ export default function ConnectionHealth() {
122122
);
123123
}
124124

125-
// status === 'auth_header_stripped'
125+
// status === 'auth_header_stripped' | 'bearer_header_stripped'
126126
const snippets = report.fix_snippet || {};
127127

128+
// The partial case is the one that costs people weeks, so it gets its own
129+
// words rather than the generic warning. Pasted-key apps keep working, so
130+
// every obvious test passes and the natural conclusion — "sign-in headers
131+
// are fine here" — is wrong. Only apps that sign in through Saddle break,
132+
// and ChatGPT is the one that can only connect that way.
133+
const bearerOnly = report.status === 'bearer_header_stripped';
134+
128135
return (
129136
<CalloutCard
130137
className="saddle-health"
131138
tone="warning"
132-
title={ __( 'Your server is blocking app sign-ins', 'saddle' ) }
133-
description={ __(
134-
'When an AI app connects, it sends its password in a sign-in header. Your web server removes that header before WordPress can see it, so every connection will fail as “unauthorized” — even with the right password. (The test above can still pass, because your browser signs in a different way.)',
135-
'saddle'
136-
) }
139+
title={
140+
bearerOnly
141+
? __(
142+
'Your server is blocking apps that sign in through Saddle',
143+
'saddle'
144+
)
145+
: __( 'Your server is blocking app sign-ins', 'saddle' )
146+
}
147+
description={
148+
bearerOnly
149+
? __(
150+
'Apps you connect with a pasted key are fine. But apps that sign in through Saddle — ChatGPT is the one that can only connect this way — send a different kind of sign-in header, and your web server removes it before WordPress sees it. Those apps will finish signing in and then report that the site has no actions they can use. The rule below lets that header through.',
151+
'saddle'
152+
)
153+
: __(
154+
'When an AI app connects, it sends its password in a sign-in header. Your web server removes that header before WordPress can see it, so every connection will fail as “unauthorized” — even with the right password. (The test above can still pass, because your browser signs in a different way.)',
155+
'saddle'
156+
)
157+
}
137158
>
138159
{ report.htaccess_fixable && fixOutcome !== 'still_stripped' && (
139160
<div className="saddle-health__actions">

includes/class-saddle-connection.php

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,15 @@ public static function rest_auth_probe() {
455455
return new WP_REST_Response(
456456
array(
457457
'received' => ( '' !== self::authorization_header() ),
458+
// WHICH scheme survived, not merely whether one did. Basic is
459+
// the scheme that gets through when others do not — Apache and
460+
// LiteSpeed consume it natively into PHP_AUTH_USER, so it is
461+
// visible to PHP even where the raw header is never forwarded,
462+
// and a WAF rule can match `Bearer` specifically. A probe that
463+
// only ever sent Basic could therefore report a healthy site
464+
// while every OAuth client was being refused. Safe to return:
465+
// it is the shape of what the caller itself just sent.
466+
'scheme' => self::credential_scheme(),
458467
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reports only whether a nonce arrived; the value is never read, verified, or returned.
459468
'nonce_header' => isset( $_SERVER['HTTP_X_WP_NONCE'] ),
460469
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- as above.
@@ -521,21 +530,29 @@ public static function rest_fix_auth_header() {
521530
* @return array
522531
*/
523532
public static function self_check() {
524-
$probe = self::probe_headers();
525-
$auth_header = $probe['auth'];
526-
$nonce_header = $probe['nonce'];
533+
$probe = self::probe_headers();
534+
$auth_header = $probe['auth'];
535+
$bearer_header = $probe['bearer'];
536+
$nonce_header = $probe['nonce'];
527537

528538
$report = array(
529539
'app_passwords_available' => function_exists( 'wp_is_application_passwords_available' ) ? (bool) wp_is_application_passwords_available() : false,
530540
'is_ssl' => is_ssl(),
531541
'server' => self::server_software(),
532542
'endpoint' => rest_url( ltrim( Saddle_MCP::REST_NAMESPACE . Saddle_MCP::ROUTE, '/' ) ),
533543
'auth_header' => $auth_header, // One of: ok, stripped, unknown.
544+
// The same question asked with a Bearer token, because the two
545+
// schemes fail independently and Saddle has clients on each: pasted
546+
// Application Passwords send Basic, and an app that signs in through
547+
// Saddle's OAuth server — ChatGPT, which has no field for a custom
548+
// header — can only ever send Bearer.
549+
'bearer_header' => $bearer_header, // One of: ok, stripped, unknown.
534550
'nonce_header' => $nonce_header, // One of: ok, stripped, unknown.
535-
// Bound to the Authorization case on purpose. The .htaccess rule
536-
// forwards that one header, and there is no equivalent for a nonce —
537-
// an edge-level strip happens upstream of Apache entirely.
538-
'htaccess_fixable' => ( 'stripped' === $auth_header ) && self::htaccess_fixable(),
551+
// Bound to the Authorization cases on purpose. The .htaccess rule
552+
// forwards that one header whatever scheme it carries, so it is the
553+
// fix for both; there is no equivalent for a nonce — an edge-level
554+
// strip happens upstream of Apache entirely.
555+
'htaccess_fixable' => ( 'stripped' === $auth_header || 'stripped' === $bearer_header ) && self::htaccess_fixable(),
539556
'fix_snippet' => self::fix_snippet(),
540557
);
541558

@@ -546,6 +563,13 @@ public static function self_check() {
546563
$report['status'] = 'app_passwords_off';
547564
} elseif ( 'stripped' === $auth_header ) {
548565
$report['status'] = 'auth_header_stripped';
566+
} elseif ( 'stripped' === $bearer_header ) {
567+
// Below auth_header_stripped, above the nonce: it breaks every app
568+
// that signs in through Saddle rather than with a pasted key, while
569+
// leaving the pasted-key apps working — which is the reason it can
570+
// go unnoticed for weeks. "Claude works, so the header is fine" is
571+
// the wrong inference, and this status exists to refuse it.
572+
$report['status'] = 'bearer_header_stripped';
549573
} elseif ( 'stripped' === $nonce_header ) {
550574
$report['status'] = 'nonce_header_stripped';
551575
} elseif ( 'ok' === $auth_header ) {
@@ -566,17 +590,56 @@ public static function self_check() {
566590
* stripping rules. Neither header carries a real credential — the probe
567591
* reports only whether they arrived.
568592
*
569-
* @return array{auth:string,nonce:string} Each 'ok' | 'stripped' | 'unknown'.
593+
* Two requests, not one, because **the schemes fail independently** and
594+
* Saddle has a client population on each. Basic is the scheme that survives
595+
* where others do not: Apache and LiteSpeed consume an RFC 7617 header
596+
* natively into `PHP_AUTH_USER`, so it reaches PHP even on setups that never
597+
* forward the raw `Authorization` header, and a WAF rule can be written
598+
* against `Bearer` alone. Probing Basic only therefore returns `ok` on a site
599+
* where every OAuth client is being refused — which is exactly the shape of
600+
* "my pasted-key apps work and ChatGPT gets 401", and exactly the report this
601+
* function gave while that was happening.
602+
*
603+
* @return array{auth:string,bearer:string,nonce:string} Each 'ok' | 'stripped' | 'unknown'.
570604
*/
571605
private static function probe_headers() {
572606
$unknown = array(
573-
'auth' => 'unknown',
574-
'nonce' => 'unknown',
607+
'auth' => 'unknown',
608+
'bearer' => 'unknown',
609+
'nonce' => 'unknown',
575610
);
576611

577-
$url = rest_url( Saddle_REST_Admin::REST_NAMESPACE . '/auth-probe' );
612+
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- building a standard RFC 7617 Basic header for the loopback probe.
613+
$basic = self::probe_once( 'Basic ' . base64_encode( 'saddle-probe:x' ) );
614+
if ( null === $basic ) {
615+
return $unknown;
616+
}
617+
618+
// Neither value is a credential. Nothing on the other end reads them —
619+
// the probe route reports only that a header arrived and of what shape.
620+
$bearer = self::probe_once( 'Bearer saddle-probe' );
621+
622+
return array(
623+
'auth' => array_key_exists( 'received', $basic ) ? ( ! empty( $basic['received'] ) ? 'ok' : 'stripped' ) : 'unknown',
624+
'nonce' => array_key_exists( 'nonce_header', $basic ) ? ( ! empty( $basic['nonce_header'] ) ? 'ok' : 'stripped' ) : 'unknown',
625+
// Judged on the SCHEME that arrived, not merely on arrival. A host
626+
// that rewrites an unrecognised Authorization header into something
627+
// else would otherwise pass a test it should fail.
628+
'bearer' => ( is_array( $bearer ) && array_key_exists( 'scheme', $bearer ) )
629+
? ( 'bearer' === $bearer['scheme'] ? 'ok' : 'stripped' )
630+
: 'unknown',
631+
);
632+
}
633+
634+
/**
635+
* One loopback request to the probe route, carrying one Authorization header.
636+
*
637+
* @param string $authorization Header value to send. Never a real credential.
638+
* @return array|null Decoded probe response, or null when the loopback failed.
639+
*/
640+
private static function probe_once( $authorization ) {
578641
$resp = wp_remote_get(
579-
$url,
642+
rest_url( Saddle_REST_Admin::REST_NAMESPACE . '/auth-probe' ),
580643
array(
581644
'timeout' => 5,
582645
// Loopback to our own rest_url with throwaway credentials —
@@ -586,27 +649,20 @@ private static function probe_headers() {
586649
'redirection' => 0,
587650
'cookies' => array(),
588651
'headers' => array(
589-
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- building a standard RFC 7617 Basic header for the loopback probe.
590-
'Authorization' => 'Basic ' . base64_encode( 'saddle-probe:x' ),
652+
'Authorization' => $authorization,
591653
// Not a real nonce and never verified against anything.
592654
'X-WP-Nonce' => 'saddle-probe',
593655
),
594656
)
595657
);
596658

597659
if ( is_wp_error( $resp ) ) {
598-
return $unknown;
660+
return null;
599661
}
600662

601663
$body = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
602-
if ( ! is_array( $body ) ) {
603-
return $unknown;
604-
}
605664

606-
return array(
607-
'auth' => array_key_exists( 'received', $body ) ? ( ! empty( $body['received'] ) ? 'ok' : 'stripped' ) : 'unknown',
608-
'nonce' => array_key_exists( 'nonce_header', $body ) ? ( ! empty( $body['nonce_header'] ) ? 'ok' : 'stripped' ) : 'unknown',
609-
);
665+
return is_array( $body ) ? $body : null;
610666
}
611667

612668
/**

languages/saddle.pot

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ msgstr ""
99
"MIME-Version: 1.0\n"
1010
"Content-Type: text/plain; charset=UTF-8\n"
1111
"Content-Transfer-Encoding: 8bit\n"
12-
"POT-Creation-Date: 2026-08-18T03:18:22+00:00\n"
12+
"POT-Creation-Date: 2026-08-18T03:22:27+00:00\n"
1313
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
1414
"X-Generator: WP-CLI 2.12.0\n"
1515
"X-Domain: saddle\n"
@@ -2096,11 +2096,11 @@ msgstr ""
20962096
msgid "Your sign-in key was rejected — it was most likely revoked or removed. Reconnect the app from Saddle to issue a fresh key."
20972097
msgstr ""
20982098

2099-
#: includes/class-saddle-connection.php:657
2099+
#: includes/class-saddle-connection.php:713
21002100
msgid "Saddle can’t edit this server’s configuration automatically. Add the rule shown below by hand or send it to your host."
21012101
msgstr ""
21022102

2103-
#: includes/class-saddle-connection.php:677
2103+
#: includes/class-saddle-connection.php:733
21042104
msgid "Saddle could not write to .htaccess. Check file permissions, or add the rule by hand."
21052105
msgstr ""
21062106

@@ -4513,13 +4513,13 @@ msgstr ""
45134513

45144514
#: admin/src/components/AuthTrouble.jsx:160
45154515
#: admin/src/components/ConnectionHealth.jsx:118
4516-
#: admin/src/components/ConnectionHealth.jsx:214
4516+
#: admin/src/components/ConnectionHealth.jsx:235
45174517
msgid "Checking…"
45184518
msgstr ""
45194519

45204520
#: admin/src/components/AuthTrouble.jsx:161
45214521
#: admin/src/components/ConnectionHealth.jsx:119
4522-
#: admin/src/components/ConnectionHealth.jsx:215
4522+
#: admin/src/components/ConnectionHealth.jsx:236
45234523
msgid "Check again"
45244524
msgstr ""
45254525

@@ -4728,43 +4728,51 @@ msgstr ""
47284728
msgid "Something on your hosting — usually a security or firewall layer — strips the X-WP-Nonce header from requests. Saddle works around it, so this dashboard is fine. But the same layer may interfere with other plugins’ settings screens, so it’s worth asking your host to let that header through."
47294729
msgstr ""
47304730

4731-
#: admin/src/components/ConnectionHealth.jsx:132
4731+
#: admin/src/components/ConnectionHealth.jsx:141
4732+
msgid "Your server is blocking apps that sign in through Saddle"
4733+
msgstr ""
4734+
4735+
#: admin/src/components/ConnectionHealth.jsx:145
47324736
msgid "Your server is blocking app sign-ins"
47334737
msgstr ""
47344738

4735-
#: admin/src/components/ConnectionHealth.jsx:133
4739+
#: admin/src/components/ConnectionHealth.jsx:149
4740+
msgid "Apps you connect with a pasted key are fine. But apps that sign in through Saddle — ChatGPT is the one that can only connect this way — send a different kind of sign-in header, and your web server removes it before WordPress sees it. Those apps will finish signing in and then report that the site has no actions they can use. The rule below lets that header through."
4741+
msgstr ""
4742+
4743+
#: admin/src/components/ConnectionHealth.jsx:153
47364744
msgid "When an AI app connects, it sends its password in a sign-in header. Your web server removes that header before WordPress can see it, so every connection will fail as “unauthorized” — even with the right password. (The test above can still pass, because your browser signs in a different way.)"
47374745
msgstr ""
47384746

4739-
#: admin/src/components/ConnectionHealth.jsx:147
4747+
#: admin/src/components/ConnectionHealth.jsx:168
47404748
msgid "Fixing…"
47414749
msgstr ""
47424750

4743-
#: admin/src/components/ConnectionHealth.jsx:148
4751+
#: admin/src/components/ConnectionHealth.jsx:169
47444752
msgid "Fix it for me"
47454753
msgstr ""
47464754

4747-
#: admin/src/components/ConnectionHealth.jsx:155
4755+
#: admin/src/components/ConnectionHealth.jsx:176
47484756
msgid "Hide the rule"
47494757
msgstr ""
47504758

4751-
#: admin/src/components/ConnectionHealth.jsx:156
4759+
#: admin/src/components/ConnectionHealth.jsx:177
47524760
msgid "See what this adds"
47534761
msgstr ""
47544762

4755-
#: admin/src/components/ConnectionHealth.jsx:163
4763+
#: admin/src/components/ConnectionHealth.jsx:184
47564764
msgid "Saddle added the rule, but the header still isn’t arriving — something earlier in the chain (a proxy or your host’s own config) is removing it. Send the rule below to your hosting support and ask them to allow the Authorization header."
47574765
msgstr ""
47584766

4759-
#: admin/src/components/ConnectionHealth.jsx:180
4767+
#: admin/src/components/ConnectionHealth.jsx:201
47604768
msgid "Saddle can’t edit this server’s configuration automatically. Add the matching rule below yourself, or send it to your hosting support."
47614769
msgstr ""
47624770

4763-
#: admin/src/components/ConnectionHealth.jsx:192
4771+
#: admin/src/components/ConnectionHealth.jsx:213
47644772
msgid "Apache / LiteSpeed — add to .htaccess"
47654773
msgstr ""
47664774

4767-
#: admin/src/components/ConnectionHealth.jsx:202
4775+
#: admin/src/components/ConnectionHealth.jsx:223
47684776
msgid "nginx — add to the PHP location block"
47694777
msgstr ""
47704778

0 commit comments

Comments
 (0)