mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-07-28 15:57:27 -04:00
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:
@@ -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]
|
||||
|
||||
@@ -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 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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user