From 8d509ef43f4b4c828532adcf55764fe3cbfe1e6c Mon Sep 17 00:00:00 2001 From: Christopher Ross Date: Fri, 29 May 2026 18:28:52 +0000 Subject: [PATCH] Fix: parse Accept header q-values in wpsc_get_accept_header() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation used a naive str_contains() check: if any known JSON media type appeared anywhere in the Accept header string, the request was classified as application/json. This ignored RFC 7231 §5.3.2 quality values (q=), so a valid HTML-preferring header such as the one sent by New Relic Synthetics — Accept: text/html,application/xhtml+xml,application/json;q=0.9,... — was misclassified as a JSON request. Two downstream effects: 1. wp_cache_serve_cache_file() refused to serve the cached file. 2. A separate application/json cache bucket was populated on every synthetic check, triggering a fresh page build each time. This commit extracts a new wpsc_parse_accept_header() helper that parses q-values and classifies the request as application/json only when a known JSON type has a strictly higher q-value than text/html. Ties resolve to text/html (safe default: serve the cached page). The wpsc_accept_headers filter continues to work — filtered types participate in the q-value comparison as JSON types. Fixes #1045 --- tests/php/WpscParseAcceptHeaderTest.php | 118 ++++++++++++++++++++++++ wp-cache-phase2.php | 72 +++++++++++++-- 2 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 tests/php/WpscParseAcceptHeaderTest.php diff --git a/tests/php/WpscParseAcceptHeaderTest.php b/tests/php/WpscParseAcceptHeaderTest.php new file mode 100644 index 00000000..d59ed6fb --- /dev/null +++ b/tests/php/WpscParseAcceptHeaderTest.php @@ -0,0 +1,118 @@ +assertSame( 'text/html', wpsc_parse_accept_header( $accept, $this->json_types ) ); + } + + /** Bare JSON-only Accept: application/json should classify as JSON. */ + public function test_bare_json_classifies_as_json(): void { + $this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/json', $this->json_types ) ); + } + + /** Tie (both implicit q=1.0) resolves to text/html (safe default). */ + public function test_tie_resolves_to_html(): void { + $this->assertSame( 'text/html', wpsc_parse_accept_header( 'text/html,application/json', $this->json_types ) ); + } + + /** JSON strictly higher q than text/html must classify as JSON. */ + public function test_json_higher_q_classifies_as_json(): void { + $this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/json,text/html;q=0.9', $this->json_types ) ); + } + + /** Extended JSON type (application/ld+json) via filter participates in comparison. */ + public function test_extended_json_type_participates(): void { + $this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/ld+json;q=1.0,text/html;q=0.8', $this->json_types ) ); + } + + /** Malformed q-value (non-numeric) treated as q=1.0, no warning/fatal. */ + public function test_malformed_q_value_treated_as_default(): void { + // application/json;q=bad → treated as q=1.0; text/html is absent → effective html_q from */*=0.7. + $this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/json;q=bad,*/*;q=0.7', $this->json_types ) ); + } + + /** Out-of-range q-value clamped — q=2 treated as 1.0. */ + public function test_out_of_range_q_clamped(): void { + $this->assertSame( 'text/html', wpsc_parse_accept_header( 'text/html;q=2,application/json;q=0.9', $this->json_types ) ); + } + + // ── Wildcard behaviour ──────────────────────────────────────────────────── + + // Wildcard (*/*) covers text/html when text/html is not explicitly listed. + public function test_wildcard_covers_html_when_not_explicit(): void { + // */* q=1.0 > json q=0.9 → text/html. + $this->assertSame( 'text/html', wpsc_parse_accept_header( '*/*,application/json;q=0.9', $this->json_types ) ); + } + + // Explicit text/html;q=0.5 wins over */*;q=1.0 — wildcard does not boost an explicit html q. + public function test_explicit_html_takes_precedence_over_wildcard(): void { + // html explicit q=0.5; wildcard q=1.0; json q=0.8 → 0.8 > 0.5 → application/json. + $this->assertSame( 'application/json', wpsc_parse_accept_header( 'text/html;q=0.5,*/*;q=1.0,application/json;q=0.8', $this->json_types ) ); + } + + // No text/html and no wildcard, but JSON present — effective html_q = 0.0 → application/json. + public function test_no_html_no_wildcard_json_present(): void { + $this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/json;q=0.5', $this->json_types ) ); + } + + // ── Edge cases ──────────────────────────────────────────────────────────── + + /** Whitespace around media types is handled. */ + public function test_whitespace_around_media_types(): void { + $this->assertSame( 'text/html', wpsc_parse_accept_header( ' text/html , application/json;q=0.9 ', $this->json_types ) ); + } + + /** application/activity+json classified as JSON. */ + public function test_activity_json_classified_as_json(): void { + $this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/activity+json', $this->json_types ) ); + } + + /** Custom type added via wpsc_accept_headers filter participates. */ + public function test_custom_json_type_via_filter_participates(): void { + $extended = array_merge( $this->json_types, array( 'application/vnd.api+json' ) ); + $this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/vnd.api+json;q=1.0,text/html;q=0.8', $extended ) ); + } + + /** Typical browser Accept header classifies as text/html. */ + public function test_typical_browser_accept_header(): void { + $accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'; + $this->assertSame( 'text/html', wpsc_parse_accept_header( $accept, $this->json_types ) ); + } +} diff --git a/wp-cache-phase2.php b/wp-cache-phase2.php index 8d1d995b..2fe140f2 100644 --- a/wp-cache-phase2.php +++ b/wp-cache-phase2.php @@ -537,23 +537,77 @@ function wpsc_get_accept_header() { $accept_headers = apply_filters( 'wpsc_accept_headers', array( 'application/json', 'application/activity+json', 'application/ld+json' ) ); $accept_headers = array_map( 'strtolower', $accept_headers ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $accept is checked and set below. - $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( filter_var( $_SERVER['HTTP_ACCEPT'] ) ) : ''; + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $raw is checked and set below. + $raw = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( filter_var( $_SERVER['HTTP_ACCEPT'] ) ) : ''; - foreach ( $accept_headers as $header ) { - if ( str_contains( $accept, $header ) ) { - $accept = 'application/json'; + $accept = empty( $raw ) ? 'text/html' : wpsc_parse_accept_header( $raw, $accept_headers ); + + wp_cache_debug( 'ACCEPT: ' . $accept ); + } + + return $accept; +} + +/** + * Classify an Accept header as 'text/html' or 'application/json'. + * + * Parses RFC 7231 §5.3.2 quality values (q=) so that a request where + * text/html has a higher or equal q-value than any known JSON type is + * correctly served from cache. The previous str_contains() approach treated + * any mention of a JSON media type as a JSON request, regardless of priority. + * + * Classification rules: + * - Classify as 'application/json' only when a known JSON type has a + * strictly higher q-value than text/html. + * - Ties, or text/html strictly higher, resolve to 'text/html'. + * - If text/html is absent, its effective q-value is taken from *\/* (if + * present). If neither is present, the effective html q-value is 0.0. + * - Malformed q-values (non-numeric, out-of-range) are treated as q=1.0. + * + * @param string $raw_accept Lowercased, non-empty Accept header value. + * @param string[] $json_types Media types to treat as JSON (from wpsc_accept_headers filter). + * @return string 'text/html' or 'application/json'. + */ +function wpsc_parse_accept_header( $raw_accept, $json_types ) { + $html_q = null; // null = not explicitly present in Accept header. + $wildcard_q = null; + $json_q = 0.0; + + foreach ( explode( ',', $raw_accept ) as $part ) { + $segments = explode( ';', trim( $part ) ); + $media_type = trim( $segments[0] ); + $q = 1.0; // Default q-value per RFC 7231 §5.3.1. + + for ( $i = 1, $len = count( $segments ); $i < $len; $i++ ) { + $param = ltrim( $segments[ $i ] ); + if ( strncmp( $param, 'q=', 2 ) === 0 ) { + $q_str = substr( $param, 2 ); + $q = is_numeric( $q_str ) ? max( 0.0, min( 1.0, (float) $q_str ) ) : 1.0; + break; } } - if ( $accept !== 'application/json' ) { - $accept = 'text/html'; + if ( 'text/html' === $media_type ) { + $html_q = null === $html_q ? $q : max( $html_q, $q ); + } elseif ( '*/*' === $media_type ) { + $wildcard_q = null === $wildcard_q ? $q : max( $wildcard_q, $q ); } - wp_cache_debug( 'ACCEPT: ' . $accept ); + if ( in_array( $media_type, $json_types, true ) ) { + $json_q = max( $json_q, $q ); + } } - return $accept; + // Effective html q: explicit text/html wins; fall back to */* if absent. + if ( null !== $html_q ) { + $effective_html_q = $html_q; + } elseif ( null !== $wildcard_q ) { + $effective_html_q = $wildcard_q; + } else { + $effective_html_q = 0.0; + } + + return $json_q > $effective_html_q ? 'application/json' : 'text/html'; } function wp_cache_get_cookies_values() {