Module: Ai2Web::Safety
- Defined in:
- lib/ai2web/safety.rb
Overview
SSRF guard. Parity with @ai2web/core safety.
Blocks the obvious pivots (loopback, private ranges, cloud metadata, link-local, non-http schemes) AND the alternative IP encodings that HTTP clients resolve to those same addresses (decimal / hex / octal / short-form IPv4, and IPv4-mapped IPv6). This is a literal host/IP check - not by itself DNS-rebind safe.
Constant Summary collapse
- IPV4_RE =
/\A(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z/.freeze
- IPV4_TAIL =
/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\z/.freeze
Class Method Summary collapse
- .assert_safe_public_url!(raw) ⇒ Object
-
.ipv4_blocked?(host) ⇒ Boolean
True if a standard dotted-quad is loopback/private/reserved (or has an invalid octet).
- .safe_public_url?(raw) ⇒ Boolean
- .same_origin?(a, b) ⇒ Boolean
Class Method Details
.assert_safe_public_url!(raw) ⇒ Object
70 71 72 73 74 |
# File 'lib/ai2web/safety.rb', line 70 def assert_safe_public_url!(raw) raise ArgumentError, "ai2w: refusing to fetch non-public or unsafe URL: #{raw}" unless safe_public_url?(raw) raw end |
.ipv4_blocked?(host) ⇒ Boolean
True if a standard dotted-quad is loopback/private/reserved (or has an invalid octet).
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
# File 'lib/ai2web/safety.rb', line 19 def ipv4_blocked?(host) m = IPV4_RE.match(host) return false unless m parts = m.captures.map(&:to_i) return true if parts.any? { |p| p > 255 } # not a real address; refuse a, b = parts[0], parts[1] return true if [0, 10, 127].include?(a) return true if a == 169 && b == 254 # link-local + cloud metadata (169.254.169.254) return true if a == 172 && b >= 16 && b <= 31 return true if a == 192 && b == 168 return true if a == 100 && b >= 64 && b <= 127 # CGNAT false end |
.safe_public_url?(raw) ⇒ Boolean
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/ai2web/safety.rb', line 36 def safe_public_url?(raw) begin u = URI.parse(raw.to_s) rescue URI::InvalidURIError return false end return false unless %w[https http].include?(u.scheme) host = (u.host || "").downcase.sub(/\A\[/, "").sub(/\]\z/, "") return false if host.empty? || host == "localhost" || host.end_with?(".localhost") # IPv6 literal. if host.include?(":") # IPv4-mapped / compat (::ffff:a.b.c.d, ::a.b.c.d): range-check the embedded IPv4. m = IPV4_TAIL.match(host) return false if m && ipv4_blocked?(m[1]) return false if host == "::1" || host.start_with?("fc", "fd", "fe80") return true end # Hex-encoded IP (0x7f000001, or a dotted octet like 0x7f): a client resolves these to an IP. return false if host.match?(/(^|\.)0x/) # Standard dotted-quad IPv4. return !ipv4_blocked?(host) if IPV4_RE.match?(host) # Any remaining all-numeric host is an alternative IPv4 encoding (decimal integer, octal, or # short form like 127.1) that a client resolves to an IP. No real domain looks like this. return false unless host.match?(/[a-z]/) true end |
.same_origin?(a, b) ⇒ Boolean
76 77 78 79 80 81 82 |
# File 'lib/ai2web/safety.rb', line 76 def same_origin?(a, b) pa = URI.parse(a.to_s) pb = URI.parse(b.to_s) [pa.scheme, pa.host, pa.port] == [pb.scheme, pb.host, pb.port] rescue URI::InvalidURIError false end |