Module: Mistri::MCP::Egress
- Defined in:
- lib/mistri/mcp/egress.rb
Overview
Resolves untrusted MCP URLs onto globally reachable addresses and pins that decision to the eventual connection. A host may narrowly approve a non-public address, but cannot bypass URL shape or plaintext rules.
Defined Under Namespace
Classes: Target
Constant Summary collapse
- NAT64_WELL_KNOWN =
IPAddr.new("64:ff9b::/96")
- IPV6_GLOBAL_UNICAST =
IPAddr.new("2000::/3")
- GLOBAL_EXCEPTIONS =
IANA IPv4/IPv6 Special-Purpose Address Registries, updated 2025-10-09. Broad non-global ranges contain a few globally reachable assignments. https://www.iana.org/assignments/iana-ipv4-special-registry/ https://www.iana.org/assignments/iana-ipv6-special-registry/ https://www.iana.org/assignments/ipv6-address-space/
%w[ 192.0.0.9/32 192.0.0.10/32 192.31.196.0/24 192.52.193.0/24 192.175.48.0/24 2001:1::1/128 2001:1::2/128 2001:1::3/128 2001:3::/32 2001:4:112::/48 2001:20::/28 2001:30::/28 2620:4f:8000::/48 ].map { |range| IPAddr.new(range) }.freeze
- NON_GLOBAL =
%w[ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 ::/96 64:ff9b:1::/48 100::/64 100:0:0:1::/64 2001::/23 2001:db8::/32 2002::/16 3fff::/20 5f00::/16 fc00::/7 fe80::/10 fec0::/10 ff00::/8 ].map { |range| IPAddr.new(range) }.freeze
Class Method Summary collapse
- .approved_address(uri, addresses, allow_non_public: nil, label: "URL") ⇒ Object
- .approved_addresses(uri, addresses, allow_non_public: nil, label: "URL") ⇒ Object
- .display(uri) ⇒ Object
- .globally_reachable?(address) ⇒ Boolean
- .normalize(url, label = "URL") ⇒ Object
- .origin(uri) ⇒ Object
- .parse(url, label) ⇒ Object
- .parse_address(value) ⇒ Object
- .redirect_uri(url) ⇒ Object
- .resolve(uri, label, timeout, lookup: nil) ⇒ Object
- .resolver(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) ⇒ Object
- .target(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) ⇒ Object
- .targets(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) ⇒ Object
- .validate_exception(exception) ⇒ Object
Class Method Details
.approved_address(uri, addresses, allow_non_public: nil, label: "URL") ⇒ Object
120 121 122 |
# File 'lib/mistri/mcp/egress.rb', line 120 def approved_address(uri, addresses, allow_non_public: nil, label: "URL") approved_addresses(uri, addresses, allow_non_public:, label:).first end |
.approved_addresses(uri, addresses, allow_non_public: nil, label: "URL") ⇒ Object
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/mistri/mcp/egress.rb', line 98 def approved_addresses(uri, addresses, allow_non_public: nil, label: "URL") validate_exception(allow_non_public) if addresses.empty? raise UnsafeURLError, "#{label} host #{uri.hostname.inspect} resolved to no addresses" end rejected = addresses.reject do |address| policy_uri = URI(uri.to_s).freeze policy_address = address.dup.freeze globally_reachable?(address) || allow_non_public&.call(policy_uri, policy_address) end unless rejected.empty? raise UnsafeURLError, "#{label} host #{uri.hostname.inspect} resolves to a non-public address" end if uri.scheme == "http" && !addresses.all?(&:loopback?) raise UnsafeURLError, "#{label} must use HTTPS" end addresses end |
.display(uri) ⇒ Object
153 154 155 156 157 |
# File 'lib/mistri/mcp/egress.rb', line 153 def display(uri) value = uri.dup value.query = nil value.to_s end |
.globally_reachable?(address) ⇒ Boolean
159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/mistri/mcp/egress.rb', line 159 def globally_reachable?(address) address = address.native if address.ipv4_mapped? if NAT64_WELL_KNOWN.include?(address) = IPAddr.new(address.to_i & 0xffffffff, Socket::AF_INET) return globally_reachable?() end return true if GLOBAL_EXCEPTIONS.any? { |range| range.include?(address) } return false if address.ipv6? && !IPV6_GLOBAL_UNICAST.include?(address) NON_GLOBAL.none? { |range| range.include?(address) } end |
.normalize(url, label = "URL") ⇒ Object
124 125 126 127 128 129 130 131 132 133 |
# File 'lib/mistri/mcp/egress.rb', line 124 def normalize(url, label = "URL") uri = parse(url, label) uri.scheme = uri.scheme.downcase uri.host = uri.host.downcase uri.port = nil if uri.port == uri.default_port uri.path = "" if uri.path == "/" uri rescue URI::Error, ArgumentError raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL" end |
.origin(uri) ⇒ Object
146 147 148 149 150 151 |
# File 'lib/mistri/mcp/egress.rb', line 146 def origin(uri) value = uri.dup value.path = "" value.query = nil value.to_s end |
.parse(url, label) ⇒ Object
200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/mistri/mcp/egress.rb', line 200 def parse(url, label) uri = URI.parse(url.to_s) unless uri.is_a?(URI::HTTP) && %w[http https].include?(uri.scheme) && !uri.hostname.to_s.empty? raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL" end raise UnsafeURLError, "#{label} must not contain credentials" if uri.user || uri.password raise UnsafeURLError, "#{label} must not contain a fragment" if uri.fragment raise UnsafeURLError, "#{label} has an invalid port" unless (1..65_535).cover?(uri.port) uri rescue URI::Error, ArgumentError raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL" end |
.parse_address(value) ⇒ Object
188 189 190 191 192 |
# File 'lib/mistri/mcp/egress.rb', line 188 def parse_address(value) IPAddr.new(value) rescue IPAddr::Error nil end |
.redirect_uri(url) ⇒ Object
135 136 137 138 139 140 141 142 143 144 |
# File 'lib/mistri/mcp/egress.rb', line 135 def redirect_uri(url) value = url.to_s uri = parse(value, "redirect_uri") return value if uri.scheme == "https" address = parse_address(uri.hostname) return value if uri.hostname.casecmp?("localhost") || address&.loopback? raise UnsafeURLError, "redirect_uri must use HTTPS or an explicit loopback address" end |
.resolve(uri, label, timeout, lookup: nil) ⇒ Object
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/mistri/mcp/egress.rb', line 171 def resolve(uri, label, timeout, lookup: nil) lookup ||= Addrinfo.method(:getaddrinfo) entries = lookup.call(uri.hostname, uri.port, nil, :STREAM, timeout: timeout) found = entries.filter_map do |entry| address = parse_address(entry.ip_address) address&.ipv4_mapped? ? address.native : address end.uniq if found.empty? raise UnsafeURLError, "#{label} host #{uri.hostname.inspect} resolved to no addresses" end found rescue SocketError, IOError, Timeout::Error raise UnsafeURLError, "#{label} host #{uri.hostname.inspect} could not be resolved" end |
.resolver(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) ⇒ Object
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/mistri/mcp/egress.rb', line 81 def resolver(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) validate_exception(allow_non_public) uri = normalize(url, label) if uri.scheme == "http" && allow_non_public.nil? raise UnsafeURLError, "#{label} must use HTTPS" end snapshot = uri.to_s.freeze resolve_addresses = lambda do target = URI(snapshot) addresses = resolve(target, label, timeout, lookup: lookup) approved_addresses(target, addresses, allow_non_public:, label:) .map(&:to_s).freeze end [URI(snapshot), resolve_addresses] end |
.target(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) ⇒ Object
72 73 74 |
# File 'lib/mistri/mcp/egress.rb', line 72 def target(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) targets(url, allow_non_public:, label:, timeout:, lookup:).first end |
.targets(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) ⇒ Object
76 77 78 79 |
# File 'lib/mistri/mcp/egress.rb', line 76 def targets(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil) uri, address_resolver = resolver(url, allow_non_public:, label:, timeout:, lookup:) address_resolver.call.map { |address| Target.new(uri:, address:) } end |
.validate_exception(exception) ⇒ Object
194 195 196 197 198 |
# File 'lib/mistri/mcp/egress.rb', line 194 def validate_exception(exception) return if exception.nil? || exception.respond_to?(:call) raise ConfigurationError, "allow_non_public: must be callable" end |