| 1 |
<?php |
| 2 |
/** |
| 3 |
* Refuses to serve a page for a query variable that resolved to nothing. |
| 4 |
* |
| 5 |
* @package ThinkRank |
| 6 |
* @since 2.7.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
declare(strict_types=1); |
| 10 |
|
| 11 |
namespace ThinkRank\SEO; |
| 12 |
|
| 13 |
if (!defined('ABSPATH')) { |
| 14 |
exit; |
| 15 |
} |
| 16 |
|
| 17 |
/** |
| 18 |
* Turns a fabricated query variable into a 404 instead of an indexable page. |
| 19 |
* |
| 20 |
* Anyone can append a content selector to a URL. WordPress 404s most of them on |
| 21 |
* its own, but a handful slip through and answer with the blog listing at HTTP |
| 22 |
* 200, indexable, with unbounded variations available to anyone who wants to |
| 23 |
* mint them. Measured on a stock install, these six do: |
| 24 |
* |
| 25 |
* /?post_type=<junk> /?taxonomy=<junk> /?tag_id=<junk> |
| 26 |
* /?cpage=<junk> /?static=<junk> /?author_name=<junk> |
| 27 |
* |
| 28 |
* The canonical tag already points each of them at the clean home URL, so the |
| 29 |
* duplicate-content signal was covered. What it does not cover is that every one |
| 30 |
* of them is a real 200 a crawler has to fetch, and there is no limit to how |
| 31 |
* many a hostile actor can produce. |
| 32 |
* |
| 33 |
* **The detection deliberately does not test `is_home()`.** That reads |
| 34 |
* differently depending on whether the site shows posts or a static page on the |
| 35 |
* front, and a guard whose behaviour depends on that setting would be right on |
| 36 |
* half of all installs. It tests the thing that is actually wrong instead: a |
| 37 |
* request asked for specific content by name, and nothing resolved. |
| 38 |
* |
| 39 |
* Every condition below is an exclusion that was verified against a live |
| 40 |
* install rather than assumed. The one that does most of the work is |
| 41 |
* {@see self::EXPLOITABLE}: an unregistered parameter — `?utm_source=`, |
| 42 |
* `?fbclid=`, a tracking tag, anything a campaign or a plugin appends — is not |
| 43 |
* on that list and can never trip this, which is what keeps a marketing URL |
| 44 |
* from turning into a 404. |
| 45 |
* |
| 46 |
* @since 2.7.0 |
| 47 |
*/ |
| 48 |
class Query_Guard { |
| 49 |
|
| 50 |
/** |
| 51 |
* Query variables that name specific content. |
| 52 |
* |
| 53 |
* If one of these is in the request and nothing resolved, the request asked |
| 54 |
* for something that does not exist. |
| 55 |
* |
| 56 |
* Notable absences, all deliberate, all legitimate on a listing page: |
| 57 |
* `s` and `sentence` (a search that found nothing is still a search), |
| 58 |
* `paged` and `page` (pagination), `orderby` and `order` (WooCommerce sorts |
| 59 |
* with these constantly), `preview*` (an unsaved draft resolves to nothing |
| 60 |
* by definition), and `embed`. |
| 61 |
* |
| 62 |
* @since 2.7.0 |
| 63 |
* @var string[] |
| 64 |
*/ |
| 65 |
private const EXPLOITABLE = [ |
| 66 |
'attachment', |
| 67 |
'attachment_id', |
| 68 |
'author', |
| 69 |
'author_name', |
| 70 |
'cat', |
| 71 |
'category_name', |
| 72 |
'cpage', |
| 73 |
'day', |
| 74 |
'hour', |
| 75 |
'm', |
| 76 |
'minute', |
| 77 |
'monthnum', |
| 78 |
'name', |
| 79 |
'p', |
| 80 |
'page_id', |
| 81 |
'pagename', |
| 82 |
'post_format', |
| 83 |
'post_type', |
| 84 |
'second', |
| 85 |
'static', |
| 86 |
'subpost', |
| 87 |
'subpost_id', |
| 88 |
'tag', |
| 89 |
'tag_id', |
| 90 |
'tag_slug__and', |
| 91 |
'tag_slug__in', |
| 92 |
'taxonomy', |
| 93 |
'term', |
| 94 |
'w', |
| 95 |
'year', |
| 96 |
]; |
| 97 |
|
| 98 |
/** |
| 99 |
* Register the guard. |
| 100 |
* |
| 101 |
* @since 2.7.0 |
| 102 |
* @return void |
| 103 |
*/ |
| 104 |
public function init(): void { |
| 105 |
// After the query has run and before the template is chosen, which is |
| 106 |
// the only point where set_404() still changes what renders. |
| 107 |
add_action('template_redirect', [$this, 'maybe_block'], 2); |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Turn an unresolved content request into a 404. |
| 112 |
* |
| 113 |
* @since 2.7.0 |
| 114 |
* @return void |
| 115 |
*/ |
| 116 |
public function maybe_block(): void { |
| 117 |
if (!$this->should_block()) { |
| 118 |
return; |
| 119 |
} |
| 120 |
|
| 121 |
global $wp_query; |
| 122 |
|
| 123 |
$wp_query->set_404(); |
| 124 |
status_header(404); |
| 125 |
nocache_headers(); |
| 126 |
|
| 127 |
// So the reason is visible in the page a site owner is looking at. |
| 128 |
// Support otherwise has to guess why a URL they can see in a browser is |
| 129 |
// answering 404, and the answer is not in any log. |
| 130 |
add_action( |
| 131 |
'wp_head', |
| 132 |
static function (): void { |
| 133 |
echo "<!-- ThinkRank: query protection, no content resolved for this request -->\n"; |
| 134 |
}, |
| 135 |
0 |
| 136 |
); |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* Whether this request asked for content that does not exist. |
| 141 |
* |
| 142 |
* @since 2.7.0 |
| 143 |
* @return bool |
| 144 |
*/ |
| 145 |
private function should_block(): bool { |
| 146 |
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) { |
| 147 |
return false; |
| 148 |
} |
| 149 |
|
| 150 |
if (defined('REST_REQUEST') && REST_REQUEST) { |
| 151 |
return false; |
| 152 |
} |
| 153 |
|
| 154 |
if (!is_main_query()) { |
| 155 |
return false; |
| 156 |
} |
| 157 |
|
| 158 |
$settings = (new Site_Identity_Manager())->get_settings('site', null); |
| 159 |
|
| 160 |
if (empty($settings['query_protection'])) { |
| 161 |
return false; |
| 162 |
} |
| 163 |
|
| 164 |
// Core already answered correctly, or this is a context that has no |
| 165 |
// queried object by design. |
| 166 |
if (is_404() || is_search() || is_preview() || is_feed() || is_robots() || is_trackback()) { |
| 167 |
return false; |
| 168 |
} |
| 169 |
|
| 170 |
// A date archive resolves no object and is perfectly valid. Core 404s a |
| 171 |
// date that cannot exist, so anything reaching here is a real one. |
| 172 |
if (is_date()) { |
| 173 |
return false; |
| 174 |
} |
| 175 |
|
| 176 |
// Something real resolved: a post, a page, a term, a post type, an |
| 177 |
// author. Nothing to protect against. |
| 178 |
if (null !== get_queried_object()) { |
| 179 |
return false; |
| 180 |
} |
| 181 |
|
| 182 |
$found = $this->exploitable_vars_present(); |
| 183 |
|
| 184 |
if ([] === $found) { |
| 185 |
return false; |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* Filter whether a request is treated as an unresolved content request. |
| 190 |
* |
| 191 |
* The escape hatch for a setup that registers a public query variable |
| 192 |
* deliberately — some page builders and tracking integrations do — and |
| 193 |
* serves a real page for it. |
| 194 |
* |
| 195 |
* @since 2.7.0 |
| 196 |
* |
| 197 |
* @param bool $block Whether to answer 404. |
| 198 |
* @param string[] $found The exploitable query variables in the request. |
| 199 |
*/ |
| 200 |
return (bool) apply_filters('thinkrank_block_unresolved_query', true, $found); |
| 201 |
} |
| 202 |
|
| 203 |
/** |
| 204 |
* Whether a selector's value names something this site really has. |
| 205 |
* |
| 206 |
* Most selectors need no check: a category or an author that exists |
| 207 |
* resolves a queried object, so the guard never gets this far. Two do not |
| 208 |
* behave that way, and both were false positives before this existed: |
| 209 |
* |
| 210 |
* - `?post_type=post` is a legitimate way to ask for the blog listing, |
| 211 |
* and `post` registers no archive, so nothing resolves and the request |
| 212 |
* looked identical to `?post_type=nosuchtype`. Every public post type |
| 213 |
* without an archive had the same problem. |
| 214 |
* - `?taxonomy=category` without a `term` is a real taxonomy naming no |
| 215 |
* term. |
| 216 |
* |
| 217 |
* A value naming a real, public thing is WordPress's business, whatever it |
| 218 |
* chooses to render for it. Only a value naming nothing is this guard's. |
| 219 |
* |
| 220 |
* @since 2.7.0 |
| 221 |
* |
| 222 |
* @param string $key Query variable name. |
| 223 |
* @param mixed $value Its value. |
| 224 |
* @return bool True when the value names something registered. |
| 225 |
*/ |
| 226 |
public static function names_something_real(string $key, $value): bool { |
| 227 |
if (!is_string($value) || '' === $value) { |
| 228 |
return false; |
| 229 |
} |
| 230 |
|
| 231 |
if ('post_type' === $key) { |
| 232 |
return in_array($value, get_post_types(['public' => true]), true); |
| 233 |
} |
| 234 |
|
| 235 |
if ('taxonomy' === $key) { |
| 236 |
return in_array($value, get_taxonomies(['public' => true]), true); |
| 237 |
} |
| 238 |
|
| 239 |
return false; |
| 240 |
} |
| 241 |
|
| 242 |
/** |
| 243 |
* Content selectors present in this request. |
| 244 |
* |
| 245 |
* Read from the request rather than from the parsed query variables: |
| 246 |
* WordPress drops an invalid `post_type` before it reaches `$wp->query_vars`, |
| 247 |
* so by then the evidence that the request asked for one is gone. That is |
| 248 |
* the single most exploitable case of the six, and reading the parsed vars |
| 249 |
* would miss it entirely. |
| 250 |
* |
| 251 |
* @since 2.7.0 |
| 252 |
* @return string[] |
| 253 |
*/ |
| 254 |
private function exploitable_vars_present(): array { |
| 255 |
global $wp; |
| 256 |
|
| 257 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only inspection of a public front-end URL; nothing is acted on or stored. |
| 258 |
$get = (array) $_GET; |
| 259 |
|
| 260 |
return self::exploitable_in( |
| 261 |
$get, |
| 262 |
$wp instanceof \WP ? (array) $wp->query_vars : [] |
| 263 |
); |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* The decision itself, as a function of its inputs. |
| 268 |
* |
| 269 |
* Separated from the globals so the part that decides which variables count |
| 270 |
* can be exercised directly. That is the part with the risk in it: the first |
| 271 |
* version of this list turned `?post_type=post` into a 404, because `post` |
| 272 |
* registers no archive and so looked exactly like a fabricated type. |
| 273 |
* |
| 274 |
* @since 2.7.0 |
| 275 |
* |
| 276 |
* @param array $get Request parameters. |
| 277 |
* @param array $query_vars Parsed query variables, for a pretty permalink |
| 278 |
* that puts the same selectors in the rewrite |
| 279 |
* result rather than the query string. |
| 280 |
* @return string[] The selectors that name nothing real. |
| 281 |
*/ |
| 282 |
public static function exploitable_in(array $get, array $query_vars = []): array { |
| 283 |
$requested = []; |
| 284 |
|
| 285 |
foreach ($get as $key => $value) { |
| 286 |
$key = (string) $key; |
| 287 |
|
| 288 |
if (in_array($key, self::EXPLOITABLE, true) && !self::names_something_real($key, $value)) { |
| 289 |
$requested[] = $key; |
| 290 |
} |
| 291 |
} |
| 292 |
|
| 293 |
foreach (self::EXPLOITABLE as $key) { |
| 294 |
if (isset($query_vars[$key]) |
| 295 |
&& '' !== $query_vars[$key] |
| 296 |
&& !in_array($key, $requested, true) |
| 297 |
&& !self::names_something_real($key, $query_vars[$key])) { |
| 298 |
$requested[] = $key; |
| 299 |
} |
| 300 |
} |
| 301 |
|
| 302 |
return $requested; |
| 303 |
} |
| 304 |
} |
| 305 |
|