Class: Otto::Security::TrustedProxyConfig
- Inherits:
-
Object
- Object
- Otto::Security::TrustedProxyConfig
- Includes:
- Core::Freezable
- Defined in:
- lib/otto/security/trusted_proxy_config.rb
Overview
Trusted-proxy resolution settings for one Otto application.
An application answers "which peer may speak for the client?" in at most one of three ways (#mode):
- :filter — enumerated proxy entries (IP, CIDR, Regexp, or a legacy string prefix); the client IP is found by walking the forwarded chain past trusted hops.
- :depth — trust the last N hops, for proxy tiers whose addresses cannot be enumerated (Fly, cloud load balancers, dynamic reverse proxies).
- :none — the explicit operator assertion that no proxy is trusted.
#header picks the forwarded header depth mode counts hops from.
This object owns the rules that keep those settings coherent. #mode is derived from the stored settings rather than stored beside them, and every mutator checks the state it would produce with #ensure_compatible!, the same check #validate! runs at freeze, so each rule is written once.
Rules that involve other objects stay with Otto::Security::Config: the ip_privacy geo_header vs depth conflict, and pinning Rack's process-global forwarding family. Config keeps this object private and routes every change through its own setters (add_trusted_proxy, trusted_proxy_depth=, trusted_proxy_header=, trust_no_proxies!), which add those checks; #check_depth! and #check_header! let Config run them before anything is stored.
Constant Summary collapse
- PROXY_MODE_CONFLICT_MESSAGE =
Error raised when the two mutually-exclusive trusted-proxy resolution modes are configured together: CIDR-walk (enumerated trusted_proxies) and count-based depth (trusted_proxy_depth >= 1).
"Cannot configure both trusted_proxies (CIDR filter mode) and\ntrusted_proxy_depth >= 1 (count mode). Enumerate proxy CIDRs OR set a\nhop count, not both.\n".gsub(/\s+/, ' ').strip.freeze
- TRUST_NO_PROXIES_CONFLICT_MESSAGE =
Error raised when the explicit "trust no proxy" assertion (trust_no_proxies!,
trusted_proxies: :none) is combined with an actual trust grant (enumerated CIDRs or a depth >= 1). The two say opposite things about the same peer, so the combination is refused at configuration time rather than silently resolved in one direction. "Cannot combine trusted_proxies: :none (trust no proxy) with\ntrusted_proxies CIDRs or trusted_proxy_depth >= 1. Assert :none OR\ngrant trust, not both.\n".gsub(/\s+/, ' ').strip.freeze
- TRUST_NO_PROXIES_ENTRY_MESSAGE =
Error raised when the trust-nobody sentinel arrives as a proxy ENTRY (
trusted_proxies: ['none'], as a YAML/JSON list naturally yields, oradd_trusted_proxy('none')) instead of as the whole option. Inside a list it would otherwise register a legacy string-prefix matcher that matches nothing: peers would be untrusted, but trust_no_proxies? would stay false and the config would stake a forwarding-family claim, so the explicit assertion would be silently replaced by a lookalike. "trusted_proxies entry :none is the trust-nobody assertion, not a proxy\naddress. Pass trusted_proxies: :none as the whole option (not inside a\nlist) or call trust_no_proxies! instead.\n".gsub(/\s+/, ' ').strip.freeze
- FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE =
Error raised when a non-default header is combined with CIDR filter mode. Otto's CIDR-walk resolves the client IP from the X-Forwarded-For family only (X-Forwarded-For, then X-Real-IP, then X-Client-IP — Otto::Utils::FORWARDED_FOR_HEADERS), never RFC 7239 Forwarded, while trusted_proxy_header also pins Rack's forwarding family; honoring 'Forwarded' or 'Both' there would make Rack read a header Otto ignores, recreating the disagreement the pin exists to close.
"Cannot configure trusted_proxy_header 'Forwarded' or 'Both' together\nwith trusted_proxies (CIDR filter mode): CIDR-walk resolves client IPs\nfrom the X-Forwarded-For family only (X-Forwarded-For, X-Real-IP,\nX-Client-IP), never RFC 7239 Forwarded. Use trusted_proxy_depth (count\nmode) to read the RFC 7239 Forwarded header.\n".gsub(/\s+/, ' ').strip.freeze
- TRUST_NO_PROXIES =
Sentinel accepted wherever a trusted_proxies list is accepted, meaning "the operator asserts that NO proxy is trusted". See #trust_none!.
:none- HEADERS =
Forwarded-header sources depth mode can count hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or Both (Forwarded when present, else X-Forwarded-For). Mirrors OneTimeSecret's site.network.trusted_proxy.header. Only consulted in depth mode; CIDR-walk is unaffected.
%w[X-Forwarded-For Forwarded Both].freeze
- DEFAULT_HEADER =
'X-Forwarded-For'
Instance Attribute Summary collapse
-
#depth ⇒ Integer?
Count-based depth; nil or 0 disables depth mode.
-
#header ⇒ String
Canonical forwarded header depth mode counts hops from.
-
#proxies ⇒ Array<String, Regexp>
readonly
Proxy entries in registration order (filter mode).
Class Method Summary collapse
-
.trust_no_proxies_option?(value) ⇒ Boolean
Whether a trusted_proxies option value is the trust-nobody sentinel.
Instance Method Summary collapse
-
#add(proxy) ⇒ void
Register one entry or a list of entries (filter mode).
-
#check_depth!(depth) ⇒ Integer?
Raise unless depth could be assigned: a non-negative Integer or nil, compatible with the current mode.
-
#check_header!(header) ⇒ String
Raise unless header could be assigned, and return its canonical spelling.
-
#configured? ⇒ Boolean
Whether any mode is configured.
-
#default_header? ⇒ Boolean
Whether #header is the X-Forwarded-For default.
-
#depth? ⇒ Boolean
Whether count-based depth mode is active.
-
#filter? ⇒ Boolean
Whether proxy entries are registered (CIDR filter mode).
-
#forwarding_family_dependent? ⇒ Boolean
Whether request handling reads a forwarded chain, and so depends on Rack's process-global forwarding family.
-
#initialize ⇒ TrustedProxyConfig
constructor
A new instance of TrustedProxyConfig.
-
#mode ⇒ Symbol?
The active resolution mode, or nil when proxy trust is unconfigured.
-
#trust_none! ⇒ void
Assert that no proxy is trusted.
-
#trust_none? ⇒ Boolean
Whether the operator asserted that no proxy is trusted.
-
#trusted?(ip) ⇒ Boolean
Whether ip matches a registered entry.
-
#validate! ⇒ void
Re-check every rule against the stored state.
Methods included from Core::Freezable
Constructor Details
#initialize ⇒ TrustedProxyConfig
Returns a new instance of TrustedProxyConfig.
123 124 125 126 127 128 129 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 123 def initialize @proxies = [] @matchers = [] @trust_none = false @depth = nil @header = DEFAULT_HEADER end |
Instance Attribute Details
#depth ⇒ Integer?
Count-based depth; nil or 0 disables depth mode.
117 118 119 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 117 def depth @depth end |
#header ⇒ String
Canonical forwarded header depth mode counts hops from.
121 122 123 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 121 def header @header end |
#proxies ⇒ Array<String, Regexp> (readonly)
Proxy entries in registration order (filter mode).
113 114 115 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 113 def proxies @proxies end |
Class Method Details
.trust_no_proxies_option?(value) ⇒ Boolean
Whether a trusted_proxies option value is the trust-nobody sentinel. Accepts the symbol and the String spelling 'none' (case-insensitive), which is what YAML/ENV-driven configuration naturally produces; without this, 'none' would fall through to #add and install a legacy string-prefix matcher, silently inverting the assertion.
107 108 109 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 107 def self.trust_no_proxies_option?(value) (value.is_a?(Symbol) || value.is_a?(String)) && value.to_s.casecmp?('none') end |
Instance Method Details
#add(proxy) ⇒ void
This method returns an undefined value.
Register one entry or a list of entries (filter mode). The whole list is validated before anything is registered, so a rejected list leaves this object untouched.
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 196 def add(proxy) ensure_not_frozen! # Adding claims filter mode even when the list is empty, so the # conflict surfaces at the call that introduced it. ensure_compatible!(filter: true) Array(proxy).each do |entry| raise ArgumentError, TRUST_NO_PROXIES_ENTRY_MESSAGE if self.class.trust_no_proxies_option?(entry) end case proxy when String, Regexp @proxies << proxy @matchers << build_matcher(proxy) when Array # Build every matcher before touching state, so a failure partway # through cannot leave entries and matchers out of step. matchers = proxy.map { |entry| build_matcher(entry) } @proxies.concat(proxy) @matchers.concat(matchers) else raise ArgumentError, 'Proxy must be a String, Regexp, or Array' end end |
#check_depth!(depth) ⇒ Integer?
Raise unless depth could be assigned: a non-negative Integer or nil, compatible with the current mode. Stores nothing.
237 238 239 240 241 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 237 def check_depth!(depth) validate_depth_value!(depth) ensure_compatible!(depth: depth.to_i >= 1) depth end |
#check_header!(header) ⇒ String
Raise unless header could be assigned, and return its canonical spelling. Matching is case-insensitive and ignores surrounding whitespace; an unrecognized value fails loud instead of silently resolving from the wrong header. Stores nothing.
259 260 261 262 263 264 265 266 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 259 def check_header!(header) candidate = header.to_s.strip canonical = HEADERS.find { |allowed| allowed.casecmp?(candidate) } raise ArgumentError, (header) unless canonical ensure_compatible!(header: canonical) canonical end |
#configured? ⇒ Boolean
Whether any mode is configured. When false, Otto leaves env absent (the tri-state contract).
145 146 147 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 145 def configured? !mode.nil? end |
#default_header? ⇒ Boolean
Whether #header is the X-Forwarded-For default.
183 184 185 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 183 def default_header? @header == DEFAULT_HEADER end |
#depth? ⇒ Boolean
Whether count-based depth mode is active. Integer-strict, so a value that never passed #depth= cannot enable it.
160 161 162 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 160 def depth? @depth.is_a?(Integer) && @depth >= 1 end |
#filter? ⇒ Boolean
Whether proxy entries are registered (CIDR filter mode).
152 153 154 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 152 def filter? @matchers.any? end |
#forwarding_family_dependent? ⇒ Boolean
Whether request handling reads a forwarded chain, and so depends on Rack's process-global forwarding family. True in filter and depth mode; false under trust-nobody (reads nothing) and when unconfigured.
176 177 178 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 176 def forwarding_family_dependent? filter? || depth? end |
#mode ⇒ Symbol?
The active resolution mode, or nil when proxy trust is unconfigured.
134 135 136 137 138 139 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 134 def mode return :filter if filter? return :depth if depth? :none if trust_none? end |
#trust_none! ⇒ void
This method returns an undefined value.
Assert that no proxy is trusted.
225 226 227 228 229 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 225 def trust_none! ensure_not_frozen! ensure_compatible!(trust_none: true) @trust_none = true end |
#trust_none? ⇒ Boolean
Whether the operator asserted that no proxy is trusted.
167 168 169 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 167 def trust_none? @trust_none end |
#trusted?(ip) ⇒ Boolean
Whether ip matches a registered entry.
String entries that parse as an IP or CIDR range are matched with proper IPAddr containment (IPv4 and IPv6). Entries that are not valid IPs (e.g. a bare prefix like '172.16.') fall back to the legacy exact/prefix string match for backward compatibility. Regexp entries are matched against the raw IP string. Entries are parsed once at registration, never per request.
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 287 def trusted?(ip) return false if @matchers.empty? || ip.nil? || ip.empty? # Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack # peer presented in mapped form still matches an IPv4 proxy entry. client = parse_ipaddr(ip)&.native @matchers.any? do |entry, range| if range # Pre-parsed IP/CIDR entry -> proper containment client && ip_in_range?(range, client) elsif entry.is_a?(Regexp) entry.match?(ip) elsif entry.is_a?(String) # Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match ip == entry || ip.start_with?(entry) else false end end end |
#validate! ⇒ void
This method returns an undefined value.
Re-check every rule against the stored state. The mutators already enforce them; this is the freeze-time backstop for state that bypassed them (a direct instance-variable write).
315 316 317 318 319 320 |
# File 'lib/otto/security/trusted_proxy_config.rb', line 315 def validate! raise ArgumentError, (@header) unless HEADERS.include?(@header) validate_depth_value!(@depth) ensure_compatible! end |