Module: Otto::Utils
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
-
#normalize_ip(ip) ⇒ String?
Validate and normalize an IP address (IPv4 and IPv6).
-
#now ⇒ Time
Current time in UTC.
-
#now_in_μs ⇒ Integer
(also: #now_in_microseconds)
Returns the current time in microseconds.
-
#private_ip?(ip) ⇒ Boolean
Whether an address is non-public: RFC1918 private, loopback, link-local, multicast, or unspecified — for both IPv4 and IPv6.
-
#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.
-
#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`).
-
#strip_ip_port(ip) ⇒ String
Strip an optional port without corrupting IPv6 addresses.
-
#yes?(value) ⇒ Boolean
Determine if a value represents a “yes” or true value.
Instance Method Details
#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.
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 |
#now ⇒ Time
Returns Current time in UTC.
32 33 34 |
# File 'lib/otto/utils.rb', line 32 def now Time.now.utc end |
#now_in_μs ⇒ Integer 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
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.
195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/otto/utils.rb', line 195 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.
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 X-Forwarded-For (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 X-Forwarded-For 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 X-Forwarded-For 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).
Only X-Forwarded-For is consulted; X-Real-IP / X-Client-IP are single-value and cannot express a hop chain. 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.
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/otto/utils.rb', line 168 def resolve_client_ip_by_depth(env, security_config) remote_addr = env['REMOTE_ADDR'] depth = security_config.trusted_proxy_depth.to_i # Split on commas keeping every position (-1 preserves trailing empty # fields) so a malformed hop still counts as a position. The client must # be located by counting from the right; dropping entries here would let # padding shift the index. forwarded = env['HTTP_X_FORWARDED_FOR'].to_s.split(',', -1) 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 |
#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.
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 |
#yes?(value) ⇒ Boolean
Determine if a value represents a “yes” or true value
Examples: yes?(‘true’) # => true yes?(‘yes’) # => true yes?(‘1’) # => true
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 |