get_method() ) { self::send_cache_defeating_headers( $result, $server, array( 'Access-Control-Request-Headers' ) ); } elseif ( preg_match( self::CACHE_ROUTE_PATTERN, (string) $request->get_route() ) ) { self::send_cache_defeating_headers( $result, $server ); } if ( ! $owns_request ) { return $served; } // Core's own filter, re-applied here because this write replaces the // one core made in WP_REST_Server::serve_request(). $expose_headers = apply_filters( 'rest_exposed_cors_headers', self::EXPOSE_HEADERS, $request ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook. $server->send_header( 'Access-Control-Allow-Origin', '*' ); $server->send_header( 'Access-Control-Expose-Headers', implode( ', ', array_unique( $expose_headers ) ) ); if ( 'OPTIONS' === $request->get_method() ) { $server->send_header( 'Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT, PATCH, DELETE' ); $server->send_header( 'Access-Control-Allow-Headers', implode( ', ', array_unique( self::preflight_allow_headers( $request ) ) ) ); $server->send_header( 'Access-Control-Max-Age', self::MAX_AGE ); } return $served; } /** * The preflight allow-list: the frozen floor plus reflected announcements. * * The floor (ALLOW_HEADERS_BASE ∪ Sync\Cors::headers(), through core's * filter) is frozen — {@see Sync\Cors::headers()}. Any `x-wcpos-*` name * the browser announces in `Access-Control-Request-Headers` is reflected * after it, so a header a future client invents is pre-authorized the * moment it ships instead of waiting out the plugin-update lag that took * tills offline in 23bcdb47, 118a091f, and forced #1760's query twins. * Reflection grants nothing: it tells the browser it MAY send the name; * every route keeps its permission callback, and the server ignores * names it does not read. {@see API\V2\Echo_Probe} advertises this * capability to clients (`cors.reflects_request_headers`) — narrowing * reflection later must update that field. * * A non-browser can put arbitrary bytes in the announcement, so * reflected names are token-checked (HEADER_NAME_PATTERN) and budgeted * on two independent axes (REFLECT_MAX_NAMES, REFLECT_MAX_BYTES). An * oversized name is SKIPPED, never truncated — and never aborts the * names after it, or one hostile entry could starve a legitimate header * and take the till offline: the exact outage class reflection removes. * * Degradation contract: an absent or proxy-stripped announcement yields * the floor, byte-identical to the pre-reflection wire. * * @param WP_REST_Request $request Request used to generate the response. * * @return string[] Floor names in canonical casing, reflected extras in lowercase. */ private static function preflight_allow_headers( WP_REST_Request $request ): array { // Same list core built in serve_request(), through the same // filter, so a third party that hooks it reaches both writes. $allow_headers = apply_filters( 'rest_allowed_cors_headers', self::ALLOW_HEADERS_BASE, $request ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook. $allowed_names = array_fill_keys( array_map( 'strtolower', $allow_headers ), true ); $reflected = 0; $reflected_bytes = 0; foreach ( self::announced_header_names( $request ) as $header ) { // The bare marker (`x-wcpos`) is already in the floor; reflected // extras must carry the hyphenated namespace, so `x-wcposter` // stays a stranger's header. if ( ! preg_match( self::HEADER_NAME_PATTERN, $header ) || 0 !== strpos( $header, self::MARKER_HEADER_PREFIX . '-' ) || isset( $allowed_names[ $header ] ) ) { continue; } if ( self::REFLECT_MAX_NAMES <= $reflected ) { break; } if ( self::REFLECT_MAX_BYTES < $reflected_bytes + strlen( $header ) ) { continue; } $allow_headers[] = $header; $allowed_names[ $header ] = true; ++$reflected; $reflected_bytes += strlen( $header ); } return $allow_headers; } /** * Whether this request is destined for WCPOS. * * Ours is: a WCPOS-namespace route (marked or not — the relay consent * route is deliberately unmarked), a marked request whatever the route * (the POS reads `wc/v3` collections too), or a preflight that ANNOUNCES * one of our headers. * * Note what is NOT here: a bare OPTIONS. The old handler claimed every * preflight on the site, which was harmless only because it ran at * priority 5 and core overwrote it at 10. At priority 20 we win, and * stamping `Access-Control-Allow-Origin: *` on another plugin's route * would break credentialed cross-origin requests that have nothing to do * with WCPOS (core pairs its origin-specific answer with * `Access-Control-Allow-Credentials: true`, which `*` invalidates). * Preflights we do not claim keep core's answer, and they still carry the * full WCPOS allow-list: core builds that one through * `rest_allowed_cors_headers`, which {@see self::allowed_cors_headers()} * filters unconditionally. * * @param WP_REST_Request $request Request used to generate the response. * * @return bool */ private static function owns_request( WP_REST_Request $request ): bool { if ( preg_match( self::ROUTE_PATTERN, (string) $request->get_route() ) ) { return true; } if ( ! empty( $request->get_header( 'X-' . SHORT_NAME ) ) ) { return true; } // The query-var marker, for clients behind a proxy that strips custom // request headers ({@see wcpos_request()}). $query_params = $request->get_query_params(); if ( ! empty( $query_params[ SHORT_NAME ] ) ) { return true; } return 'OPTIONS' === $request->get_method() && self::preflight_announces_wcpos( $request ); } /** * Whether a preflight says the request it precedes will be a WCPOS one. * * The browser builds `Access-Control-Request-Headers` from the headers the * real request carries, so a marked request to a route outside our * namespaces — the shape that took the till offline in 23bcdb47 and * 118a091f — announces `x-wcpos` here and is answered as ours. * * @param WP_REST_Request $request Request used to generate the response. * * @return bool */ private static function preflight_announces_wcpos( WP_REST_Request $request ): bool { foreach ( self::announced_header_names( $request ) as $header ) { if ( 0 === strpos( $header, self::MARKER_HEADER_PREFIX ) ) { return true; } } return false; } /** * Parse the header names announced by a CORS preflight. * * @param WP_REST_Request $request Request used to generate the response. * * @return string[] Trimmed, lowercase, non-empty header names. */ private static function announced_header_names( WP_REST_Request $request ): array { return array_values( array_filter( array_map( 'trim', explode( ',', strtolower( (string) $request->get_header( 'Access-Control-Request-Headers' ) ) ) ), static function ( string $header ): bool { return '' !== $header; } ) ); } /** * Defeat shared caching of WCPOS REST responses. * * Hosting layers cache authenticated REST GETs and replay them across * users (LiteSpeed caches REST by default for 7 days with no * Authorization bypass; WP Engine's edge cache excludes /wp-json/wc but * not /wp-json/wcpos; Sucuri's default levels ignore Cache-Control). * * Vary is defense-in-depth for intermediaries that ignore no-store but * honor Vary ({@see self::VARY_TOKENS}). Existing Vary tokens are * preserved (deduped case-insensitively); a wildcard Vary stays alone, * since '*' is grammatically an alternative to a field list. Preflights * also vary on their announced headers because the answer depends on them * (RFC 9111 section 4.1). * * @param WP_HTTP_Response $result Result to send to the client. * @param WP_REST_Server $server Server instance. * @param string[] $extra_vary_tokens Additional Vary tokens. */ private static function send_cache_defeating_headers( WP_HTTP_Response $result, WP_REST_Server $server, array $extra_vary_tokens = array() ): void { $response_headers = array_change_key_case( $result->get_headers(), CASE_LOWER ); $existing_vary = isset( $response_headers['vary'] ) ? array_values( array_filter( array_map( 'trim', explode( ',', (string) $response_headers['vary'] ) ), static function ( string $token ): bool { return '' !== $token; } ) ) : array(); if ( in_array( '*', $existing_vary, true ) ) { $vary = '*'; } else { $vary_tokens = array_merge( $existing_vary, self::VARY_TOKENS, $extra_vary_tokens ); $vary_tokens = array_change_key_case( array_combine( $vary_tokens, $vary_tokens ), CASE_LOWER ); $vary = implode( ', ', $vary_tokens ); } $server->send_header( 'Cache-Control', 'private, no-store' ); $server->send_header( 'Vary', $vary ); do_action( 'litespeed_control_set_nocache', 'wcpos rest response' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook } }