Module: Otto::Utils

Extended by:
Utils
Included in:
Utils
Defined in:
lib/otto/utils.rb

Overview

Utility methods for common operations and helpers

Constant Summary collapse

FORWARDED_FOR_HEADERS =

Forwarded-for style headers consulted (in order) when resolving the real client IP from behind a trusted proxy. Shared by IPPrivacyMiddleware and Otto::Request so the two resolvers cannot drift.

%w[
  HTTP_X_FORWARDED_FOR
  HTTP_X_REAL_IP
  HTTP_X_CLIENT_IP
].freeze
SPECIAL_USE_RANGES =

Special-use IPv4/IPv6 ranges that IPAddr’s #private?/#loopback?/#link_local? predicates do not cover but that should still be treated as non-public (e.g. when picking the real client out of a forwarded chain).

[
  IPAddr.new('0.0.0.0/8'),   # "this" network / unspecified (IPv4)
  IPAddr.new('224.0.0.0/4'), # IPv4 multicast
  IPAddr.new('::/128'),      # IPv6 unspecified
  IPAddr.new('ff00::/8'),    # IPv6 multicast
].freeze

Instance Method Summary collapse

Instance Method Details

#forwarded_chain_for_depth(env, header_mode) ⇒ Array<String>

Positional forwarded-hop chain for depth resolution, selected by header mode. Each element is one hop (preserving count); values are raw — only the finally-selected entry is normalized. Mirrors OneTimeSecret’s site.network.trusted_proxy.header semantics.

Parameters:

  • env (Hash)

    Rack environment

  • header_mode (String)

    ‘X-Forwarded-For’, ‘Forwarded’, or ‘Both’

Returns:

  • (Array<String>)

    one entry per hop (may include blank/invalid entries)



197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/otto/utils.rb', line 197

def forwarded_chain_for_depth(env, header_mode)
  case header_mode
  when 'Forwarded'
    rfc7239_for_chain(env['HTTP_FORWARDED'])
  when 'Both'
    # RFC 7239 wins when it carries at least one `for=`; otherwise fall back
    # to X-Forwarded-For. The chains are NOT merged (matches OTS's
    # `extract_rfc7239_forwarded(env) || extract_x_forwarded_for(env)`).
    forwarded = rfc7239_for_chain(env['HTTP_FORWARDED'])
    forwarded.any? { |entry| !entry.empty? } ? forwarded : xff_chain(env['HTTP_X_FORWARDED_FOR'])
  else
    xff_chain(env['HTTP_X_FORWARDED_FOR'])
  end
end

#normalize_ip(ip) ⇒ String?

Validate and normalize an IP address (IPv4 and IPv6).

Strips an optional port (IPv6-safe), validates with IPAddr, and returns the cleaned address string, or nil if the input is blank or malformed.

Parameters:

  • ip (String, nil)

    candidate address, optionally with a port

Returns:

  • (String, nil)

    cleaned IP string, or nil if invalid



67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/otto/utils.rb', line 67

def normalize_ip(ip)
  return nil if ip.nil? || ip.empty?

  candidate = strip_ip_port(ip.strip)
  return nil if candidate.nil? || candidate.empty?

  # IPAddr validates both IPv4 and IPv6; raises for malformed input
  IPAddr.new(candidate)
  candidate
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
  nil
end

#nowTime

Returns Current time in UTC.

Returns:

  • (Time)

    Current time in UTC



32
33
34
# File 'lib/otto/utils.rb', line 32

def now
  Time.now.utc
end

#now_in_μsInteger Also known as: now_in_microseconds

Returns the current time in microseconds. This is used to measure the duration of Database commands.

Alias: now_in_microseconds

Returns:

  • (Integer)

    The current time in microseconds.



42
43
44
# File 'lib/otto/utils.rb', line 42

def now_in_μs
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
end

#private_ip?(ip) ⇒ Boolean

Whether an address is non-public: RFC1918 private, loopback, link-local, multicast, or unspecified — for both IPv4 and IPv6.

Uses IPAddr’s family-aware predicates (which also fold IPv4-mapped IPv6 via #native) plus an explicit set of special-use ranges that the predicates don’t cover (IPv4 0.0.0.0/8 and 224.0.0.0/4, IPv6 ::/128 and ff00::/8). Returns false for malformed input rather than raising.

Parameters:

  • ip (String, IPAddr, nil)

    address to classify

Returns:

  • (Boolean)


274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/otto/utils.rb', line 274

def private_ip?(ip)
  return false if ip.nil?
  return false if ip.respond_to?(:empty?) && ip.empty?

  addr = ip.is_a?(IPAddr) ? ip : IPAddr.new(strip_ip_port(ip.to_s.strip))
  addr = addr.native # fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to IPv4

  return true if addr.private? || addr.loopback? || addr.link_local?

  SPECIAL_USE_RANGES.any? { |range| range.family == addr.family && range.include?(addr) }
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
  false
end

#resolve_client_ip(env, security_config) ⇒ String?

Resolve the real client IP from a Rack env, honoring forwarded headers only when the connecting peer (REMOTE_ADDR) is a trusted proxy.

This is the single canonical resolver shared by IPPrivacyMiddleware (“resolve once”) and Otto::Request#client_ipaddress (its no-middleware fallback), so both paths agree on which headers to trust and how to walk a proxy chain. It walks the forwarded chain left-to-right and returns the first address that is not itself a trusted proxy; if the peer is not a trusted proxy (or there is no config) it returns REMOTE_ADDR unchanged.

Parameters:

  • env (Hash)

    Rack environment

  • security_config (Otto::Security::Config, nil)

    config exposing #trusted_proxy?

Returns:

  • (String, nil)

    resolved client IP (the raw REMOTE_ADDR when no proxy applies)



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/otto/utils.rb', line 112

def resolve_client_ip(env, security_config)
  remote_addr = env['REMOTE_ADDR']

  # Count-based ("trust the last N hops") mode for non-enumerable proxy
  # tiers (Fly, cloud load balancers, dynamic reverse proxies) where the
  # CIDR-walk below has no enumerable proxy IPs to match. Mirrors Express
  # `trust proxy = N`. Takes precedence over CIDR-walk; the two modes are
  # mutually exclusive (enforced at config freeze).
  return resolve_client_ip_by_depth(env, security_config) if security_config&.trusted_proxy_depth_mode?

  # No config, or the peer is a direct (untrusted) connection: REMOTE_ADDR
  # is the client. Don't honor forwarded headers from untrusted sources.
  return remote_addr unless security_config&.trusted_proxy?(remote_addr)

  forwarded_ips = FORWARDED_FOR_HEADERS
                  .filter_map { |header| env[header] }
                  .flat_map { |value| value.split(/,\s*/) }

  forwarded_ips.each do |candidate|
    clean_ip = normalize_ip(candidate.strip)
    next unless clean_ip

    # First address in the chain that isn't a known proxy is the client.
    return clean_ip unless security_config.trusted_proxy?(clean_ip)
  end

  # Whole chain was trusted proxies (or empty): fall back to the peer.
  remote_addr
end

#resolve_client_ip_by_depth(env, security_config) ⇒ String?

Resolve the client IP by trusting a fixed number of proxy hops, counted from the right of the forwarded chain (Express ‘trust proxy = N`). Used when the proxy tier’s addresses cannot be enumerated as CIDRs.

The chain is the configured forwarded header (leftmost = client .. rightmost = nearest proxy) plus REMOTE_ADDR (the direct peer). With depth N the client is chain — exactly N trusted hops from the right, equivalent to Express’s addrs. This is robust to forwarded-header padding: a forged leftmost entry is never reached.

SECURITY: depth trust ASSUMES ORIGIN LOCKDOWN — the app must be unreachable except through the proxy tier. Without it, a direct client could pad the forwarded header to land a forged value at the target index. This is the inherent trade vs CIDR-walk (a fixed hop count instead of enumerable proxy addresses).

The forwarded chain is selected by security_config.trusted_proxy_header: ‘X-Forwarded-For’ (default), ‘Forwarded’ (RFC 7239), or ‘Both’ (RFC 7239 when it carries a ‘for=`, otherwise X-Forwarded-For — mirrors OneTimeSecret’s site.network.trusted_proxy.header). X-Real-IP / X-Client-IP are single-value and cannot express a hop chain, so they are never consulted in depth mode. Positions are counted raw (never dropped), so junk padding cannot shift the index; only the selected entry is validated. If the chain is shorter than N+1 (a request that may have bypassed the proxy tier) or the selected entry is invalid, REMOTE_ADDR is returned rather than a spoofable forwarded value.

Parameters:

  • env (Hash)

    Rack environment

  • security_config (Otto::Security::Config)

    config exposing #trusted_proxy_depth and #trusted_proxy_header

Returns:

  • (String, nil)

    resolved client IP (REMOTE_ADDR on short chain / invalid target)



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/otto/utils.rb', line 172

def resolve_client_ip_by_depth(env, security_config)
  remote_addr = env['REMOTE_ADDR']
  depth       = security_config.trusted_proxy_depth.to_i

  # Build the positional hop chain from the configured header, keeping every
  # position (junk/empty entries included) so the client can be located by
  # counting from the right; dropping entries would let padding shift the
  # index. REMOTE_ADDR (the direct peer) is the rightmost hop.
  forwarded = forwarded_chain_for_depth(env, security_config.trusted_proxy_header)
  chain     = forwarded + [remote_addr]

  index = chain.length - (depth + 1)
  return remote_addr if index.negative? # chain shorter than depth + 1

  normalize_ip(chain[index].to_s.strip) || remote_addr
end

#rfc7239_for_chain(value) ⇒ Array<String>

Extract the per-hop ‘for=` chain from an RFC 7239 Forwarded header, preserving one position per forwarded-element. Elements without a `for=` parameter yield a blank placeholder so they still occupy a hop position (raw position counting). The extracted token is only unquoted here; port and IPv6 brackets are left for normalize_ip when the entry is selected. Obfuscated (`for=_hidden`) and `for=unknown` identifiers are preserved as positions but normalize to nil (→ REMOTE_ADDR fallback if selected). Commas separate forwarded-elements (and join multiple Forwarded headers). A nil/blank header splits to [] (not [”]), so an absent Forwarded header yields an empty chain and depth’s explicit short-chain guard returns REMOTE_ADDR — symmetric with xff_chain.

Parameters:

  • value (String, nil)

    raw Forwarded header value

Returns:

  • (Array<String>)

    one ‘for=` token per forwarded-element



235
236
237
# File 'lib/otto/utils.rb', line 235

def rfc7239_for_chain(value)
  value.to_s.split(',', -1).map { |element| rfc7239_for_value(element) }
end

#rfc7239_for_value(element) ⇒ String

Pull the ‘for=` token out of a single RFC 7239 forwarded-element. The value is a quoted-string (which may itself legally contain ’;‘) or an unquoted token ending at the next ’;‘. The quoted form is matched first so a ’;‘ inside DQUOTEs is NOT treated as a parameter separator — otherwise a value like for=“1.2.3.4;junk” would be truncated to a valid-looking IP instead of being rejected. Only DQUOTE wrappers are stripped: RFC 7239 quoted-strings use DQUOTE exclusively, so a value like for=’1.2.3.4’ keeps its quotes, fails normalize_ip, and safely falls back to REMOTE_ADDR rather than being permissively accepted. This is deliberately stricter than OTS (which strips both [‘“]), consistent with depth’s other intentionally-not-reconciled-down safety properties. The raw value (port / IPv6 brackets intact) is left for normalize_ip when the entry is selected. Returns ” when the element carries no ‘for=` parameter, preserving the hop position. The `for=` pair may be the element’s first pair or follow a ‘;’; leading whitespace (e.g. after a comma split) is tolerated.

Parameters:

  • element (String)

    one forwarded-element (e.g. ‘for=1.2.3.4;proto=https’)

Returns:

  • (String)


257
258
259
260
261
262
# File 'lib/otto/utils.rb', line 257

def rfc7239_for_value(element)
  match = element.match(/(?:\A|;)\s*for=(?:"([^"]*)"|([^;]+))/i)
  return '' unless match

  (match[1] || match[2]).strip
end

#strip_ip_port(ip) ⇒ String

Strip an optional port without corrupting IPv6 addresses.

Handles bracketed IPv6 with a port (‘[2001:db8::1]:443`) and IPv4 host:port (`203.0.113.5:443`). A bare IPv6 address (multiple colons, no brackets) is returned unchanged.

Parameters:

  • ip (String)

    candidate address, possibly including a port

Returns:

  • (String)

    address with any port removed



88
89
90
91
92
93
94
95
96
97
# File 'lib/otto/utils.rb', line 88

def strip_ip_port(ip)
  if ip.start_with?('[')
    inner = ip[/\A\[([^\]]+)\]/, 1]
    return inner if inner
  end

  return ip.split(':', 2).first if ip.count(':') == 1

  ip
end

#xff_chain(value) ⇒ Array<String>

Split X-Forwarded-For into raw positional entries. ‘-1` keeps trailing empty fields so a malformed/empty hop still counts as a position.

Parameters:

  • value (String, nil)

    raw X-Forwarded-For header value

Returns:

  • (Array<String>)


217
218
219
# File 'lib/otto/utils.rb', line 217

def xff_chain(value)
  value.to_s.split(',', -1)
end

#yes?(value) ⇒ Boolean

Determine if a value represents a “yes” or true value

Examples: yes?(‘true’) # => true yes?(‘yes’) # => true yes?(‘1’) # => true

Parameters:

  • value (Object)

    The value to evaluate

Returns:

  • (Boolean)

    True if the value represents “yes”, false otherwise



56
57
58
# File 'lib/otto/utils.rb', line 56

def yes?(value)
  !value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
end