Files
chatwoot/spec/lib/url_safety_validator_spec.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

193 lines
7.8 KiB
Ruby

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