| 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 |
|