Files
chatwoot/lib/url_safety_validator.rb
Vinay Keerthi ef7b31d202 fix: add SSRF protection to upload endpoint
Add URL safety validation to prevent Server-Side Request Forgery attacks
in the file upload endpoint. The new UrlSafetyValidator blocks:

- Private IP ranges (10.x, 172.16.x, 192.168.x)
- Loopback addresses (127.x)
- Link-local addresses (169.254.x) including AWS metadata service
- Cloud metadata hostnames (localhost, metadata.google.internal, etc.)
- DNS rebinding attacks by validating resolved IP addresses

Fixes: https://linear.app/chatwoot/issue/CW-6303
2026-01-12 16:50:36 +05:30

105 lines
3.2 KiB
Ruby

require 'resolv'
require 'ipaddr'
module UrlSafetyValidator
# Blocked IP ranges for SSRF protection
BLOCKED_IP_RANGES = [
IPAddr.new('10.0.0.0/8'), # Private Class A
IPAddr.new('172.16.0.0/12'), # Private Class B
IPAddr.new('192.168.0.0/16'), # Private Class C
IPAddr.new('127.0.0.0/8'), # Loopback
IPAddr.new('169.254.0.0/16'), # Link-local (AWS metadata service)
IPAddr.new('0.0.0.0/8'), # Current network
IPAddr.new('100.64.0.0/10'), # Carrier-grade NAT
IPAddr.new('192.0.0.0/24'), # IETF Protocol Assignments
IPAddr.new('192.0.2.0/24'), # TEST-NET-1
IPAddr.new('198.51.100.0/24'), # TEST-NET-2
IPAddr.new('203.0.113.0/24'), # TEST-NET-3
IPAddr.new('224.0.0.0/4'), # Multicast
IPAddr.new('240.0.0.0/4'), # Reserved
IPAddr.new('255.255.255.255/32'), # Broadcast
# IPv6 ranges
IPAddr.new('::1/128'), # IPv6 loopback
IPAddr.new('fc00::/7'), # IPv6 unique local
IPAddr.new('fe80::/10'), # IPv6 link-local
IPAddr.new('ff00::/8') # IPv6 multicast
].freeze
BLOCKED_HOSTNAMES = [
'localhost',
'metadata.google.internal',
'169.254.169.254',
'metadata',
'kubernetes.default.svc',
'kubernetes.default'
].freeze
class UnsafeUrlError < StandardError; end
class << self
def safe?(url)
validate!(url)
true
rescue UnsafeUrlError, URI::InvalidURIError, SocketError, Resolv::ResolvError
false
end
def validate!(url)
uri = parse_and_validate_scheme(url)
validate_host!(uri)
resolve_and_validate_ip!(uri)
true
end
private
def parse_and_validate_scheme(url)
uri = URI.parse(url)
raise UnsafeUrlError, 'URL must use HTTP or HTTPS' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
uri
end
def validate_host!(uri)
host = uri.host&.downcase
raise UnsafeUrlError, 'URL must have a valid host' if host.blank?
raise UnsafeUrlError, 'Blocked hostname' if BLOCKED_HOSTNAMES.include?(host)
raise UnsafeUrlError, 'Hostname ending with .local is not allowed' if host.end_with?('.local')
raise UnsafeUrlError, 'Hostname ending with .internal is not allowed' if host.end_with?('.internal')
# Check if host is an IP address directly
validate_ip_not_blocked!(host) if ip_address?(host)
end
def resolve_and_validate_ip!(uri)
host = uri.host
# Resolve DNS to get actual IP addresses
addresses = Resolv.getaddresses(host)
raise UnsafeUrlError, 'Could not resolve hostname' if addresses.empty?
# Validate all resolved IPs
addresses.each do |addr|
validate_ip_not_blocked!(addr)
end
end
def validate_ip_not_blocked!(ip_string)
ip = IPAddr.new(ip_string)
BLOCKED_IP_RANGES.each do |range|
raise UnsafeUrlError, "IP address #{ip_string} is in a blocked range" if range.include?(ip)
end
rescue IPAddr::InvalidAddressError
raise UnsafeUrlError, "Invalid IP address: #{ip_string}"
end
def ip_address?(host)
IPAddr.new(host)
true
rescue IPAddr::InvalidAddressError
false
end
end
end