PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.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 All 51 releases
thinkrank / includes / ai / class-endpoint-url-validator.php

class-endpoint-url-validator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/ai/class-endpoint-url-validator.php

600 lines 22.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Validator for user-supplied AI endpoint base URLs.
4 *
5 * @package ThinkRank\AI
6 * @since 2.8.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\AI;
12
13 // Prevent direct access
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 /**
19 * Endpoint URL Validator
20 *
21 * The OpenAI-compatible provider lets an administrator point ThinkRank at any
22 * host that speaks the Chat Completions API — a local Ollama box, an Azure
23 * deployment, a company gateway. That is a URL the plugin then fetches
24 * server-side with the stored API key attached, so it is exactly the shape of
25 * an SSRF: unchecked, it could be aimed at a cloud instance-metadata service
26 * and used to read credentials out of the host.
27 *
28 * The rules here are deliberately narrow:
29 *
30 * - http:// is allowed only for loopback and RFC1918/RFC4193 private addresses
31 * (that is the whole point — a model on the same machine or LAN). Every
32 * public host must use https:// so the key is not sent in clear text.
33 * - Link-local (169.254/16, fe80::/10) and the known cloud metadata hosts are
34 * refused outright, whether they are spelled as a name or an address.
35 * - Credentials in the URL are refused; the API key has its own field.
36 *
37 * This is a validation gate for what gets *stored*, not a resolver: a public
38 * hostname that happens to resolve to a private address still has to be https,
39 * and the request itself is sent with redirects disabled so the key can never
40 * follow a 302 to another host.
41 *
42 * @since 2.8.0
43 */
44 class Endpoint_URL_Validator {
45
46 /**
47 * Hostnames that front a cloud instance-metadata service.
48 *
49 * @var string[]
50 */
51 private const BLOCKED_HOSTS = [
52 'metadata.google.internal',
53 'metadata.goog',
54 'metadata',
55 'instance-data',
56 ];
57
58 /**
59 * Ceiling for a response body from an endpoint we do not control.
60 *
61 * Generous enough for the largest thing we ask for — a full content brief
62 * as JSON — and far below what it takes to hurt a PHP worker.
63 */
64 private const MAX_RESPONSE_BYTES = 2097152; // 2 MB.
65
66 /**
67 * Literal addresses of instance-metadata services outside 169.254/16.
68 *
69 * @var string[]
70 */
71 private const BLOCKED_IPS = [
72 '100.100.100.200', // Alibaba Cloud.
73 'fd00:ec2::254', // AWS IMDS over IPv6.
74 ];
75
76 /**
77 * Validate and normalise a base URL for the OpenAI-compatible provider.
78 *
79 * @since 2.8.0
80 *
81 * @param string $url Raw URL as typed by the administrator.
82 * @return string|\WP_Error Normalised URL (no trailing slash) or the reason it was refused.
83 */
84 public static function validate(string $url) {
85 $url = trim($url);
86
87 if ('' === $url) {
88 return new \WP_Error(
89 'thinkrank_endpoint_empty',
90 __('Enter the base URL of your OpenAI-compatible endpoint, for example http://localhost:11434/v1', 'thinkrank')
91 );
92 }
93
94 $parts = wp_parse_url($url);
95
96 if (!is_array($parts) || empty($parts['host'])) {
97 return new \WP_Error(
98 'thinkrank_endpoint_unparseable',
99 __('That does not look like a URL. Include the scheme and host, for example http://localhost:11434/v1', 'thinkrank')
100 );
101 }
102
103 $scheme = strtolower((string) ($parts['scheme'] ?? ''));
104
105 if (!in_array($scheme, ['http', 'https'], true)) {
106 return new \WP_Error(
107 'thinkrank_endpoint_scheme',
108 __('The endpoint URL must start with http:// or https://', 'thinkrank')
109 );
110 }
111
112 if (isset($parts['user']) || isset($parts['pass'])) {
113 return new \WP_Error(
114 'thinkrank_endpoint_credentials',
115 __('Remove the username and password from the URL. Enter the key in the API key field instead.', 'thinkrank')
116 );
117 }
118
119 $host = strtolower((string) $parts['host']);
120 // An IPv6 literal arrives wrapped in brackets.
121 $bare_host = trim($host, '[]');
122 // Judge ::ffff:a.b.c.d as the IPv4 address it reaches.
123 $check_host = self::unmap_ipv4($bare_host);
124
125 if (in_array($check_host, self::BLOCKED_HOSTS, true) || in_array($check_host, self::BLOCKED_IPS, true)) {
126 return new \WP_Error(
127 'thinkrank_endpoint_blocked',
128 __('That host is a cloud metadata service, not an AI endpoint. Refusing to send requests there.', 'thinkrank')
129 );
130 }
131
132 if (self::is_link_local($check_host)) {
133 return new \WP_Error(
134 'thinkrank_endpoint_blocked',
135 __('Link-local addresses (169.254.x.x, fe80::) front instance metadata services and are not allowed.', 'thinkrank')
136 );
137 }
138
139 if ('http' === $scheme && !self::is_local_host($check_host)) {
140 return new \WP_Error(
141 'thinkrank_endpoint_insecure',
142 sprintf(
143 /* translators: %s: the host the administrator entered. */
144 __('Use https:// for %s. Plain http:// is only allowed for localhost and private network addresses, so your API key is never sent unencrypted over the internet.', 'thinkrank'),
145 $bare_host
146 )
147 );
148 }
149
150 return self::normalize($parts);
151 }
152
153 /**
154 * Check the address a URL actually resolves to, at request time.
155 *
156 * {@see self::validate()} can only judge what was typed. A name is not an
157 * address: `alias.internal` passes the spelling rules and can still resolve
158 * to 169.254.169.254, which is the exact destination the guard exists to
159 * refuse. So every request to a custom endpoint resolves the host first and
160 * judges the addresses, not the label.
161 *
162 * The answer carries the address it approved, and
163 * {@see self::guarded_request()} pins the connection to it — otherwise a
164 * second lookup between this check and the socket (DNS rebinding) could
165 * still land somewhere else.
166 *
167 * @since 2.8.0
168 *
169 * @param string $url URL about to be requested.
170 * @return string|\WP_Error The approved IP, or the reason it was refused.
171 */
172 public static function resolve_safe_address(string $url) {
173 $parts = wp_parse_url($url);
174 if (!is_array($parts) || empty($parts['host'])) {
175 return new \WP_Error(
176 'thinkrank_endpoint_unparseable',
177 __('That does not look like a URL.', 'thinkrank')
178 );
179 }
180
181 $scheme = strtolower((string) ($parts['scheme'] ?? ''));
182 $host = trim(strtolower((string) $parts['host']), '[]');
183
184 $addresses = self::addresses_for($host);
185
186 if (empty($addresses)) {
187 return new \WP_Error(
188 'thinkrank_endpoint_unresolvable',
189 sprintf(
190 /* translators: %s: the host that could not be resolved. */
191 __('Could not resolve %s. Check the endpoint host name.', 'thinkrank'),
192 $host
193 )
194 );
195 }
196
197 // Every address has to be acceptable, not just the first: a name that
198 // answers with both a private address and a metadata address would
199 // otherwise be a coin toss.
200 foreach ($addresses as $address) {
201 $check = self::unmap_ipv4($address);
202
203 if (self::is_link_local($check) || in_array($check, self::BLOCKED_IPS, true)) {
204 return new \WP_Error(
205 'thinkrank_endpoint_blocked',
206 sprintf(
207 /* translators: 1: host name, 2: the address it resolved to. */
208 __('%1$s resolves to %2$s, a link-local or cloud metadata address. Refusing to send requests there.', 'thinkrank'),
209 $host,
210 $address
211 )
212 );
213 }
214
215 // Plain http is allowed for the local case only, and that has to be
216 // true of the address as well as the name — `.internal` and
217 // `.local` names are accepted on spelling alone by validate().
218 if ('http' === $scheme && !self::is_local_address($check)) {
219 return new \WP_Error(
220 'thinkrank_endpoint_insecure',
221 sprintf(
222 /* translators: 1: host name, 2: the public address it resolved to. */
223 __('%1$s resolves to the public address %2$s, so plain http:// is not allowed. Use https:// instead.', 'thinkrank'),
224 $host,
225 $address
226 )
227 );
228 }
229 }
230
231 return $addresses[0];
232 }
233
234 /**
235 * Send a request to a custom endpoint with the destination checked and pinned.
236 *
237 * This is transport, not a provider call of its own: the clients that
238 * route through it consult Spend_Guard before they get here, and the
239 * Settings connection test uses it to probe a URL the administrator just
240 * typed — charging that against the daily ceiling, or refusing it while
241 * AI is paused, would stop them fixing the very setup the pause is about.
242 *
243 * @thinkrank-no-spend-guard
244 *
245 * @since 2.8.0
246 *
247 * @param string $url Absolute URL.
248 * @param array $args wp_remote_request() arguments.
249 * @return array|\WP_Error Response, or the reason the destination was refused.
250 */
251 public static function guarded_request(string $url, array $args) {
252 $address = self::resolve_safe_address($url);
253 if (is_wp_error($address)) {
254 return $address;
255 }
256
257 $unpinnable = self::unpinnable_reason($url);
258 if (is_wp_error($unpinnable)) {
259 return $unpinnable;
260 }
261
262 // A server we do not control decides how much it sends back. Without a
263 // ceiling the whole body is buffered before anything can reject it, so
264 // a hostile or broken endpoint could spend the worker's memory (#721).
265 if (!isset($args['limit_response_size'])) {
266 $args['limit_response_size'] = self::MAX_RESPONSE_BYTES;
267 }
268
269 // Never follow a redirect: the key rides in the headers, and the
270 // address approved above is only the address of this host.
271 $args['redirection'] = 0;
272
273 // Certificate verification is half of the pin for an https endpoint
274 // (see self::unpinnable_reason()), so it is not a caller's to disable.
275 $args['sslverify'] = true;
276
277 $parts = wp_parse_url($url);
278 $scheme = strtolower((string) ($parts['scheme'] ?? 'https'));
279 $host = trim(strtolower((string) ($parts['host'] ?? '')), '[]');
280 $port = (int) ($parts['port'] ?? ('https' === $scheme ? 443 : 80));
281
282 // Pin the connection to the address that was just approved, so a second
283 // DNS answer cannot send this request somewhere else (rebinding). The
284 // Host header and TLS SNI still use the name, so certificates and
285 // virtual hosts keep working. A no-op on a non-curl transport, which is
286 // why the checks above stand on their own.
287 $pin = static function ($handle) use ($host, $port, $address) {
288 if (!function_exists('curl_setopt') || !defined('CURLOPT_RESOLVE')) {
289 return;
290 }
291
292 // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- pinning the resolved address is the point; wp_remote_*() has no equivalent, and this runs on the handle WordPress itself created.
293 curl_setopt($handle, CURLOPT_RESOLVE, [$host . ':' . $port . ':' . $address]);
294 };
295
296 add_action('http_api_curl', $pin, 10, 1);
297
298 try {
299 $response = wp_remote_request($url, $args);
300 } finally {
301 remove_action('http_api_curl', $pin, 10);
302 }
303
304 return $response;
305 }
306
307 /**
308 * Refuse the request when the destination cannot be held to what was checked.
309 *
310 * The address check above happens before the socket opens, so something has
311 * to guarantee the socket goes to the address that was approved. Two things
312 * can do that:
313 *
314 * - cURL's CURLOPT_RESOLVE, which this class sets; or
315 * - TLS itself. A rebound answer pointing at a metadata service cannot
316 * present a valid certificate for the name, so an https request fails
317 * before anything is sent. Certificate verification is therefore the pin,
318 * which is why https is allowed on any transport.
319 *
320 * That leaves one gap: plain http to a *name*, on an installation where
321 * cURL is unavailable (WordPress then uses the streams transport, which
322 * resolves again and offers no hook to pin). There we fail closed and ask
323 * for the address instead of the name — nothing else in the request can
324 * tell us we reached the host we vetted (#721).
325 *
326 * @since 2.8.0
327 *
328 * @param string $url URL about to be requested.
329 * @return true|\WP_Error True when the destination can be held; the reason otherwise.
330 */
331 private static function unpinnable_reason(string $url) {
332 $parts = wp_parse_url($url);
333 $scheme = strtolower((string) ($parts['scheme'] ?? 'https'));
334 $host = trim(strtolower((string) ($parts['host'] ?? '')), '[]');
335
336 // https verifies the name against the certificate, and an IP literal
337 // has no lookup to race.
338 if ('http' !== $scheme || filter_var($host, FILTER_VALIDATE_IP)) {
339 return true;
340 }
341
342 if (self::can_pin_destination()) {
343 return true;
344 }
345
346 return new \WP_Error(
347 'thinkrank_endpoint_unpinnable',
348 sprintf(
349 /* translators: %s: the host name the administrator entered. */
350 __('This site cannot pin a plain-http connection to a verified address (cURL is unavailable), so %s has to be given as an IP address rather than a name, or use https://.', 'thinkrank'),
351 $host
352 )
353 );
354 }
355
356 /**
357 * Can this installation hold a connection to a chosen address?
358 *
359 * True when cURL is available with CURLOPT_RESOLVE, which is what
360 * {@see self::guarded_request()} pins with. Filterable so a site that
361 * routes HTTP through a transport of its own can state the answer, and so
362 * the failure path is testable.
363 *
364 * @since 2.8.0
365 *
366 * @return bool
367 */
368 private static function can_pin_destination(): bool {
369 $can_pin = function_exists('curl_init')
370 && function_exists('curl_setopt')
371 && defined('CURLOPT_RESOLVE');
372
373 /**
374 * Filters whether the destination of a custom AI endpoint request can be pinned.
375 *
376 * @since 2.8.0
377 *
378 * @param bool $can_pin Whether cURL with CURLOPT_RESOLVE is available.
379 */
380 return (bool) apply_filters('thinkrank_ai_endpoint_can_pin_destination', $can_pin);
381 }
382
383 /**
384 * Resolve a host to the addresses it answers with.
385 *
386 * An IP literal resolves to itself. A name is looked up for both families;
387 * a lookup that returns nothing is treated as a failure by the caller
388 * rather than as "no bad addresses".
389 *
390 * @since 2.8.0
391 *
392 * @param string $host Lower-cased host without IPv6 brackets.
393 * @return string[] Addresses, possibly empty.
394 */
395 private static function addresses_for(string $host): array {
396 if (filter_var($host, FILTER_VALIDATE_IP)) {
397 return [$host];
398 }
399
400 $addresses = [];
401
402 $ipv4 = gethostbynamel($host);
403 if (is_array($ipv4)) {
404 $addresses = $ipv4;
405 }
406
407 // dns_get_record() is absent or restricted on some hosts; a missing
408 // AAAA answer is not an error, the A records above still decide.
409 if (function_exists('dns_get_record')) {
410 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a failed lookup is handled by the empty check below, not by a warning.
411 $ipv6 = @dns_get_record($host, DNS_AAAA);
412 if (is_array($ipv6)) {
413 foreach ($ipv6 as $record) {
414 if (!empty($record['ipv6'])) {
415 $addresses[] = (string) $record['ipv6'];
416 }
417 }
418 }
419 }
420
421 return array_values(array_unique($addresses));
422 }
423
424 /**
425 * Is this *address* loopback or a private range?
426 *
427 * The name-based test below accepts `.local` and `.internal` on spelling;
428 * this one is about what the name actually points at.
429 *
430 * @since 2.8.0
431 *
432 * @param string $address IPv4 or IPv6 address.
433 * @return bool
434 */
435 private static function is_local_address(string $address): bool {
436 if (in_array($address, ['127.0.0.1', '::1'], true) || str_starts_with($address, '127.')) {
437 return true;
438 }
439
440 if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
441 return !filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE);
442 }
443
444 if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
445 return !filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE);
446 }
447
448 return false;
449 }
450
451 /**
452 * Is this host loopback, a private range, or a .local/.internal name?
453 *
454 * @since 2.8.0
455 *
456 * @param string $host Lower-cased host without IPv6 brackets.
457 * @return bool True when plain http is acceptable for it.
458 */
459 private static function is_local_host(string $host): bool {
460 if (in_array($host, ['localhost', '127.0.0.1', '::1', '0.0.0.0'], true)) {
461 return true;
462 }
463
464 // host.docker.internal and friends: a name that can only resolve inside
465 // the machine or its LAN.
466 if (str_ends_with($host, '.localhost') || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
467 return true;
468 }
469
470 if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
471 // 127/8 is all loopback, not just .0.1.
472 if (str_starts_with($host, '127.')) {
473 return true;
474 }
475
476 return !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE);
477 }
478
479 if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
480 return !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE);
481 }
482
483 return false;
484 }
485
486 /**
487 * Unwrap an IPv4-mapped IPv6 address (::ffff:a.b.c.d) to its IPv4 form.
488 *
489 * The mapped spelling reaches the IPv4 host, but matches none of the
490 * IPv4 checks: `169.254.` prefixes, BLOCKED_IPS literals, or PHP's
491 * private-range flags. Unwrapping first makes every check judge the
492 * address the socket actually connects to.
493 *
494 * @since 2.8.0
495 *
496 * @param string $host Lower-cased host without IPv6 brackets.
497 * @return string The IPv4 address, or the host unchanged.
498 */
499 private static function unmap_ipv4(string $host): string {
500 if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
501 return $host;
502 }
503
504 $packed = inet_pton($host);
505 if (false === $packed || 16 !== strlen($packed) || str_repeat("\0", 10) . "\xff\xff" !== substr($packed, 0, 12)) {
506 return $host;
507 }
508
509 return (string) inet_ntop(substr($packed, 12));
510 }
511
512 /**
513 * Is this host a link-local address?
514 *
515 * FILTER_FLAG_NO_RES_RANGE would also reject loopback, which we want to
516 * allow, so link-local is matched on its own.
517 *
518 * @since 2.8.0
519 *
520 * @param string $host Lower-cased host without IPv6 brackets.
521 * @return bool
522 */
523 private static function is_link_local(string $host): bool {
524 if (str_starts_with($host, '169.254.')) {
525 return true;
526 }
527
528 if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
529 return false;
530 }
531
532 $normalized = strtolower((string) inet_ntop((string) inet_pton($host)));
533
534 // fe80::/10 — fe80 through febf.
535 return (bool) preg_match('/^fe[89ab][0-9a-f]:/', $normalized);
536 }
537
538 /**
539 * Rebuild the URL from its parsed parts, dropping the fragment and any
540 * trailing slash on the path.
541 *
542 * The query string is kept. Azure OpenAI requires `?api-version=…` on every
543 * call, so discarding it silently turned the documented Azure URL into one
544 * that 400s — which is why the route goes *before* the query (see
545 * {@see self::route()}) rather than the base being concatenated blindly.
546 *
547 * @since 2.8.0
548 *
549 * @param array $parts Output of wp_parse_url().
550 * @return string Normalised URL.
551 */
552 private static function normalize(array $parts): string {
553 $url = strtolower((string) $parts['scheme']) . '://' . strtolower((string) $parts['host']);
554
555 if (!empty($parts['port'])) {
556 $url .= ':' . (int) $parts['port'];
557 }
558
559 // Host and scheme are case-insensitive; a path and a query are not.
560 $url .= isset($parts['path']) ? rtrim((string) $parts['path'], '/') : '';
561
562 if (isset($parts['query']) && '' !== $parts['query']) {
563 $url .= '?' . $parts['query'];
564 }
565
566 return $url;
567 }
568
569 /**
570 * Build the URL for one route against a stored base URL.
571 *
572 * A base URL may carry a query string (Azure's required `api-version`), so
573 * the route has to be spliced in before it: '…/deployments/gpt4o' plus
574 * 'chat/completions' plus '?api-version=2024-10-21', never
575 * '…?api-version=2024-10-21/chat/completions'. Every caller — generation,
576 * connection test, model listing, vision — goes through this so they cannot
577 * drift apart (#721).
578 *
579 * @since 2.8.0
580 *
581 * @param string $base_url Stored (already validated) base URL.
582 * @param string $route Route to append, e.g. 'chat/completions'.
583 * @return string Absolute URL.
584 */
585 public static function route(string $base_url, string $route): string {
586 $base_url = trim($base_url);
587 $query = '';
588
589 $separator = strpos($base_url, '?');
590 if (false !== $separator) {
591 $query = substr($base_url, $separator + 1);
592 $base_url = substr($base_url, 0, $separator);
593 }
594
595 $url = rtrim($base_url, '/') . '/' . ltrim($route, '/');
596
597 return '' !== $query ? $url . '?' . $query : $url;
598 }
599 }
600