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) uri = URI.parse(url)
validate_uri(uri) validate_uri(uri)
uri uri
rescue URI::InvalidURIError, SocketError rescue URI::InvalidURIError, SocketError, UrlSafetyValidator::UnsafeUrlError, Resolv::ResolvError
render_error('Invalid URL provided', :unprocessable_entity) render_error('Invalid URL provided', :unprocessable_entity)
nil nil
end end
def validate_uri(uri) def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
UrlSafetyValidator.validate!(uri.to_s)
end end
def fetch_and_process_file_from_uri(uri) 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).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL') expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
end 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 end
it 'returns an error when no file or URL is provided' do 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