Block SSRF via backchannel_logout_uri
backchannel_logout_uri was validated only for scheme/HTTPS, so an admin (or a compromised admin account) could point it at internal infrastructure — cloud metadata (169.254.169.254), loopback, or RFC1918 hosts — and every user logout would fire a server-side POST there. Add PrivateAddressCheck (app/lib) and apply it as defense-in-depth: - Application validation rejects URIs whose host is, or is a literal, internal address (loopback / private / link-local / 0.0.0.0 / localhost / metadata hostnames). Fast, DNS-free, immediate admin feedback. - BackchannelLogoutJob re-checks at request time WITH DNS resolution and aborts (no retry) if the host resolves to a non-public address — covering URIs that predate the validation and public hostnames pointed at internal IPs. Tests cover the address classification, the model validation, and updates an existing test that used a localhost logout URI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
57
app/lib/private_address_check.rb
Normal file
57
app/lib/private_address_check.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
require "ipaddr"
|
||||
require "resolv"
|
||||
|
||||
# SSRF guard for outbound requests to admin-configured URLs (currently the OIDC
|
||||
# backchannel logout endpoint). Blocks hosts that are, or resolve to, private,
|
||||
# loopback, link-local (incl. the cloud metadata address 169.254.169.254) or
|
||||
# otherwise non-public address space.
|
||||
module PrivateAddressCheck
|
||||
module_function
|
||||
|
||||
# Hostnames that are internal by definition and must never be dialled.
|
||||
BLOCKED_HOSTNAMES = %w[localhost metadata.google.internal].freeze
|
||||
|
||||
# Fast, DNS-free check: catches IP literals and well-known internal hostnames.
|
||||
# Suitable for model validation (deterministic, immediate admin feedback).
|
||||
def internal_host?(host)
|
||||
host = host.to_s.downcase
|
||||
return true if host.blank?
|
||||
return true if BLOCKED_HOSTNAMES.include?(host)
|
||||
return true if host.end_with?(".localhost")
|
||||
|
||||
ip = parse_ip(host)
|
||||
ip ? internal_ip?(ip) : false
|
||||
end
|
||||
|
||||
# Authoritative check: resolves the hostname and blocks if ANY address is
|
||||
# internal. Suitable for request time — also defeats a public hostname that
|
||||
# has been pointed at an internal IP (DNS rebinding to internal space).
|
||||
def resolves_to_internal?(host)
|
||||
addresses(host).any? { |ip| internal_ip?(ip) }
|
||||
end
|
||||
|
||||
def addresses(host)
|
||||
ip = parse_ip(host)
|
||||
return [ip] if ip
|
||||
|
||||
Resolv.getaddresses(host.to_s).filter_map { |a| parse_ip(a) }
|
||||
rescue StandardError
|
||||
# Resolution failure: surface no addresses. Callers treat "can't resolve" as
|
||||
# not-provably-internal; the dial itself will then fail safely.
|
||||
[]
|
||||
end
|
||||
|
||||
def internal_ip?(ip)
|
||||
ip.loopback? || ip.private? || ip.link_local? || unspecified?(ip)
|
||||
end
|
||||
|
||||
def parse_ip(str)
|
||||
IPAddr.new(str.to_s)
|
||||
rescue IPAddr::Error
|
||||
nil
|
||||
end
|
||||
|
||||
def unspecified?(ip)
|
||||
ip == IPAddr.new("0.0.0.0") || ip == IPAddr.new("::")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user