From f1c3740d0a13f5965bc5d02ae0d46f56c8a661e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 09:16:33 +0000 Subject: [PATCH] Only accept public, registered taxonomies and post types from the query string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre_get_posts_transpose_query_vars() mapped `query-*` GET params onto the loop's WP_Query without checking what was named. Any registered taxonomy was accepted by get_taxonomy(), and post_type values were passed through wp_parse_list() with no validation at all. That let a visitor point a public query loop at a private post type by URL — `?query-1-post_type=some_private_cpt` — and read the titles and excerpts of records never meant to be listed on the front end. Private taxonomies were the same story in miniature: a filter that turns the loop into an oracle for internal groupings. Gate both on the canonical viewability checks: is_taxonomy_viewable() and is_post_type_viewable(). Non-viewable and unregistered names are dropped; if a post_type list ends up empty, the query keeps its own post type rather than being set to nothing. Also skip non-scalar GET values. `?query-1-post_type[]=x` previously reached urldecode() as an array, which is an uncaught TypeError on PHP 8 — an unauthenticated 500 on any page carrying a filter block. The render partials now apply the same test, so a control is never drawn for a taxonomy or post type whose filtering the server will discard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015J9z6XLV1hT1FB1BSXPczo --- inc/namespace.php | 122 ++++++++++++++++++++++++--------------- src/post-type/render.php | 5 +- src/taxonomy/render.php | 11 +++- 3 files changed, 89 insertions(+), 49 deletions(-) diff --git a/inc/namespace.php b/inc/namespace.php index 20f00e9..941c1f1 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -99,55 +99,85 @@ function pre_get_posts_transpose_query_vars( WP_Query $query ) : void { // Map get params to this query. foreach ( $_GET as $key => $value ) { - if ( strpos( $key, $prefix ) === 0 ) { - $key = str_replace( $prefix, '', $key ); - $value = sanitize_text_field( urldecode( wp_unslash( $value ) ) ); - - // Handle taxonomies specifically. - if ( get_taxonomy( $key ) ) { - // If multiple taxonomy filters are selected, ALL of them must match. - $tax_query['relation'] = 'AND'; - - // Handle multiple values separated by commas (for checkbox mode) - $values = wp_parse_list( $value ); - - if ( count( $values ) > 1 ) { - // If multiple terms in a taxonomy are selected, posts with - // ANY of the selected terms should be returned. - $tax_query[] = [ - 'taxonomy' => $key, - 'terms' => $values, - 'field' => 'slug', - 'operator' => 'IN', - ]; - } else { - // Single value: normal behavior - $tax_query[] = [ - 'taxonomy' => $key, - 'terms' => $values, - 'field' => 'slug', - ]; - } + if ( strpos( $key, $prefix ) !== 0 ) { + continue; + } + + $key = str_replace( $prefix, '', $key ); + + // Only scalar values are ever produced by the filter blocks. Array + // values (`?query-post_type[]=x`) would sanitize to an empty string. + if ( ! is_scalar( $value ) ) { + continue; + } + + $value = sanitize_text_field( urldecode( wp_unslash( $value ) ) ); + + // Handle taxonomies specifically. + if ( taxonomy_exists( $key ) ) { + // A visitor can name any registered taxonomy here, including ones + // registered privately for internal bookkeeping. Filtering by those + // turns the front end into an oracle for private groupings, so only + // honour taxonomies that are publicly queryable in the first place. + if ( ! is_taxonomy_viewable( $key ) ) { + continue; + } + + // If multiple taxonomy filters are selected, ALL of them must match. + $tax_query['relation'] = 'AND'; + + // Handle multiple values separated by commas (for checkbox mode) + $values = wp_parse_list( $value ); + + if ( count( $values ) > 1 ) { + // If multiple terms in a taxonomy are selected, posts with + // ANY of the selected terms should be returned. + $tax_query[] = [ + 'taxonomy' => $key, + 'terms' => $values, + 'field' => 'slug', + 'operator' => 'IN', + ]; } else { - // Other options should map directly to query vars. - $key = sanitize_key( $key ); - - if ( ! in_array( $key, array_keys( $valid_keys ), true ) ) { - continue; - } - - // post_type accepts multiple comma-separated values in checkbox mode. - // Parse as list so WP_Query returns results from any selected post_type. - if ( $key === 'post_type' ) { - $value = wp_parse_list( $value ); - } - - $query->set( - $key, - $value - ); + // Single value: normal behavior + $tax_query[] = [ + 'taxonomy' => $key, + 'terms' => $values, + 'field' => 'slug', + ]; + } + + continue; + } + + // Other options should map directly to query vars. + $key = sanitize_key( $key ); + + if ( ! in_array( $key, array_keys( $valid_keys ), true ) ) { + continue; + } + + // post_type accepts multiple comma-separated values in checkbox mode. + // Parse as list so WP_Query returns results from any selected post_type. + if ( $key === 'post_type' ) { + // Same reasoning as taxonomies, with sharper teeth: an unfiltered + // post_type lets a visitor swap the loop onto any registered post + // type, including private ones holding unpublished editorial or + // plugin data, and read their titles and excerpts straight out of + // the loop. Keep only post types that are publicly queryable. + $value = array_values( array_filter( wp_parse_list( $value ), 'is_post_type_viewable' ) ); + + // Everything requested was unknown or non-public. Leave the query's + // own post type in place rather than setting an empty one. + if ( empty( $value ) ) { + continue; } } + + $query->set( + $key, + $value + ); } if ( ! empty( $tax_query ) ) { diff --git a/src/post-type/render.php b/src/post-type/render.php index 1493d09..95cc5d1 100644 --- a/src/post-type/render.php +++ b/src/post-type/render.php @@ -40,7 +40,10 @@ } } -$post_types = array_unique( $post_types ); +// Only offer post types that are publicly queryable. The block context is +// authored, but a private post type named there would render a filter the +// server discards, and an unregistered one has no object to read a label from. +$post_types = array_filter( array_unique( $post_types ), 'is_post_type_viewable' ); $post_types = array_map( 'get_post_type_object', $post_types ); if ( empty( $post_types ) ) { diff --git a/src/taxonomy/render.php b/src/taxonomy/render.php index c9afc25..0efb602 100644 --- a/src/taxonomy/render.php +++ b/src/taxonomy/render.php @@ -8,12 +8,19 @@ return; } +$taxonomy = get_taxonomy( $attributes['taxonomy'] ); + +// The saved taxonomy may since have been unregistered or made private. The +// query string filter is discarded server side in either case, so rendering +// the control would only offer a filter that silently does nothing. +if ( ! $taxonomy || ! is_taxonomy_viewable( $taxonomy ) ) { + return; +} + $id = 'query-filter-' . wp_generate_uuid4(); $display_type = $attributes['displayType'] ?? 'select'; $layout_direction = $attributes['layoutDirection'] ?? 'vertical'; -$taxonomy = get_taxonomy( $attributes['taxonomy'] ); - if ( empty( $block->context['query']['inherit'] ) ) { $query_id = $block->context['queryId'] ?? 0; $query_var = sprintf( 'query-%d-%s', $query_id, $attributes['taxonomy'] );