| 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 |
|