CVE-2023-28111: Server-Side Request Forgery (SSRF) (CWE-918)
value: Discourse is an open-source discussion platform. Prior to version 3.1.0.beta3 of the `beta` and `tests-passed` branches, attackers are able to bypass Discourse's server-side request forgery (SSRF) protection for private IPv4 addresses by using a IPv4-mapped IPv6 address. The issue is patched in the latest beta and tests-passed version of Discourse. version 3.1.0.beta3 of the `beta` and `tests-passed` branches. There are no known workarounds.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 7.5
Published: 2023-03-17T17:15Z
Repository: https://github.com/discourse/discourse
Language: Ruby
Files changed: lib/final_destination/ssrf_detector.rb, spec/lib/final_destination/ssrf_detector_spec.rb
Commit date: 2023-03-16 15:27:09 -0600
Commit message: SECURITY: SSRF protection bypass with IPv4-mapped IPv6 addresses

As part of this commit, we've also expanded our list of private IP
ranges based on
https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
and https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml

Diff: diff --git a/lib/final_destination/ssrf_detector.rb b/lib/final_destination/ssrf_detector.rb
index d9953044ded2..7a3bf1f0369d 100644
--- a/lib/final_destination/ssrf_detector.rb
+++ b/lib/final_destination/ssrf_detector.rb
@@ -5,22 +5,51 @@ class FinalDestination
     class DisallowedIpError < SocketError
     end
     class LookupFailedError < SocketError
     end
 
-    def self.standard_private_ranges
-      @private_ranges ||= [
-        IPAddr.new("0.0.0.0/8"),
-        IPAddr.new("127.0.0.1"),
-        IPAddr.new("172.16.0.0/12"),
-        IPAddr.new("192.168.0.0/16"),
-        IPAddr.new("10.0.0.0/8"),
-        IPAddr.new("::1"),
-        IPAddr.new("fc00::/7"),
-        IPAddr.new("fe80::/10"),
-      ]
-    end
+    # This is a list of private IPv4 IP ranges that are not allowed to be globally reachable as given by
+    # https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml.
+    PRIVATE_IPV4_RANGES = [
+      IPAddr.new("0.0.0.0/8"),
+      IPAddr.new("10.0.0.0/8"),
+      IPAddr.new("100.64.0.0/10"),
+      IPAddr.new("127.0.0.0/8"),
+      IPAddr.new("169.254.0.0/16"),
+      IPAddr.new("172.16.0.0/12"),
+      IPAddr.new("192.0.0.0/24"),
+      IPAddr.new("192.0.0.0/29"),
+      IPAddr.new("192.0.0.8/32"),
+      IPAddr.new("192.0.0.170/32"),
+      IPAddr.new("192.0.0.171/32"),
+      IPAddr.new("192.0.2.0/24"),
+      IPAddr.new("192.168.0.0/16"),
+      IPAddr.new("192.175.48.0/24"),
+      IPAddr.new("198.18.0.0/15"),
+      IPAddr.new("198.51.100.0/24"),
+      IPAddr.new("203.0.113.0/24"),
+      IPAddr.new("240.0.0.0/4"),
+      IPAddr.new("255.255.255.255/32"),
+    ]
+
+    # This is a list of private IPv6 IP ranges that are not allowed to be globally reachable as given by
+    # https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml.
+    #
+    # ::ffff:0:0/96 is excluded from the list because it is used for IPv4-mapped IPv6 addresses which is something we want to allow.
+    PRIVATE_IPV6_RANGES = [
+      IPAddr.new("::1/128"),
+      IPAddr.new("::/128"),
+      IPAddr.new("64:ff9b:1::/48"),
+      IPAddr.new("100::/64"),
+      IPAddr.new("2001::/23"),
+      IPAddr.new("2001:2::/48"),
+      IPAddr.new("2001:db8::/32"),
+      IPAddr.new("fc00::/7"),
+      IPAddr.new("fe80::/10"),
+    ]
+
+    PRIVATE_IP_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES
 
     def self.blocked_ip_blocks
       SiteSetting
         .blocked_ip_blocks
         .split(/[|\n]/)
@@ -52,14 +81,13 @@ class FinalDestination
       allowed_internal_hosts.any? { |h| h.downcase == hostname.downcase }
     end
 
     def self.ip_allowed?(ip)
       ip = ip.is_a?(IPAddr) ? ip : IPAddr.new(ip)
+      ip = ip.native
 
-      if ip_in_ranges?(ip, blocked_ip_blocks) || ip_in_ranges?(ip, standard_private_ranges)
-        return false
-      end
+      return false if ip_in_ranges?(ip, blocked_ip_blocks) || ip_in_ranges?(ip, PRIVATE_IP_RANGES)
 
       true
     end
 
     def self.lookup_and_filter_ips(name, timeout: nil)
diff --git a/spec/lib/final_destination/ssrf_detector_spec.rb b/spec/lib/final_destination/ssrf_detector_spec.rb
index 6d24a9ca3af9..8479f8bf159b 100644
--- a/spec/lib/final_destination/ssrf_detector_spec.rb
+++ b/spec/lib/final_destination/ssrf_detector_spec.rb
@@ -41,13 +41,23 @@ describe FinalDestination::SSRFDetector do
       SiteSetting.blocked_ip_blocks = "98.0.0.0/8|78.13.47.0/24|9001:82f3::/32"
       expect(subject.ip_allowed?("98.23.19.111")).to eq(false)
       expect(subject.ip_allowed?("9001:82f3:8873::3")).to eq(false)
     end
 
-    it "returns false for standard internal IPs" do
-      expect(subject.ip_allowed?("172.31.100.31")).to eq(false)
-      expect(subject.ip_allowed?("fd02:77fa:ffea::f")).to eq(false)
+    %w[0.0.0.0 10.0.0.0 127.0.0.0 172.31.100.31 255.255.255.255 ::1 ::].each do |internal_ip|
+      it "returns false for '#{internal_ip}'" do
+        expect(subject.ip_allowed?(internal_ip)).to eq(false)
+      end
+    end
+
+    it "returns false for private IPv4-mapped IPv6 addresses" do
+      expect(subject.ip_allowed?("::ffff:172.31.100.31")).to eq(false)
+      expect(subject.ip_allowed?("::ffff:0.0.0.0")).to eq(false)
+    end
+
+    it "returns true for public IPv4-mapped IPv6 addresses" do
+      expect(subject.ip_allowed?("::ffff:52.52.167.244")).to eq(true)
     end
   end
 
   describe ".host_bypasses_checks?" do
     it "returns true for URLs when allowed_internal_hosts allows the host" do

Vulnerable code:     def self.standard_private_ranges
      @private_ranges ||= [
        IPAddr.new("0.0.0.0/8"),
        IPAddr.new("127.0.0.1"),
        IPAddr.new("172.16.0.0/12"),
        IPAddr.new("192.168.0.0/16"),
        IPAddr.new("10.0.0.0/8"),
        IPAddr.new("::1"),
        IPAddr.new("fc00::/7"),
        IPAddr.new("fe80::/10"),
      ]
    end
      if ip_in_ranges?(ip, blocked_ip_blocks) || ip_in_ranges?(ip, standard_private_ranges)
        return false
      end
    it "returns false for standard internal IPs" do
      expect(subject.ip_allowed?("172.31.100.31")).to eq(false)
      expect(subject.ip_allowed?("fd02:77fa:ffea::f")).to eq(false)
Fixed code:     # This is a list of private IPv4 IP ranges that are not allowed to be globally reachable as given by
    # https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml.
    PRIVATE_IPV4_RANGES = [
      IPAddr.new("0.0.0.0/8"),
      IPAddr.new("10.0.0.0/8"),
      IPAddr.new("100.64.0.0/10"),
      IPAddr.new("127.0.0.0/8"),
      IPAddr.new("169.254.0.0/16"),
      IPAddr.new("172.16.0.0/12"),
      IPAddr.new("192.0.0.0/24"),
      IPAddr.new("192.0.0.0/29"),
      IPAddr.new("192.0.0.8/32"),
      IPAddr.new("192.0.0.170/32"),
      IPAddr.new("192.0.0.171/32"),
      IPAddr.new("192.0.2.0/24"),
      IPAddr.new("192.168.0.0/16"),
      IPAddr.new("192.175.48.0/24"),
      IPAddr.new("198.18.0.0/15"),
      IPAddr.new("198.51.100.0/24"),
      IPAddr.new("203.0.113.0/24"),
      IPAddr.new("240.0.0.0/4"),
      IPAddr.new("255.255.255.255/32"),
    ]

    # This is a list of private IPv6 IP ranges that are not allowed to be globally reachable as given by
    # https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml.
    #
    # ::ffff:0:0/96 is excluded from the list because it is used for IPv4-mapped IPv6 addresses which is something we want to allow.
    PRIVATE_IPV6_RANGES = [
      IPAddr.new("::1/128"),
      IPAddr.new("::/128"),
      IPAddr.new("64:ff9b:1::/48"),
      IPAddr.new("100::/64"),
      IPAddr.new("2001::/23"),
      IPAddr.new("2001:2::/48"),
      IPAddr.new("2001:db8::/32"),
      IPAddr.new("fc00::/7"),
      IPAddr.new("fe80::/10"),
    ]

    PRIVATE_IP_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES
      ip = ip.native
      return false if ip_in_ranges?(ip, blocked_ip_blocks) || ip_in_ranges?(ip, PRIVATE_IP_RANGES)
    %w[0.0.0.0 10.0.0.0 127.0.0.0 172.31.100.31 255.255.255.255 ::1 ::].each do |internal_ip|
      it "returns false for '#{internal_ip}'" do
        expect(subject.ip_allowed?(internal_ip)).to eq(false)
      end
    end

    it "returns false for private IPv4-mapped IPv6 addresses" do
      expect(subject.ip_allowed?("::ffff:172.31.100.31")).to eq(false)
      expect(subject.ip_allowed?("::ffff:0.0.0.0")).to eq(false)
    end

    it "returns true for public IPv4-mapped IPv6 addresses" do
      expect(subject.ip_allowed?("::ffff:52.52.167.244")).to eq(true)