Class: Otto::Security::TrustedProxyConfig

Inherits:
Object
  • Object
show all
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, or add_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

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Core::Freezable

#deep_freeze!

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.

Returns:

  • (Integer, nil)


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.

Returns:

  • (String) —

    one of HEADERS



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).

Returns:

  • (Array<String, Regexp>)


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.

Parameters:

  • value (Object) —

    raw trusted_proxies option

Returns:

  • (Boolean)


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.

Parameters:

  • proxy (String, Regexp, Array<String, Regexp>) —

    entry or entries

Raises:

  • (ArgumentError) —

    on a mode conflict, a trust-nobody sentinel inside the list, or an unsupported type

  • (FrozenError) —

    if frozen



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.

Parameters:

  • depth (Object) —

    candidate value

Returns:

  • (Integer, nil) —

    depth

Raises:

  • (ArgumentError) —

    if invalid or conflicting



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.

Parameters:

  • header (Object) —

    candidate value

Returns:

  • (String) —

    canonical header (one of HEADERS)

Raises:

  • (ArgumentError) —

    if unrecognized or conflicting with filter mode



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, invalid_header_message(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).

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean) —

    true when depth is an Integer >= 1



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).

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (Symbol, nil) —

    :filter, :depth, :none, or nil



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.

Raises:

  • (ArgumentError) —

    if entries or a depth >= 1 are configured

  • (FrozenError) —

    if frozen



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.

Returns:

  • (Boolean)


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.

Parameters:

  • ip (String) —

    IP address to check

Returns:

  • (Boolean)


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).

Raises:

  • (ArgumentError) —

    if any rule is violated



315
316
317
318
319
320
# File 'lib/otto/security/trusted_proxy_config.rb', line 315

def validate!
  raise ArgumentError, invalid_header_message(@header) unless HEADERS.include?(@header)

  validate_depth_value!(@depth)
  ensure_compatible!
end