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 <alexandre@alapetite.fr>
This commit is contained in:
Inverle
2026-07-23 13:05:53 +02:00
committed by GitHub
parent 818bc986f7
commit 48abfd58fa
5 changed files with 140 additions and 15 deletions

View File

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

View File

@@ -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<string>|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>|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 proxys 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();

View File

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

View File

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

View File

@@ -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 proxys 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<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.
* @return array<string>|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 [];
}