| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS REST CORS and cache wire contract. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS; |
| 9 |
|
| 10 |
use WP_HTTP_Response; |
| 11 |
use WP_REST_Request; |
| 12 |
use WP_REST_Server; |
| 13 |
|
| 14 |
/** |
| 15 |
* The single owner of the WCPOS REST wire contract. |
| 16 |
* |
| 17 |
* ## Why one owner |
| 18 |
* |
| 19 |
* `Access-Control-Allow-Headers` is the one response header where a missing |
| 20 |
* entry does not degrade a feature: the browser refuses the ACTUAL request, |
| 21 |
* so a cross-origin till goes offline outright with no client-side recovery. |
| 22 |
* The same set used to be published by two hand-maintained writers — Init's |
| 23 |
* preflight handler and API's `rest_allowed_cors_headers` callback — and they |
| 24 |
* drifted apart twice in one week (`23bcdb47`, `118a091f`), each time taking |
| 25 |
* every cross-origin till down. `6b10fdcd` then moved the cache contract to |
| 26 |
* Init because the relay's consent route is served WITHOUT constructing API. |
| 27 |
* All of that lives here now, in one class, published by one handler. |
| 28 |
* |
| 29 |
* ## Why priority 20 |
| 30 |
* |
| 31 |
* WP core hooks `rest_send_cors_headers()` onto `rest_pre_serve_request` at |
| 32 |
* priority 10, and it publishes an ORIGIN-SPECIFIC `Access-Control-Allow- |
| 33 |
* Origin`. `WP_REST_Server::send_header()` calls PHP's `header()` with |
| 34 |
* `$replace = true`, so the last writer wins: running at 20 is what makes the |
| 35 |
* WCPOS `*` origin survive on the lanes that are ours, including a preflight |
| 36 |
* to a non-WCPOS namespace (`/wc/v3/...`) that announces a WCPOS header, |
| 37 |
* where API is never constructed. Everything WCPOS writes must therefore run |
| 38 |
* after 10 — and, because a later writer WINS rather than merely adds, what |
| 39 |
* we claim has to be exactly ours ({@see self::owns_request()}). |
| 40 |
* Raw byte responses echo their body from their own callback and are pushed |
| 41 |
* to priority 30 for the same reason ({@see API\V1\Raw_Response::serve()}): |
| 42 |
* headers cannot be sent after the body. |
| 43 |
* |
| 44 |
* ## Why registered unconditionally |
| 45 |
* |
| 46 |
* The Cloud Print relay proves site consent by fetching the verification |
| 47 |
* route WITHOUT the `X-WCPOS` marker, and it is served without constructing |
| 48 |
* API ({@see Init::register_public_relay_routes()}), so a hook registered |
| 49 |
* behind the marker gate would miss it — a shared cache could then replay a |
| 50 |
* single-use verification token as stale proof. CORS preflights carry no |
| 51 |
* request headers at all (Fetch spec), so they cannot be gated either. |
| 52 |
* Registration is unconditional; {@see self::owns_request()} decides per |
| 53 |
* request what is published. |
| 54 |
*/ |
| 55 |
final class Rest_Cors { |
| 56 |
/** |
| 57 |
* Response headers a cross-origin client is allowed to READ. |
| 58 |
* |
| 59 |
* `X-WCPOS-Pressure` is also emitted by the bootstrap ping fast path, |
| 60 |
* which short-circuits before WP REST loads ({@see API\V2\Ping}). |
| 61 |
* |
| 62 |
* @var string[] |
| 63 |
*/ |
| 64 |
public const EXPOSE_HEADERS = array( |
| 65 |
'X-WP-Total', // Total number of records in a collection. |
| 66 |
'X-WP-TotalPages', // Total number of pages in a collection. |
| 67 |
'Link', // Pagination and API discovery. |
| 68 |
'X-Server-Load', // Response telemetry. |
| 69 |
'Server-Timing', // Response telemetry. |
| 70 |
'X-WCPOS-Memory-Peak', // Response telemetry. |
| 71 |
'X-WCPOS-Pressure', // Host pressure bucket, also sent by the ping fast path. |
| 72 |
'ETag', // Conditional sequence-log polling (304s). |
| 73 |
'Date', // Server clock, used for client drift correction. |
| 74 |
); |
| 75 |
|
| 76 |
/** |
| 77 |
* Request headers allowed through preflight, before the sync-lane set. |
| 78 |
* |
| 79 |
* The first five are WP core's own defaults, repeated so this list stands |
| 80 |
* on its own: the handler publishes it directly and core's copy is |
| 81 |
* overwritten. The sync-lane additions are merged in from Sync\Cors. |
| 82 |
* |
| 83 |
* Known duplication, recorded rather than fixed here: the first six entries |
| 84 |
* restate WordPress core's own defaults from WP_REST_Server::serve_request(), |
| 85 |
* and the contract test restates them a third time. If core ever adds a |
| 86 |
* seventh, both copies drift and no test fails — the same class of bug this |
| 87 |
* module exists to remove, one level up. The real fix is to stop republishing |
| 88 |
* the allow-list here at all and hook `rest_allowed_cors_headers` instead, |
| 89 |
* since core already writes it unconditionally before any |
| 90 |
* `rest_pre_serve_request` filter runs; that shrinks this module to what |
| 91 |
* genuinely needs last-writer-wins (Allow-Origin, Max-Age, cache contract). |
| 92 |
* That is a behaviour-shaped change and does not belong in a release-week |
| 93 |
* refactor of a High-tier path. |
| 94 |
* |
| 95 |
* Access-Control-Allow-Methods matches core's list exactly, OPTIONS included. |
| 96 |
* The handler this replaces omitted OPTIONS, but it wrote at priority 5 and |
| 97 |
* core overwrote it at 10 whenever an Origin was present — so the value that |
| 98 |
* actually reached the wire was core's. Winning at 20 without OPTIONS would |
| 99 |
* have changed the wire for the first time in that header's life. |
| 100 |
* |
| 101 |
* @var string[] |
| 102 |
*/ |
| 103 |
public const ALLOW_HEADERS_BASE = array( |
| 104 |
'Authorization', // For user-agent authentication with a server. |
| 105 |
'X-WP-Nonce', // WordPress-specific header, used for CSRF protection. |
| 106 |
'Content-Disposition', // Informs how to process the response data. |
| 107 |
'Content-MD5', // For verifying data integrity. |
| 108 |
'Content-Type', // Specifies the media type of the resource. |
| 109 |
'X-HTTP-Method-Override', // Used to override the HTTP method. |
| 110 |
'X-WCPOS', // Used to identify WCPOS requests. |
| 111 |
); |
| 112 |
|
| 113 |
/** |
| 114 |
* Request headers a shared cache must key WCPOS responses on. |
| 115 |
* |
| 116 |
* Authorization keys per bearer token and X-WCPOS-Store per store scope — |
| 117 |
* the same token requesting different scopes receives store-specific |
| 118 |
* pricing and taxes, so a shared entry must not span stores either. |
| 119 |
* |
| 120 |
* @var string[] |
| 121 |
*/ |
| 122 |
public const VARY_TOKENS = array( 'Origin', 'Authorization', Sync\Store_Scope::HEADER ); |
| 123 |
|
| 124 |
/** |
| 125 |
* Preflight cache lifetime, in seconds. |
| 126 |
* |
| 127 |
* Without it the Fetch spec caches a preflight for only FIVE seconds, so |
| 128 |
* every cross-origin POS request is two requests. 7200 is Chromium's cap |
| 129 |
* (Firefox allows 86400). |
| 130 |
*/ |
| 131 |
public const MAX_AGE = '7200'; |
| 132 |
|
| 133 |
/** |
| 134 |
* WCPOS REST routes, matched case-insensitively because WP dispatches |
| 135 |
* REST routes with a case-insensitive regex: `/WCPOS/V1/...` reaches the |
| 136 |
* controllers, so it must reach this contract too. |
| 137 |
*/ |
| 138 |
private const ROUTE_PATTERN = '#^/wcpos/v\d+(?:/|$)#i'; |
| 139 |
|
| 140 |
/** |
| 141 |
* The lanes the cache contract covers: the two shipped namespaces. |
| 142 |
* |
| 143 |
* Deliberately narrower than ROUTE_PATTERN — a namespace added through |
| 144 |
* `woocommerce_pos_rest_namespaces` publishes its own response semantics, |
| 145 |
* and this guard has never covered it. |
| 146 |
*/ |
| 147 |
private const CACHE_ROUTE_PATTERN = '#^/wcpos/v[12](?:/|$)#i'; |
| 148 |
|
| 149 |
/** |
| 150 |
* The prefix every WCPOS-specific request header shares. |
| 151 |
* |
| 152 |
* A preflight carries no headers of its own, but it ANNOUNCES the ones the |
| 153 |
* real request will send in `Access-Control-Request-Headers` — the marker |
| 154 |
* `X-WCPOS` plus the scope and idempotency headers ({@see Sync\Cors}) all |
| 155 |
* start with this, so a preflight destined for us is identifiable without |
| 156 |
* having to guess from the route alone. |
| 157 |
*/ |
| 158 |
private const MARKER_HEADER_PREFIX = 'x-wcpos'; |
| 159 |
|
| 160 |
/** |
| 161 |
* Register the wire contract. Unconditional — see the class docblock. |
| 162 |
*/ |
| 163 |
public static function register_hooks(): void { |
| 164 |
add_filter( 'rest_allowed_cors_headers', array( self::class, 'allowed_cors_headers' ), 10, 1 ); |
| 165 |
add_filter( 'rest_pre_serve_request', array( self::class, 'rest_pre_serve_request' ), 20, 4 ); |
| 166 |
} |
| 167 |
|
| 168 |
/** |
| 169 |
* WP core's `rest_allowed_cors_headers` filter: the request headers a POS |
| 170 |
* client may send. |
| 171 |
* |
| 172 |
* Both publishers of the allow-list — core's own write in |
| 173 |
* `WP_REST_Server::serve_request()` and this class's preflight handler — |
| 174 |
* pass through here, so there is exactly one list. |
| 175 |
* |
| 176 |
* @param string[] $allow_headers The allow-list under construction. |
| 177 |
* |
| 178 |
* @return string[] $allow_headers |
| 179 |
*/ |
| 180 |
public static function allowed_cors_headers( array $allow_headers ): array { |
| 181 |
return Sync\Cors::allow_headers( array_merge( $allow_headers, self::ALLOW_HEADERS_BASE ) ); |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* Publish the WCPOS CORS and cache contract on a REST response. |
| 186 |
* |
| 187 |
* @param bool $served Whether the request has already been served. |
| 188 |
* Default false. |
| 189 |
* @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`. |
| 190 |
* @param WP_REST_Request $request Request used to generate the response. |
| 191 |
* @param WP_REST_Server $server Server instance. |
| 192 |
* |
| 193 |
* @return bool $served |
| 194 |
*/ |
| 195 |
public static function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_REST_Request $request, WP_REST_Server $server ) { |
| 196 |
if ( preg_match( self::CACHE_ROUTE_PATTERN, (string) $request->get_route() ) ) { |
| 197 |
self::send_cache_defeating_headers( $result, $server ); |
| 198 |
} |
| 199 |
|
| 200 |
if ( ! self::owns_request( $request ) ) { |
| 201 |
return $served; |
| 202 |
} |
| 203 |
|
| 204 |
// Core's own filter, re-applied here because this write replaces the |
| 205 |
// one core made in WP_REST_Server::serve_request(). |
| 206 |
$expose_headers = apply_filters( 'rest_exposed_cors_headers', self::EXPOSE_HEADERS, $request ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook. |
| 207 |
|
| 208 |
$server->send_header( 'Access-Control-Allow-Origin', '*' ); |
| 209 |
$server->send_header( 'Access-Control-Expose-Headers', implode( ', ', array_unique( $expose_headers ) ) ); |
| 210 |
|
| 211 |
if ( 'OPTIONS' === $request->get_method() ) { |
| 212 |
// Same list core built in serve_request(), through the same |
| 213 |
// filter, so a third party that hooks it reaches both writes. |
| 214 |
$allow_headers = apply_filters( 'rest_allowed_cors_headers', self::ALLOW_HEADERS_BASE, $request ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook. |
| 215 |
|
| 216 |
$server->send_header( 'Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT, PATCH, DELETE' ); |
| 217 |
$server->send_header( 'Access-Control-Allow-Headers', implode( ', ', array_unique( $allow_headers ) ) ); |
| 218 |
$server->send_header( 'Access-Control-Max-Age', self::MAX_AGE ); |
| 219 |
} |
| 220 |
|
| 221 |
return $served; |
| 222 |
} |
| 223 |
|
| 224 |
/** |
| 225 |
* Whether this request is destined for WCPOS. |
| 226 |
* |
| 227 |
* Ours is: a WCPOS-namespace route (marked or not — the relay consent |
| 228 |
* route is deliberately unmarked), a marked request whatever the route |
| 229 |
* (the POS reads `wc/v3` collections too), or a preflight that ANNOUNCES |
| 230 |
* one of our headers. |
| 231 |
* |
| 232 |
* Note what is NOT here: a bare OPTIONS. The old handler claimed every |
| 233 |
* preflight on the site, which was harmless only because it ran at |
| 234 |
* priority 5 and core overwrote it at 10. At priority 20 we win, and |
| 235 |
* stamping `Access-Control-Allow-Origin: *` on another plugin's route |
| 236 |
* would break credentialed cross-origin requests that have nothing to do |
| 237 |
* with WCPOS (core pairs its origin-specific answer with |
| 238 |
* `Access-Control-Allow-Credentials: true`, which `*` invalidates). |
| 239 |
* Preflights we do not claim keep core's answer, and they still carry the |
| 240 |
* full WCPOS allow-list: core builds that one through |
| 241 |
* `rest_allowed_cors_headers`, which {@see self::allowed_cors_headers()} |
| 242 |
* filters unconditionally. |
| 243 |
* |
| 244 |
* @param WP_REST_Request $request Request used to generate the response. |
| 245 |
* |
| 246 |
* @return bool |
| 247 |
*/ |
| 248 |
private static function owns_request( WP_REST_Request $request ): bool { |
| 249 |
if ( preg_match( self::ROUTE_PATTERN, (string) $request->get_route() ) ) { |
| 250 |
return true; |
| 251 |
} |
| 252 |
|
| 253 |
if ( ! empty( $request->get_header( 'X-' . SHORT_NAME ) ) ) { |
| 254 |
return true; |
| 255 |
} |
| 256 |
|
| 257 |
// The query-var marker, for clients behind a proxy that strips custom |
| 258 |
// request headers ({@see wcpos_request()}). |
| 259 |
$query_params = $request->get_query_params(); |
| 260 |
if ( ! empty( $query_params[ SHORT_NAME ] ) ) { |
| 261 |
return true; |
| 262 |
} |
| 263 |
|
| 264 |
return 'OPTIONS' === $request->get_method() && self::preflight_announces_wcpos( $request ); |
| 265 |
} |
| 266 |
|
| 267 |
/** |
| 268 |
* Whether a preflight says the request it precedes will be a WCPOS one. |
| 269 |
* |
| 270 |
* The browser builds `Access-Control-Request-Headers` from the headers the |
| 271 |
* real request carries, so a marked request to a route outside our |
| 272 |
* namespaces — the shape that took the till offline in 23bcdb47 and |
| 273 |
* 118a091f — announces `x-wcpos` here and is answered as ours. |
| 274 |
* |
| 275 |
* @param WP_REST_Request $request Request used to generate the response. |
| 276 |
* |
| 277 |
* @return bool |
| 278 |
*/ |
| 279 |
private static function preflight_announces_wcpos( WP_REST_Request $request ): bool { |
| 280 |
$announced = (string) $request->get_header( 'Access-Control-Request-Headers' ); |
| 281 |
if ( '' === $announced ) { |
| 282 |
return false; |
| 283 |
} |
| 284 |
|
| 285 |
foreach ( explode( ',', strtolower( $announced ) ) as $header ) { |
| 286 |
if ( 0 === strpos( trim( $header ), self::MARKER_HEADER_PREFIX ) ) { |
| 287 |
return true; |
| 288 |
} |
| 289 |
} |
| 290 |
|
| 291 |
return false; |
| 292 |
} |
| 293 |
|
| 294 |
/** |
| 295 |
* Defeat shared caching of WCPOS REST responses. |
| 296 |
* |
| 297 |
* Hosting layers cache authenticated REST GETs and replay them across |
| 298 |
* users (LiteSpeed caches REST by default for 7 days with no |
| 299 |
* Authorization bypass; WP Engine's edge cache excludes /wp-json/wc but |
| 300 |
* not /wp-json/wcpos; Sucuri's default levels ignore Cache-Control). |
| 301 |
* |
| 302 |
* Vary is defense-in-depth for intermediaries that ignore no-store but |
| 303 |
* honor Vary ({@see self::VARY_TOKENS}). Existing Vary tokens are |
| 304 |
* preserved (deduped case-insensitively); a wildcard Vary stays alone, |
| 305 |
* since '*' is grammatically an alternative to a field list. |
| 306 |
* |
| 307 |
* @param WP_HTTP_Response $result Result to send to the client. |
| 308 |
* @param WP_REST_Server $server Server instance. |
| 309 |
*/ |
| 310 |
private static function send_cache_defeating_headers( WP_HTTP_Response $result, WP_REST_Server $server ): void { |
| 311 |
$response_headers = array_change_key_case( $result->get_headers(), CASE_LOWER ); |
| 312 |
$existing_vary = isset( $response_headers['vary'] ) |
| 313 |
? array_values( |
| 314 |
array_filter( |
| 315 |
array_map( 'trim', explode( ',', (string) $response_headers['vary'] ) ), |
| 316 |
static function ( string $token ): bool { |
| 317 |
return '' !== $token; |
| 318 |
} |
| 319 |
) |
| 320 |
) |
| 321 |
: array(); |
| 322 |
|
| 323 |
if ( in_array( '*', $existing_vary, true ) ) { |
| 324 |
$vary = '*'; |
| 325 |
} else { |
| 326 |
$vary_tokens = array_merge( $existing_vary, self::VARY_TOKENS ); |
| 327 |
$vary_tokens = array_change_key_case( array_combine( $vary_tokens, $vary_tokens ), CASE_LOWER ); |
| 328 |
$vary = implode( ', ', $vary_tokens ); |
| 329 |
} |
| 330 |
|
| 331 |
$server->send_header( 'Cache-Control', 'private, no-store' ); |
| 332 |
$server->send_header( 'Vary', $vary ); |
| 333 |
do_action( 'litespeed_control_set_nocache', 'wcpos rest response' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook |
| 334 |
} |
| 335 |
} |
| 336 |
|