fix(security): URL import with own range check and a generic error (audit M3, cheap part)

PublicAddress blocks every private/reserved IPv4 and IPv6 range
(169.254/16, 100.64/10, fc00::/7, fe80::/10, IPv4-mapped/NAT64/6to4 by
their embedded address, ...) independent of the core version; checked
before the request and on every redirect (Requests before_redirect).
Network failures return only "Die Datei konnte nicht geladen werden.";
the detail goes to the error log. DNS-rebinding pinning stays deferred.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 19:27:11 +02:00
co-authored by Claude Opus 5.5
parent 0531a5ffb1
commit 7f361304ae
8 changed files with 318 additions and 14 deletions
+41 -9
View File
@@ -4,19 +4,23 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Limits;
use LIT\MCP\Log;
use Throwable;
use WP_Error;
use WpOrg\Requests\Exception as RequestsException;
/**
* Imports files into the media library (spec §6.3, §10).
*
* - URL: only http(s), checked with wp_http_validate_url() before any request and fetched
* with wp_safe_remote_get() (rejects internal addresses, also after redirects), streamed
* into a temp file and cut off one byte above the upload limit.
* - URL: only http(s), checked with wp_http_validate_url() and the plugin's own range check
* (PublicAddress, independent of the core version) before any request and on every redirect,
* fetched with wp_safe_remote_get() (core re-validates redirects too), streamed into a temp file
* and cut off one byte above the upload limit. Network failures reach the client only as one
* generic message (no refused/timeout/status oracle, security audit M3); details go to the log.
* - Base64: decoded strictly; the size is checked against Limits::inlineBytes() (upload limit,
* at most 10 MB – the whole file sits in memory) before anything is decoded or written.
* - Type: whitelist (filter lit_mcp_upload_mimes) intersected with what WordPress allows the
@@ -44,16 +48,26 @@ class MediaImporter
private const TIMEOUT = 30;
private const ALT_KEY = '_wp_attachment_image_alt';
private const REDIRECT_HOOK = 'requests-requests.before_redirect';
public function __construct(private Limits $limits)
/** @var Closure(string): list<string> Host name => IPv4 addresses. */
private Closure $resolve;
/** @param (Closure(string): list<string>)|null $resolve DNS lookup; null = the real one. */
public function __construct(private Limits $limits, ?Closure $resolve = null)
{
$this->resolve = $resolve ?? PublicAddress::dnsResolver();
}
/** @param array{title?: string, alt?: string, post_id?: int} $meta */
public function fromUrl(string $url, ?string $filename, array $meta): int|WP_Error
{
$scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
if (! in_array($scheme, ['http', 'https'], true) || ! wp_http_validate_url($url)) {
if (
! in_array($scheme, ['http', 'https'], true)
|| ! wp_http_validate_url($url)
|| ! $this->isPublicUrl($url)
) {
return Errors::invalidInput(
'Die URL ist nicht erlaubt.',
['url' => 'nur öffentlich erreichbare http(s)-Adressen'],
@@ -126,7 +140,15 @@ class MediaImporter
curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
}
};
// Every redirect target gets the plugin's own range check too; Requests turns the
// exception into a failed request (WP_Http returns a WP_Error).
$redirect = function ($location): void {
if (! $this->isPublicUrl((string) $location)) {
throw new RequestsException('Redirect to a non-public address refused', 'lit_mcp_redirect');
}
};
add_action('http_api_curl', $ipv4);
add_action(self::REDIRECT_HOOK, $redirect);
try {
$response = wp_safe_remote_get($url, [
'timeout' => self::TIMEOUT,
@@ -136,15 +158,16 @@ class MediaImporter
]);
} finally {
remove_action('http_api_curl', $ipv4);
remove_action(self::REDIRECT_HOOK, $redirect);
}
if ($response instanceof WP_Error) {
return $this->downloadFailed($response->get_error_message());
return $this->downloadFailed($url, $response->get_error_message());
}
$status = (int) wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return $this->downloadFailed(sprintf('HTTP-Status %d', $status));
return $this->downloadFailed($url, sprintf('HTTP status %d', $status));
}
$length = (string) wp_remote_retrieve_header($response, 'content-length');
@@ -336,10 +359,19 @@ class MediaImporter
}
}
private function downloadFailed(string $reason): WP_Error
/** Host (name or IP literal) of $url resolves to public addresses only (PublicAddress). */
private function isPublicUrl(string $url): bool
{
return PublicAddress::hostIsPublic((string) wp_parse_url($url, PHP_URL_HOST), $this->resolve);
}
/** One message for every network failure (security audit M3); the reason is logged only. */
private function downloadFailed(string $url, string $reason): WP_Error
{
Log::failure('media import from ' . $url, $reason);
return Errors::invalidInput(
'Download fehlgeschlagen: ' . $reason,
'Die Datei konnte nicht geladen werden.',
['url' => 'nicht abrufbar'],
'URL prüfen; nur öffentlich erreichbare Dateien können importiert werden.'
);
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Media;
use Closure;
/**
* The plugin's own check that a URL import only reaches public addresses (security audit M3), in
* addition to wp_http_validate_url(): older WordPress versions (the plugin supports 6.9) block
* fewer ranges, e.g. not 169.254.0.0/16 (cloud metadata) or 100.64.0.0/10 (carrier-grade NAT).
*
* Blocked: every private, loopback, link-local, shared, documentation, benchmark, multicast and
* reserved range of IPv4 and IPv6; IPv6 forms that embed an IPv4 address (::ffff:0:0/96 mapped,
* 64:ff9b::/96 NAT64, 2002::/16 6to4) are judged by that IPv4 address. Pure PHP.
*/
final class PublicAddress
{
/** IPv4 ranges that are not public (network, prefix length). */
private const V4_BLOCKED = [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
];
/** IPv6 ranges that are not public (network, prefix length); ::/96 covers ::, ::1 and IPv4-compatible. */
private const V6_BLOCKED = [
['::', 96], ['100::', 64], ['2001::', 32], ['2001:db8::', 32], ['fc00::', 7], ['fe80::', 10],
['fec0::', 10], ['ff00::', 8],
];
/** IPv6 prefixes that embed an IPv4 address: prefix, prefix length, byte offset of the IPv4 address. */
private const V6_EMBEDDING = [['::ffff:0:0', 96, 12], ['64:ff9b::', 96, 12], ['2002::', 16, 2]];
public static function isPublicIp(string $ip): bool
{
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- invalid input is a normal "no".
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
return strlen($packed) === 4 ? self::isPublicV4($packed) : self::isPublicV6($packed);
}
/**
* Whether $host (name or IP literal, IPv6 possibly in brackets) resolves only to public
* addresses. An unresolvable host is not public.
*
* @param Closure(string): list<string> $resolve Host name => its addresses.
*/
public static function hostIsPublic(string $host, Closure $resolve): bool
{
$host = trim($host, '[]');
if ($host === '') {
return false;
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see isPublicIp().
if (@inet_pton($host) !== false) {
return self::isPublicIp($host);
}
$addresses = $resolve($host);
if ($addresses === []) {
return false;
}
foreach ($addresses as $address) {
if (! self::isPublicIp($address)) {
return false;
}
}
return true;
}
/** @return Closure(string): list<string> IPv4 lookup like the import request makes (cURL is IPv4-only there). */
public static function dnsResolver(): Closure
{
return static function (string $host): array {
$addresses = gethostbynamel($host);
return is_array($addresses) ? array_values($addresses) : [];
};
}
private static function isPublicV4(string $packed): bool
{
foreach (self::V4_BLOCKED as [$network, $bits]) {
if (self::inRange($packed, (string) inet_pton($network), $bits)) {
return false;
}
}
return true;
}
private static function isPublicV6(string $packed): bool
{
foreach (self::V6_EMBEDDING as [$prefix, $bits, $offset]) {
if (self::inRange($packed, (string) inet_pton($prefix), $bits)) {
return self::isPublicV4(substr($packed, $offset, 4));
}
}
foreach (self::V6_BLOCKED as [$network, $bits]) {
if (self::inRange($packed, (string) inet_pton($network), $bits)) {
return false;
}
}
return true;
}
/** Whether the first $bits bits of $address and $network (packed, same length) are equal. */
private static function inRange(string $address, string $network, int $bits): bool
{
if (strlen($address) !== strlen($network)) {
return false;
}
$bytes = intdiv($bits, 8);
if (substr($address, 0, $bytes) !== substr($network, 0, $bytes)) {
return false;
}
$rest = $bits % 8;
if ($rest === 0) {
return true;
}
$mask = (0xFF << (8 - $rest)) & 0xFF;
return (ord($address[$bytes]) & $mask) === (ord($network[$bytes]) & $mask);
}
}