PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / Rest_Cors.php

Rest_Cors.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/Rest_Cors.php

436 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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. Preflights are the exception:
146 * every preflight this class ANSWERS gets the cache contract regardless
147 * of lane ({@see self::rest_pre_serve_request()}), because the answer
148 * depends on the announced headers; the narrowing here binds real
149 * responses only.
150 */
151 private const CACHE_ROUTE_PATTERN = '#^/wcpos/v[12](?:/|$)#i';
152
153 /**
154 * The prefix every WCPOS-specific request header shares.
155 *
156 * A preflight carries no headers of its own, but it ANNOUNCES the ones the
157 * real request will send in `Access-Control-Request-Headers` — the marker
158 * `X-WCPOS` plus the scope and idempotency headers ({@see Sync\Cors}) all
159 * start with this, so a preflight destined for us is identifiable without
160 * having to guess from the route alone.
161 */
162 private const MARKER_HEADER_PREFIX = 'x-wcpos';
163
164 /** Makes CR/LF/NUL and malformed names unreachable in the response header. */
165 private const HEADER_NAME_PATTERN = '/\A[A-Za-z0-9!#$%&\'*+.^_`|~-]+\z/';
166
167 /**
168 * Reflection budget, names axis ({@see self::preflight_allow_headers()}).
169 *
170 * Bounds a hostile many-short-names announcement, which the byte budget
171 * alone would not. A legitimate client announces well under ten names
172 * beyond the floor.
173 */
174 private const REFLECT_MAX_NAMES = 16;
175
176 /**
177 * Reflection budget, bytes axis ({@see self::preflight_allow_headers()}).
178 *
179 * Bounds a hostile few-long-names announcement. With the ~250-byte floor
180 * the emitted header stays well under the smallest real proxy
181 * response-header ceilings (nginx buffers 4 KB per header line).
182 */
183 private const REFLECT_MAX_BYTES = 256;
184
185 /**
186 * Register the wire contract. Unconditional — see the class docblock.
187 */
188 public static function register_hooks(): void {
189 add_filter( 'rest_allowed_cors_headers', array( self::class, 'allowed_cors_headers' ), 10, 1 );
190 add_filter( 'rest_pre_serve_request', array( self::class, 'rest_pre_serve_request' ), 20, 4 );
191 }
192
193 /**
194 * WP core's `rest_allowed_cors_headers` filter: the request headers a POS
195 * client may send.
196 *
197 * Both publishers of the allow-list — core's own write in
198 * `WP_REST_Server::serve_request()` and this class's preflight handler —
199 * pass through here, so there is exactly one list.
200 *
201 * @param string[] $allow_headers The allow-list under construction.
202 *
203 * @return string[] $allow_headers
204 */
205 public static function allowed_cors_headers( array $allow_headers ): array {
206 return Sync\Cors::allow_headers( array_merge( $allow_headers, self::ALLOW_HEADERS_BASE ) );
207 }
208
209 /**
210 * Publish the WCPOS CORS and cache contract on a REST response.
211 *
212 * @param bool $served Whether the request has already been served.
213 * Default false.
214 * @param WP_HTTP_Response $result Result to send to the client. Usually a `WP_REST_Response`.
215 * @param WP_REST_Request $request Request used to generate the response.
216 * @param WP_REST_Server $server Server instance.
217 *
218 * @return bool $served
219 */
220 public static function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_REST_Request $request, WP_REST_Server $server ) {
221 $owns_request = self::owns_request( $request );
222 if ( $owns_request && 'OPTIONS' === $request->get_method() ) {
223 self::send_cache_defeating_headers( $result, $server, array( 'Access-Control-Request-Headers' ) );
224 } elseif ( preg_match( self::CACHE_ROUTE_PATTERN, (string) $request->get_route() ) ) {
225 self::send_cache_defeating_headers( $result, $server );
226 }
227
228 if ( ! $owns_request ) {
229 return $served;
230 }
231
232 // Core's own filter, re-applied here because this write replaces the
233 // one core made in WP_REST_Server::serve_request().
234 $expose_headers = apply_filters( 'rest_exposed_cors_headers', self::EXPOSE_HEADERS, $request ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook.
235
236 $server->send_header( 'Access-Control-Allow-Origin', '*' );
237 $server->send_header( 'Access-Control-Expose-Headers', implode( ', ', array_unique( $expose_headers ) ) );
238
239 if ( 'OPTIONS' === $request->get_method() ) {
240 $server->send_header( 'Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT, PATCH, DELETE' );
241 $server->send_header( 'Access-Control-Allow-Headers', implode( ', ', array_unique( self::preflight_allow_headers( $request ) ) ) );
242 $server->send_header( 'Access-Control-Max-Age', self::MAX_AGE );
243 }
244
245 return $served;
246 }
247
248 /**
249 * The preflight allow-list: the frozen floor plus reflected announcements.
250 *
251 * The floor (ALLOW_HEADERS_BASE ∪ Sync\Cors::headers(), through core's
252 * filter) is frozen — {@see Sync\Cors::headers()}. Any `x-wcpos-*` name
253 * the browser announces in `Access-Control-Request-Headers` is reflected
254 * after it, so a header a future client invents is pre-authorized the
255 * moment it ships instead of waiting out the plugin-update lag that took
256 * tills offline in 23bcdb47, 118a091f, and forced #1760's query twins.
257 * Reflection grants nothing: it tells the browser it MAY send the name;
258 * every route keeps its permission callback, and the server ignores
259 * names it does not read. {@see API\V2\Echo_Probe} advertises this
260 * capability to clients (`cors.reflects_request_headers`) — narrowing
261 * reflection later must update that field.
262 *
263 * A non-browser can put arbitrary bytes in the announcement, so
264 * reflected names are token-checked (HEADER_NAME_PATTERN) and budgeted
265 * on two independent axes (REFLECT_MAX_NAMES, REFLECT_MAX_BYTES). An
266 * oversized name is SKIPPED, never truncated — and never aborts the
267 * names after it, or one hostile entry could starve a legitimate header
268 * and take the till offline: the exact outage class reflection removes.
269 *
270 * Degradation contract: an absent or proxy-stripped announcement yields
271 * the floor, byte-identical to the pre-reflection wire.
272 *
273 * @param WP_REST_Request $request Request used to generate the response.
274 *
275 * @return string[] Floor names in canonical casing, reflected extras in lowercase.
276 */
277 private static function preflight_allow_headers( WP_REST_Request $request ): array {
278 // Same list core built in serve_request(), through the same
279 // filter, so a third party that hooks it reaches both writes.
280 $allow_headers = apply_filters( 'rest_allowed_cors_headers', self::ALLOW_HEADERS_BASE, $request ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook.
281 $allowed_names = array_fill_keys( array_map( 'strtolower', $allow_headers ), true );
282 $reflected = 0;
283 $reflected_bytes = 0;
284
285 foreach ( self::announced_header_names( $request ) as $header ) {
286 // The bare marker (`x-wcpos`) is already in the floor; reflected
287 // extras must carry the hyphenated namespace, so `x-wcposter`
288 // stays a stranger's header.
289 if ( ! preg_match( self::HEADER_NAME_PATTERN, $header ) || 0 !== strpos( $header, self::MARKER_HEADER_PREFIX . '-' ) || isset( $allowed_names[ $header ] ) ) {
290 continue;
291 }
292 if ( self::REFLECT_MAX_NAMES <= $reflected ) {
293 break;
294 }
295 if ( self::REFLECT_MAX_BYTES < $reflected_bytes + strlen( $header ) ) {
296 continue;
297 }
298
299 $allow_headers[] = $header;
300 $allowed_names[ $header ] = true;
301 ++$reflected;
302 $reflected_bytes += strlen( $header );
303 }
304
305 return $allow_headers;
306 }
307
308 /**
309 * Whether this request is destined for WCPOS.
310 *
311 * Ours is: a WCPOS-namespace route (marked or not — the relay consent
312 * route is deliberately unmarked), a marked request whatever the route
313 * (the POS reads `wc/v3` collections too), or a preflight that ANNOUNCES
314 * one of our headers.
315 *
316 * Note what is NOT here: a bare OPTIONS. The old handler claimed every
317 * preflight on the site, which was harmless only because it ran at
318 * priority 5 and core overwrote it at 10. At priority 20 we win, and
319 * stamping `Access-Control-Allow-Origin: *` on another plugin's route
320 * would break credentialed cross-origin requests that have nothing to do
321 * with WCPOS (core pairs its origin-specific answer with
322 * `Access-Control-Allow-Credentials: true`, which `*` invalidates).
323 * Preflights we do not claim keep core's answer, and they still carry the
324 * full WCPOS allow-list: core builds that one through
325 * `rest_allowed_cors_headers`, which {@see self::allowed_cors_headers()}
326 * filters unconditionally.
327 *
328 * @param WP_REST_Request $request Request used to generate the response.
329 *
330 * @return bool
331 */
332 private static function owns_request( WP_REST_Request $request ): bool {
333 if ( preg_match( self::ROUTE_PATTERN, (string) $request->get_route() ) ) {
334 return true;
335 }
336
337 if ( ! empty( $request->get_header( 'X-' . SHORT_NAME ) ) ) {
338 return true;
339 }
340
341 // The query-var marker, for clients behind a proxy that strips custom
342 // request headers ({@see wcpos_request()}).
343 $query_params = $request->get_query_params();
344 if ( ! empty( $query_params[ SHORT_NAME ] ) ) {
345 return true;
346 }
347
348 return 'OPTIONS' === $request->get_method() && self::preflight_announces_wcpos( $request );
349 }
350
351 /**
352 * Whether a preflight says the request it precedes will be a WCPOS one.
353 *
354 * The browser builds `Access-Control-Request-Headers` from the headers the
355 * real request carries, so a marked request to a route outside our
356 * namespaces — the shape that took the till offline in 23bcdb47 and
357 * 118a091f — announces `x-wcpos` here and is answered as ours.
358 *
359 * @param WP_REST_Request $request Request used to generate the response.
360 *
361 * @return bool
362 */
363 private static function preflight_announces_wcpos( WP_REST_Request $request ): bool {
364 foreach ( self::announced_header_names( $request ) as $header ) {
365 if ( 0 === strpos( $header, self::MARKER_HEADER_PREFIX ) ) {
366 return true;
367 }
368 }
369
370 return false;
371 }
372
373 /**
374 * Parse the header names announced by a CORS preflight.
375 *
376 * @param WP_REST_Request $request Request used to generate the response.
377 *
378 * @return string[] Trimmed, lowercase, non-empty header names.
379 */
380 private static function announced_header_names( WP_REST_Request $request ): array {
381 return array_values(
382 array_filter(
383 array_map( 'trim', explode( ',', strtolower( (string) $request->get_header( 'Access-Control-Request-Headers' ) ) ) ),
384 static function ( string $header ): bool {
385 return '' !== $header;
386 }
387 )
388 );
389 }
390
391 /**
392 * Defeat shared caching of WCPOS REST responses.
393 *
394 * Hosting layers cache authenticated REST GETs and replay them across
395 * users (LiteSpeed caches REST by default for 7 days with no
396 * Authorization bypass; WP Engine's edge cache excludes /wp-json/wc but
397 * not /wp-json/wcpos; Sucuri's default levels ignore Cache-Control).
398 *
399 * Vary is defense-in-depth for intermediaries that ignore no-store but
400 * honor Vary ({@see self::VARY_TOKENS}). Existing Vary tokens are
401 * preserved (deduped case-insensitively); a wildcard Vary stays alone,
402 * since '*' is grammatically an alternative to a field list. Preflights
403 * also vary on their announced headers because the answer depends on them
404 * (RFC 9111 section 4.1).
405 *
406 * @param WP_HTTP_Response $result Result to send to the client.
407 * @param WP_REST_Server $server Server instance.
408 * @param string[] $extra_vary_tokens Additional Vary tokens.
409 */
410 private static function send_cache_defeating_headers( WP_HTTP_Response $result, WP_REST_Server $server, array $extra_vary_tokens = array() ): void {
411 $response_headers = array_change_key_case( $result->get_headers(), CASE_LOWER );
412 $existing_vary = isset( $response_headers['vary'] )
413 ? array_values(
414 array_filter(
415 array_map( 'trim', explode( ',', (string) $response_headers['vary'] ) ),
416 static function ( string $token ): bool {
417 return '' !== $token;
418 }
419 )
420 )
421 : array();
422
423 if ( in_array( '*', $existing_vary, true ) ) {
424 $vary = '*';
425 } else {
426 $vary_tokens = array_merge( $existing_vary, self::VARY_TOKENS, $extra_vary_tokens );
427 $vary_tokens = array_change_key_case( array_combine( $vary_tokens, $vary_tokens ), CASE_LOWER );
428 $vary = implode( ', ', $vary_tokens );
429 }
430
431 $server->send_header( 'Cache-Control', 'private, no-store' );
432 $server->send_header( 'Vary', $vary );
433 do_action( 'litespeed_control_set_nocache', 'wcpos rest response' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook
434 }
435 }
436