فهرست منبع

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>
Inverle 5 روز پیش
والد
کامیت
48abfd58fa
5فایلهای تغییر یافته به همراه140 افزوده شده و 15 حذف شده
  1. 2 2
      app/Models/SimplePieFetch.php
  2. 74 7
      app/Utils/httpUtil.php
  3. 4 2
      docs/en/admins/09_AccessControl.md
  4. 1 1
      lib/composer.json
  5. 59 3
      lib/simplepie/simplepie/src/File.php

+ 2 - 2
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]

+ 74 - 7
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<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();

+ 4 - 2
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

+ 1 - 1
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,

+ 59 - 3
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<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 [];
     }