| @@ -141,9 +141,13 @@ | ||
| 141 | 141 | * The lanes the cache contract covers: the two shipped namespaces. |
| 142 | 142 | * |
| 143 | 143 | * Deliberately narrower than ROUTE_PATTERN — a namespace added through |
| 144 | 144 | * `woocommerce_pos_rest_namespaces` publishes its own response semantics, |
| 145 | - * and this guard has never covered it. | |
| 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. | |
| 146 | 150 | */ |
| 147 | 151 | private const CACHE_ROUTE_PATTERN = '#^/wcpos/v[12](?:/|$)#i'; |
| 148 | 152 | |
| 149 | 153 | /** |
| @@ -156,9 +160,30 @@ | ||
| 156 | 160 | * having to guess from the route alone. |
| 157 | 161 | */ |
| 158 | 162 | private const MARKER_HEADER_PREFIX = 'x-wcpos'; |
| 159 | 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 | + | |
| 160 | 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 | + /** | |
| 161 | 186 | * Register the wire contract. Unconditional — see the class docblock. |
| 162 | 187 | */ |
| 163 | 188 | public static function register_hooks(): void { |
| 164 | 189 | add_filter( 'rest_allowed_cors_headers', array( self::class, 'allowed_cors_headers' ), 10, 1 ); |
| @@ -192,13 +217,16 @@ | ||
| 192 | 217 | * |
| 193 | 218 | * @return bool $served |
| 194 | 219 | */ |
| 195 | 220 | 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() ) ) { | |
| 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() ) ) { | |
| 197 | 225 | self::send_cache_defeating_headers( $result, $server ); |
| 198 | 226 | } |
| 199 | 227 | |
| 200 | - if ( ! self::owns_request( $request ) ) { | |
| 228 | + if ( ! $owns_request ) { | |
| 201 | 229 | return $served; |
| 202 | 230 | } |
| 203 | 231 | |
| 204 | 232 | // Core's own filter, re-applied here because this write replaces the |
| @@ -208,14 +236,10 @@ | ||
| 208 | 236 | $server->send_header( 'Access-Control-Allow-Origin', '*' ); |
| 209 | 237 | $server->send_header( 'Access-Control-Expose-Headers', implode( ', ', array_unique( $expose_headers ) ) ); |
| 210 | 238 | |
| 211 | 239 | 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 | 240 | $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 ) ) ); | |
| 241 | + $server->send_header( 'Access-Control-Allow-Headers', implode( ', ', array_unique( self::preflight_allow_headers( $request ) ) ) ); | |
| 218 | 242 | $server->send_header( 'Access-Control-Max-Age', self::MAX_AGE ); |
| 219 | 243 | } |
| 220 | 244 | |
| 221 | 245 | return $served; |
| @@ -221,8 +245,68 @@ | ||
| 221 | 245 | return $served; |
| 222 | 246 | } |
| 223 | 247 | |
| 224 | 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 | + /** | |
| 225 | 309 | * Whether this request is destined for WCPOS. |
| 226 | 310 | * |
| 227 | 311 | * Ours is: a WCPOS-namespace route (marked or not — the relay consent |
| 228 | 312 | * route is deliberately unmarked), a marked request whatever the route |
| @@ -276,15 +360,10 @@ | ||
| 276 | 360 | * |
| 277 | 361 | * @return bool |
| 278 | 362 | */ |
| 279 | 363 | 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 ) ) { | |
| 364 | + foreach ( self::announced_header_names( $request ) as $header ) { | |
| 365 | + if ( 0 === strpos( $header, self::MARKER_HEADER_PREFIX ) ) { | |
| 287 | 366 | return true; |
| 288 | 367 | } |
| 289 | 368 | } |
| 290 | 369 | |
| @@ -291,8 +370,26 @@ | ||
| 291 | 370 | return false; |
| 292 | 371 | } |
| 293 | 372 | |
| 294 | 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 | + /** | |
| 295 | 392 | * Defeat shared caching of WCPOS REST responses. |
| 296 | 393 | * |
| 297 | 394 | * Hosting layers cache authenticated REST GETs and replay them across |
| 298 | 395 | * users (LiteSpeed caches REST by default for 7 days with no |
| @@ -301,14 +398,17 @@ | ||
| 301 | 398 | * |
| 302 | 399 | * Vary is defense-in-depth for intermediaries that ignore no-store but |
| 303 | 400 | * honor Vary ({@see self::VARY_TOKENS}). Existing Vary tokens are |
| 304 | 401 | * preserved (deduped case-insensitively); a wildcard Vary stays alone, |
| 305 | - * since '*' is grammatically an alternative to a field list. | |
| 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). | |
| 306 | 405 | * |
| 307 | - * @param WP_HTTP_Response $result Result to send to the client. | |
| 308 | - * @param WP_REST_Server $server Server instance. | |
| 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. | |
| 309 | 409 | */ |
| 310 | - private static function send_cache_defeating_headers( WP_HTTP_Response $result, WP_REST_Server $server ): void { | |
| 410 | + private static function send_cache_defeating_headers( WP_HTTP_Response $result, WP_REST_Server $server, array $extra_vary_tokens = array() ): void { | |
| 311 | 411 | $response_headers = array_change_key_case( $result->get_headers(), CASE_LOWER ); |
| 312 | 412 | $existing_vary = isset( $response_headers['vary'] ) |
| 313 | 413 | ? array_values( |
| 314 | 414 | array_filter( |
| @@ -322,9 +422,9 @@ | ||
| 322 | 422 | |
| 323 | 423 | if ( in_array( '*', $existing_vary, true ) ) { |
| 324 | 424 | $vary = '*'; |
| 325 | 425 | } else { |
| 326 | - $vary_tokens = array_merge( $existing_vary, self::VARY_TOKENS ); | |
| 426 | + $vary_tokens = array_merge( $existing_vary, self::VARY_TOKENS, $extra_vary_tokens ); | |
| 327 | 427 | $vary_tokens = array_change_key_case( array_combine( $vary_tokens, $vary_tokens ), CASE_LOWER ); |
| 328 | 428 | $vary = implode( ', ', $vary_tokens ); |
| 329 | 429 | } |
| 330 | 430 | |