PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 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 All 163 releases
← All changes | includes/Rest_Cors.php +120 -20 1.10.01.10.18 View file →
@@ -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