PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-query-guard.php

class-query-guard.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/seo/class-query-guard.php

305 lines 9.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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