Переглянути джерело

feat: add URL validation to prevent SSRF attacks by blocking local/internal resources

causefx@me.com 19 годин тому
батько
коміт
299a1e8fa1
1 змінених файлів з 93 додано та 0 видалено
  1. 93 0
      api/classes/organizr.class.php

+ 93 - 0
api/classes/organizr.class.php

@@ -7460,6 +7460,94 @@ public function youtubeSearch($query)
 		}
 	}
 
+	/**
+	 * Validate that URL is external and not a local/internal resource
+	 * Prevents SSRF attacks by blocking local file access and private IP ranges
+	 * 
+	 * @param string $url The URL to validate
+	 * @return bool True if URL is external and safe, false otherwise
+	 */
+	private function isExternalURL($url)
+	{
+		// Parse the URL
+		$parsedUrl = parse_url($url);
+		
+		if (!$parsedUrl || !isset($parsedUrl['scheme']) || !isset($parsedUrl['host'])) {
+			return false;
+		}
+		
+		// Block file:// and other non-http(s) schemes
+		$scheme = strtolower($parsedUrl['scheme']);
+		if (!in_array($scheme, ['http', 'https'])) {
+			return false;
+		}
+		
+		$host = strtolower($parsedUrl['host']);
+		
+		// Block localhost variations
+		$localhostPatterns = [
+			'localhost',
+			'127.0.0.1',
+			'0.0.0.0',
+			'::1',
+			'0:0:0:0:0:0:0:1'
+		];
+		
+		if (in_array($host, $localhostPatterns)) {
+			return false;
+		}
+		
+		// Resolve hostname to IP if it's not already an IP
+		$ip = $host;
+		if (!filter_var($host, FILTER_VALIDATE_IP)) {
+			$ip = gethostbyname($host);
+			// If gethostbyname fails, it returns the hostname unchanged
+			if ($ip === $host) {
+				// Could not resolve - for security, block unresolvable hosts
+				return false;
+			}
+		}
+		
+		// Block private IP ranges (IPv4)
+		if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+			// Convert IP to long for range checking
+			$ipLong = ip2long($ip);
+			
+			// Private IP ranges:
+			// 10.0.0.0 - 10.255.255.255
+			// 172.16.0.0 - 172.31.255.255
+			// 192.168.0.0 - 192.168.255.255
+			// 169.254.0.0 - 169.254.255.255 (link-local)
+			// 127.0.0.0 - 127.255.255.255 (loopback)
+			$privateRanges = [
+				['10.0.0.0', '10.255.255.255'],
+				['172.16.0.0', '172.31.255.255'],
+				['192.168.0.0', '192.168.255.255'],
+				['169.254.0.0', '169.254.255.255'],
+				['127.0.0.0', '127.255.255.255']
+			];
+			
+			foreach ($privateRanges as $range) {
+				$rangeStart = ip2long($range[0]);
+				$rangeEnd = ip2long($range[1]);
+				if ($ipLong >= $rangeStart && $ipLong <= $rangeEnd) {
+					return false;
+				}
+			}
+		}
+		
+		// Block private/local IPv6 addresses
+		if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+			// Block IPv6 loopback (::1) and link-local (fe80::/10)
+			if (strpos($ip, '::1') === 0 || strpos($ip, 'fe80:') === 0 || strpos($ip, 'fc00:') === 0 || strpos($ip, 'fd00:') === 0) {
+				return false;
+			}
+		}
+		
+		// URL passed all checks - it's external
+		return true;
+	}
+
 	public function scrapePage($array)
 	{
 		try {
@@ -7470,6 +7558,11 @@ public function youtubeSearch($query)
 				return false;
 			}
 			$url = $this->qualifyURL($url);
+			// Security: Only allow external URLs, block local/internal resources
+			if (!$this->isExternalURL($url)) {
+				$this->setAPIResponse('error', 'Access to local or internal URLs is not allowed', 403);
+				return false;
+			}
 			$data = array(
 				'full_url' => $url,
 				'drill_url' => $this->qualifyURL($url, true)