From 48abfd58faf7566847f73d1cd760df915849ec1f Mon Sep 17 00:00:00 2001 From: Inverle Date: Thu, 23 Jul 2026 13:05:53 +0200 Subject: [PATCH] Improve SSRF mitigations by restricting `CURLOPT_PROXY` (#8950) * Improve SSRF mitigations by restricting `CURLOPT_PROXY` Follow-up of https://github.com/FreshRSS/FreshRSS/pull/8400 SimplePie PR: https://github.com/FreshRSS/simplepie/pull/83 * Reword docs * Change single quote to backquote in docs * Fix `cURL error 5: Could not resolve proxy: Array` error * Sync SimplePie * A few fixes * Implement suggestion * A few more fixes * Sync composer https://github.com/FreshRSS/simplepie/pull/83 --------- Co-authored-by: Alexandre Alapetite --- app/Models/SimplePieFetch.php | 4 +- app/Utils/httpUtil.php | 81 +++++++++++++++++++++++++--- docs/en/admins/09_AccessControl.md | 6 ++- lib/composer.json | 2 +- lib/simplepie/simplepie/src/File.php | 62 +++++++++++++++++++-- 5 files changed, 140 insertions(+), 15 deletions(-) diff --git a/app/Models/SimplePieFetch.php b/app/Models/SimplePieFetch.php index c8268362d..d4bad9bf6 100644 --- a/app/Models/SimplePieFetch.php +++ b/app/Models/SimplePieFetch.php @@ -45,8 +45,8 @@ final class FreshRSS_SimplePieFetch extends \SimplePie\File } #[\Override] - protected function get_curl_resolve_info(string $url): array|null|false { - return FreshRSS_http_Util::getCurlResolveInfo($url); + protected function get_curl_resolve_info(string $url, bool $for_proxy = false): array|string|null|false { + return FreshRSS_http_Util::getCurlResolveInfo($url, $for_proxy); } #[\Override] diff --git a/app/Utils/httpUtil.php b/app/Utils/httpUtil.php index 0ddf5f6da..2d670a020 100644 --- a/app/Utils/httpUtil.php +++ b/app/Utils/httpUtil.php @@ -306,12 +306,17 @@ final class FreshRSS_http_Util { /** * Returns a value for CURLOPT_RESOLVE as an array, null if no allowed IPs were found, false if the domain failed to resolve. * - * @return array|null|false + * Can also be used for checking if the CURLOPT_PROXY value is allowed, by providing a proxy URL with the `for_proxy` parameter set to `true`. + * In that case, a string value will be returned with the hostname resolved to an IP if allowed. + * + * @return array|string|null|false */ - public static function getCurlResolveInfo(string $url): array|null|false { + public static function getCurlResolveInfo(string $url, bool $for_proxy = false): array|string|null|false { + // Parse the original URL first so that credentials keep their original case (only the host is case-insensitive). + $parsedOriginal = parse_url($url); $url = strtolower($url); $parsed = parse_url($url); - if ($parsed === false) { + if ($parsed === false || $parsedOriginal === false) { return false; } $host = $parsed['host'] ?? null; @@ -319,6 +324,12 @@ final class FreshRSS_http_Util { if ($host === null || $scheme === null) { return false; } + $credentials = ''; + $user = $parsedOriginal['user'] ?? null; + $pass = $parsedOriginal['pass'] ?? null; + if (is_string($user) && is_string($pass)) { + $credentials = "$user:$pass@"; + } if (str_starts_with($host, '[') && str_ends_with($host, ']')) { if (strlen($host) === 2) { return false; @@ -334,15 +345,25 @@ final class FreshRSS_http_Util { $internal_host_allowlist = FreshRSS_Context::systemConf()->internal_host_allowlist; } - if (in_array('*', $internal_host_allowlist, true)) { - return []; // Disables SSRF checks entirely (unsafe) - } - $port = parse_url($url)['port'] ?? match ($scheme) { 'http' => 80, 'https' => 443, + 'socks4' => 1080, + 'socks4a' => 1080, + 'socks5' => 1080, + 'socks5h' => 1080, default => 0, }; + if (in_array('*', $internal_host_allowlist, true)) { + if ($for_proxy) { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + return $credentials . "[$host]:$port"; + } + return $credentials . "$host:$port"; + } + return []; // Disables SSRF checks entirely (unsafe) + } + $resolve_str = "$host:$port:"; $ips_ok = []; $ips = []; @@ -401,10 +422,20 @@ final class FreshRSS_http_Util { if (count($ips_ok) > 0) { if (count($records) > 0 || isset(self::$resolve_ok[$host])) { + if ($for_proxy) { + // $ips_ok[0] is already bracketed when it is an IPv6 address + return $credentials . "$ips_ok[0]:$port"; + } $resolve_str .= implode(',', $ips_ok); return [$resolve_str]; } if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + if ($for_proxy) { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + return $credentials . "[$host]:$port"; + } + return $credentials . "$host:$port"; + } // No resolve overrides since the URL only contained an IP, not a domain return []; } @@ -453,10 +484,13 @@ final class FreshRSS_http_Util { $accept = ''; $proxy = is_string(FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] ?? null) ? FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] : ''; + $proxy_type = is_int(FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXYTYPE] ?? null) ? + FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXYTYPE] : 0; $options = []; // User-defined cURL options if (is_array($attributes['curl_params'] ?? null)) { $options = self::sanitizeCurlParams($attributes['curl_params']); $proxy = is_string($options[CURLOPT_PROXY] ?? null) ? $options[CURLOPT_PROXY] : $proxy; + $proxy_type = is_int($options[CURLOPT_PROXYTYPE] ?? null) ? $options[CURLOPT_PROXYTYPE] : $proxy_type; if (is_array($options[CURLOPT_HTTPHEADER] ?? null)) { // Add Accept header if it is not set if (preg_grep('/^Accept\\s*:/i', $options[CURLOPT_HTTPHEADER]) === false) { @@ -465,6 +499,7 @@ final class FreshRSS_http_Util { } } $proxy = is_string($curl_options[CURLOPT_PROXY] ?? null) ? $curl_options[CURLOPT_PROXY] : $proxy; + $proxy_type = is_int($curl_options[CURLOPT_PROXYTYPE] ?? null) ? $curl_options[CURLOPT_PROXYTYPE] : $proxy_type; if (($retryAfter = FreshRSS_http_Util::getRetryAfter($url, $proxy)) > 0) { Minz_Log::warning('For that domain, will first retry after ' . date('c', $retryAfter) . '. ' . \SimplePie\Misc::url_remove_credentials($url)); @@ -516,6 +551,38 @@ final class FreshRSS_http_Util { if (!empty($resolve)) { $curl_options[CURLOPT_RESOLVE] = $resolve; // Prevent DNS rebinding } + } else { + defined('CURLPROXY_HTTPS') or define('CURLPROXY_HTTPS', 2); // Compatibility cURL 7.51 + $proxy_scheme = match ($proxy_type) { + CURLPROXY_HTTP => 'http', + CURLPROXY_HTTPS => 'https', + CURLPROXY_SOCKS4 => 'socks4', + CURLPROXY_SOCKS4A => 'socks4a', + CURLPROXY_SOCKS5 => 'socks5', + CURLPROXY_SOCKS5_HOSTNAME => 'socks5h', + default => null, + }; + if ($proxy_scheme === null) { + // Unsupported proxy type + return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true, 'status' => -500, 'error' => '']; + } + $proxy_url = "$proxy_scheme://$proxy"; // CURLOPT_PROXY ($proxy) is formatted as user:pass@hostname:port, with the part before @ being optional + $resolve = self::getCurlResolveInfo($proxy_url, for_proxy: true); + if ($resolve === null) { + Minz_Log::warning('Failed to fetch this URL, because the proxy’s IP is not in the allowlist [' . + \SimplePie\Misc::url_remove_credentials($url) . '] [' . + \SimplePie\Misc::url_remove_credentials($proxy_url) . ']'); + return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true, 'status' => -500, 'error' => '']; + } elseif ($resolve === false) { + return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true, 'status' => -500, 'error' => '']; + } + // Translate from a hostname:port value to ip:port, in order to avoid DNS rebinding + $curl_options[CURLOPT_PROXY] = $resolve; + if (defined('CURLOPT_PROXY_SSL_VERIFYHOST')) { + // Skip verifying the hostname (a bit unsafe, but needed since + // there is no CURLOPT_RESOLVE equivalent for proxy hostnames) + $curl_options[CURLOPT_PROXY_SSL_VERIFYHOST] = 0; + } } // TODO: Implement HTTP 1.1 conditional GET If-Modified-Since $ch = curl_init(); diff --git a/docs/en/admins/09_AccessControl.md b/docs/en/admins/09_AccessControl.md index 4f4b2cc81..be79681cb 100644 --- a/docs/en/admins/09_AccessControl.md +++ b/docs/en/admins/09_AccessControl.md @@ -18,11 +18,13 @@ In an SSRF scenario, a malicious user could submit a feed URL that points to int * Other services not meant to be exposed externally FreshRSS blocks these unsafe requests by default, due to the security risks written above, though certain hosts can be excluded from the block by going to `Settings > System configuration` and making changes to the internal host allowlist. -Entries are separated by newlines, and must be a `host:port` combination, for example `127.0.0.1:8080`, `rss-bridge:80` or a CIDR notation ('0.0.0.0/0' to allow any IPv4, `::/0` to allow any IPv6). +Entries are separated by newlines, and must be a `host:port` combination, for example `127.0.0.1:8080`, `rss-bridge:80` or a CIDR notation (`0.0.0.0/0` to allow any IPv4, `::/0` to allow any IPv6). Another option is to set an `INTERNAL_HOST_ALLOWLIST` environment variable (e.g. in your docker-compose file). The entries there are separated by whitespace instead. Adding `*` disables the SSRF check completely (unsafe). -### Recommended mitigations for shared/public setups +### Recommended additional mitigations for shared/public setups + +Although there are already existing mitigations for SSRF, some extra steps can be taken for more safety: * Run FreshRSS behind a firewall or reverse proxy that blocks access to internal IP ranges * Use container isolation or a virtual network to prevent access to sensitive endpoints diff --git a/lib/composer.json b/lib/composer.json index ceefc92e5..3a8844b15 100644 --- a/lib/composer.json +++ b/lib/composer.json @@ -18,7 +18,7 @@ "marienfressinaud/lib_opml": "dev-main#f0e850b6394af90b898daf0e65fcc7363457b844", "phpgt/cssxpath": "v1.5.0", "phpmailer/phpmailer": "7.1.1", - "simplepie/simplepie": "dev-freshrss#7acfcf7c7195d4c1af626ecf84d1f433564c0211" + "simplepie/simplepie": "dev-freshrss#37ebf581e60ce90ebee7d79181de43cd8f63f936" }, "config": { "sort-packages": true, diff --git a/lib/simplepie/simplepie/src/File.php b/lib/simplepie/simplepie/src/File.php index cf22f0633..74002574b 100644 --- a/lib/simplepie/simplepie/src/File.php +++ b/lib/simplepie/simplepie/src/File.php @@ -114,7 +114,9 @@ class File implements Response } if (!$force_fsockopen && function_exists('curl_exec')) { $resolve = false; // FreshRSS - if (empty($curl_options[CURLOPT_PROXY] ?? null)) { // FreshRSS + $proxy = is_string($curl_options[CURLOPT_PROXY] ?? null) ? $curl_options[CURLOPT_PROXY] : null; // FreshRSS + $proxy_type = $curl_options[CURLOPT_PROXYTYPE] ?? CURLPROXY_HTTP; // FreshRSS + if ($proxy == null) { // FreshRSS $resolve = $this->get_curl_resolve_info($url); if ($resolve === null) { $this->error = 'URL is not allowed to be resolved: ' . \SimplePie\Misc::url_remove_credentials($url); @@ -128,6 +130,53 @@ class File implements Response if (!empty($resolve)) { $curl_options[CURLOPT_RESOLVE] = $resolve; // Prevent DNS rebinding } + } else { // FreshRSS + defined('CURLPROXY_HTTPS') or define('CURLPROXY_HTTPS', 2); // Compatibility cURL 7.51 + $proxy_scheme = null; + switch ($proxy_type) { + case CURLPROXY_HTTP: + $proxy_scheme = 'http'; + break; + case CURLPROXY_HTTPS: + $proxy_scheme = 'https'; + break; + case CURLPROXY_SOCKS4: + $proxy_scheme = 'socks4'; + break; + case CURLPROXY_SOCKS4A: + $proxy_scheme = 'socks4a'; + break; + case CURLPROXY_SOCKS5: + $proxy_scheme = 'socks5'; + break; + case CURLPROXY_SOCKS5_HOSTNAME: + $proxy_scheme = 'socks5h'; + break; + } + if ($proxy_scheme === null) { + $this->error = 'Unsupported proxy type'; + $this->success = false; + return; + } + $proxy = preg_replace('#^.*://#i', '', $proxy); // Strip any scheme already present in CURLOPT_PROXY + $proxy_url = "$proxy_scheme://$proxy"; // CURLOPT_PROXY ($proxy) is formatted as user:pass@hostname:port, with the part before @ being optional + $resolve = $this->get_curl_resolve_info($proxy_url, true); + if ($resolve === null) { + $this->error = 'Failed to fetch this URL, because the proxy’s IP is not in the allowlist [' . + \SimplePie\Misc::url_remove_credentials($url) . '] [' . + \SimplePie\Misc::url_remove_credentials($proxy_url) . ']'; + $this->success = false; + return; + } elseif ($resolve === false) { + $this->error = 'Failed to resolve proxy hostname: ' . \SimplePie\Misc::url_remove_credentials($proxy_url); + $this->success = false; + return; + } + $curl_options[CURLOPT_PROXY] = $resolve; // Translate from a hostname:port value to ip:port, in order to prevent DNS rebinding + if (defined('CURLOPT_PROXY_SSL_VERIFYHOST')) { + // Available as of PHP 7.3.0 and cURL 7.52.0 + $curl_options[CURLOPT_PROXY_SSL_VERIFYHOST] = 0; // Skip verifying the hostname (a bit unsafe, but needed since there is no CURLOPT_RESOLVE equivalent for proxy hostnames) + } } $this->method = \SimplePie\SimplePie::FILE_SOURCE_REMOTE | \SimplePie\SimplePie::FILE_SOURCE_CURL; $fp = self::curlInit($url, $timeout, $headers, $useragent, $curl_options); @@ -444,10 +493,17 @@ class File implements Response /** * Event to allow inheriting classes to control fetching certain URLs. * @param string $url - * @return array|null|false Returns a value for CURLOPT_RESOLVE as an array, null if no allowed IPs were found, false if the domain failed to resolve. + * @return array|string|null|false Returns a value for CURLOPT_RESOLVE as an array, null if no allowed IPs were found, false if the domain failed to resolve. Can also be used for checking if the CURLOPT_PROXY value is allowed, by providing a proxy URL with the `for_proxy` parameter set to `true`. In that case, a string value will be returned with the hostname resolved to an IP if allowed. */ - protected function get_curl_resolve_info(string $url) + protected function get_curl_resolve_info(string $url, bool $for_proxy = false) { + if ($for_proxy) { + $pos = strpos($url, '://'); + if ($pos === false) { + return false; + } + return substr($url, $pos + 3); + } return []; }