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:
@@ -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.'
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user