Compare commits

...
Author SHA1 Message Date
Shivam MishraandGitHub b7d4fbd31d Merge branch 'develop' into fix/ssrf-upload-endpoint 2026-04-07 12:33:58 +05:30
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
Vinay Keerthi 61483c94f0 fix: use cached headingText variable for consistency
Reuse the previously cached headingText variable instead of accessing
element.innerText again to avoid unnecessary DOM access.
2026-01-12 16:38:58 +05:30
Vinay Keerthi 6e60e3b946 fix: use safe DOM manipulation for article heading permalinks
Fixes [CW-6300](https://linear.app/chatwoot/issue/CW-6300/advisory-stored-dom-xss-in-chatwoot-help-center-article-heading)
2026-01-12 16:18:35 +05:30
4 changed files with 377 additions and 1 deletions
@@ -29,13 +29,15 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
rescue URI::InvalidURIError, SocketError, UrlSafetyValidator::UnsafeUrlError, Resolv::ResolvError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
UrlSafetyValidator.validate!(uri.to_s)
end
def fetch_and_process_file_from_uri(uri)
+104
View File
@@ -0,0 +1,104 @@
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
@@ -96,6 +96,84 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
end
context 'with SSRF attack vectors' do
it 'blocks requests to private IP ranges (10.x.x.x)' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://10.0.0.1/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to private IP ranges (172.16.x.x)' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://172.16.0.1/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to private IP ranges (192.168.x.x)' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://192.168.1.1/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to loopback addresses' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://127.0.0.1/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to AWS metadata service (169.254.169.254)' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to localhost' do
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://localhost/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks requests to .local domains' do
allow(Resolv).to receive(:getaddresses).with('server.local').and_return(['192.168.1.100'])
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://server.local/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
it 'blocks DNS rebinding attacks (hostname resolving to private IP)' do
allow(Resolv).to receive(:getaddresses).with('evil.attacker.com').and_return(['10.0.0.1'])
post upload_url,
headers: user.create_new_auth_token,
params: { external_url: 'http://evil.attacker.com/secret' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Invalid URL provided')
end
end
end
it 'returns an error when no file or URL is provided' do
+192
View File
@@ -0,0 +1,192 @@
require 'rails_helper'
RSpec.describe UrlSafetyValidator do
describe '.safe?' do
context 'with safe URLs' do
it 'returns true for valid public URLs' do
expect(described_class.safe?('https://example.com/image.png')).to be true
expect(described_class.safe?('http://example.org/file.pdf')).to be true
end
end
context 'with unsafe URLs' do
it 'returns false for private IP ranges' do
expect(described_class.safe?('http://10.0.0.1/secret')).to be false
expect(described_class.safe?('http://172.16.0.1/secret')).to be false
expect(described_class.safe?('http://192.168.1.1/secret')).to be false
end
it 'returns false for loopback addresses' do
expect(described_class.safe?('http://127.0.0.1/secret')).to be false
expect(described_class.safe?('http://127.0.0.254/secret')).to be false
end
it 'returns false for AWS metadata service' do
expect(described_class.safe?('http://169.254.169.254/latest/meta-data/')).to be false
end
it 'returns false for localhost' do
expect(described_class.safe?('http://localhost/secret')).to be false
end
it 'returns false for invalid schemes' do
expect(described_class.safe?('ftp://example.com/file')).to be false
expect(described_class.safe?('file:///etc/passwd')).to be false
end
end
end
describe '.validate!' do
context 'with safe URLs' do
before do
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
end
it 'returns true for valid public URLs' do
expect(described_class.validate!('https://example.com/image.png')).to be true
end
end
context 'with private IP ranges' do
it 'raises UnsafeUrlError for Class A private IPs' do
expect { described_class.validate!('http://10.0.0.1/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for Class B private IPs' do
expect { described_class.validate!('http://172.16.0.1/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
expect { described_class.validate!('http://172.31.255.255/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for Class C private IPs' do
expect { described_class.validate!('http://192.168.1.1/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
end
context 'with loopback addresses' do
it 'raises UnsafeUrlError for localhost IP' do
expect { described_class.validate!('http://127.0.0.1/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for any loopback IP' do
expect { described_class.validate!('http://127.0.0.254/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
end
context 'with AWS/cloud metadata service' do
it 'raises UnsafeUrlError for link-local IPs (169.254.x.x)' do
expect { described_class.validate!('http://169.254.169.254/latest/meta-data/') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for AWS metadata IP as hostname' do
expect { described_class.validate!('http://169.254.169.254/latest/meta-data/iam/security-credentials/') }
.to raise_error(described_class::UnsafeUrlError)
end
it 'raises UnsafeUrlError for GCP metadata hostname' do
expect { described_class.validate!('http://metadata.google.internal/computeMetadata/v1/') }
.to raise_error(described_class::UnsafeUrlError, /Blocked hostname/)
end
end
context 'with blocked hostnames' do
it 'raises UnsafeUrlError for localhost' do
expect { described_class.validate!('http://localhost/secret') }
.to raise_error(described_class::UnsafeUrlError, /Blocked hostname/)
end
it 'raises UnsafeUrlError for .local domains' do
allow(Resolv).to receive(:getaddresses).with('server.local').and_return(['192.168.1.100'])
expect { described_class.validate!('http://server.local/secret') }
.to raise_error(described_class::UnsafeUrlError, /\.local is not allowed/)
end
it 'raises UnsafeUrlError for .internal domains' do
allow(Resolv).to receive(:getaddresses).with('server.internal').and_return(['10.0.0.1'])
expect { described_class.validate!('http://server.internal/secret') }
.to raise_error(described_class::UnsafeUrlError, /\.internal is not allowed/)
end
it 'raises UnsafeUrlError for kubernetes.default.svc' do
expect { described_class.validate!('http://kubernetes.default.svc/api') }
.to raise_error(described_class::UnsafeUrlError, /Blocked hostname/)
end
end
context 'with DNS rebinding protection' do
it 'raises UnsafeUrlError when hostname resolves to private IP' do
allow(Resolv).to receive(:getaddresses).with('evil.com').and_return(['10.0.0.1'])
expect { described_class.validate!('http://evil.com/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError when any resolved IP is blocked' do
allow(Resolv).to receive(:getaddresses).with('multi.com').and_return(['93.184.216.34', '127.0.0.1'])
expect { described_class.validate!('http://multi.com/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
end
context 'with invalid URLs' do
it 'raises UnsafeUrlError for non-HTTP schemes' do
expect { described_class.validate!('ftp://example.com/file') }
.to raise_error(described_class::UnsafeUrlError, /HTTP or HTTPS/)
end
it 'raises URI::InvalidURIError for malformed URLs' do
expect { described_class.validate!('not_a_url') }
.to raise_error(URI::InvalidURIError)
end
it 'raises UnsafeUrlError for URLs without host' do
expect { described_class.validate!('http:///path') }
.to raise_error(described_class::UnsafeUrlError, /valid host/)
end
end
context 'with IPv6 addresses' do
it 'raises UnsafeUrlError for IPv6 loopback' do
expect { described_class.validate!('http://[::1]/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for IPv6 unique local addresses' do
expect { described_class.validate!('http://[fc00::1]/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for IPv6 link-local addresses' do
expect { described_class.validate!('http://[fe80::1]/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
end
context 'with special IP ranges' do
it 'raises UnsafeUrlError for current network (0.0.0.0/8)' do
expect { described_class.validate!('http://0.0.0.1/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for carrier-grade NAT' do
expect { described_class.validate!('http://100.64.0.1/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for multicast addresses' do
expect { described_class.validate!('http://224.0.0.1/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
it 'raises UnsafeUrlError for broadcast address' do
expect { described_class.validate!('http://255.255.255.255/secret') }
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
end
end
end
end