diff --git a/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb b/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb index 4b6a69cd..990ca6c9 100644 --- a/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +++ b/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb @@ -85,9 +85,11 @@ def resolved_in_current_context # Matches strings that contain only characters that appear in some # form of IP address. # + # An IPv6 zone ID (e.g. "%eth0") may follow the address. + # # Hostnames are not expected to match, allowing `parse_address` to # skip the `Socket.getaddrinfo` call. - ADDRESS_REGEXP = /\A[0-9a-fx:.]+\z/i + ADDRESS_REGEXP = /\A[0-9a-fx:.]+(?:%[^%\s]+)?\z/i # Parses `address` as an IP address, in any form that is accepted # by `getaddrinfo`: @@ -96,6 +98,7 @@ def resolved_in_current_context # * Plain integer IPv4 notation, in decimal, octal, or hexadecimal # (e.g. "2130706433", "017700000001", "0x7f000001") # * Shorthand dotted IPv4 notation (e.g. "127.1") + # * IPv6 notation with zone ID (e.g. "fe80::1%eth0") # # Delegates to `Socket.getaddrinfo` with the `AI_NUMERICHOST` flag, # which never performs a DNS lookup. @@ -105,8 +108,10 @@ def resolved_in_current_context def parse_address(address) return nil unless address.is_a?(String) && ADDRESS_REGEXP.match?(address) - Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST) - .map { |info| IPAddr.new(info[3]) } + Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST).map do |info| + # `IPAddr.new` only accepts a zone ID on Ruby 3.1+. + IPAddr.new(info[3].partition("%").first) + end rescue SocketError nil end diff --git a/test/aikido/zen/scanners/ssrf/private_ip_checker_test.rb b/test/aikido/zen/scanners/ssrf/private_ip_checker_test.rb index 8c7e7fee..45126d04 100644 --- a/test/aikido/zen/scanners/ssrf/private_ip_checker_test.rb +++ b/test/aikido/zen/scanners/ssrf/private_ip_checker_test.rb @@ -114,6 +114,18 @@ def refute_private(address) refute_private "0X01020304" # 1.2.3.4 end + test "detects link-local IPv6 addresses with a numeric zone ID" do + assert_private "fe80::1%1" + end + + test "detects link-local IPv6 addresses with a named zone ID" do + interface_names = Socket.getifaddrs.map(&:name) + loopback = %w[lo lo0].find { |name| interface_names.include?(name) } + skip "no loopback interface found" unless loopback + + assert_private "fe80::1%#{loopback}" + end + test "detects _actually_ private (RFC 1918/RFC 4193) addresses" do # 10.0.0.0/8 assert_private "10.0.0.0"