get_route() ) ) { self::send_cache_defeating_headers( $result, $server ); } if ( ! self::owns_request( $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() ) { // 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. $server->send_header( 'Access-Control-Allow-Methods', 'OPTIONS, GET, POST, PUT, PATCH, DELETE' ); $server->send_header( 'Access-Control-Allow-Headers', implode( ', ', array_unique( $allow_headers ) ) ); $server->send_header( 'Access-Control-Max-Age', self::MAX_AGE ); } return $served; } /** * 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 { $announced = (string) $request->get_header( 'Access-Control-Request-Headers' ); if ( '' === $announced ) { return false; } foreach ( explode( ',', strtolower( $announced ) ) as $header ) { if ( 0 === strpos( trim( $header ), self::MARKER_HEADER_PREFIX ) ) { return true; } } return false; } /** * 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. * * @param WP_HTTP_Response $result Result to send to the client. * @param WP_REST_Server $server Server instance. */ private static function send_cache_defeating_headers( WP_HTTP_Response $result, WP_REST_Server $server ): 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 ); $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 } }