otto 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ class Otto
15
15
  #
16
16
  # @example Default behavior (privacy enabled)
17
17
  # # env['REMOTE_ADDR'] is masked to 192.168.1.0
18
- # # env['otto.redacted_fingerprint'] contains full anonymized data
18
+ # # env['otto.privacy.fingerprint'] contains full anonymized data
19
19
  # # env['otto.original_ip'] is NOT set
20
20
  #
21
21
  # @example Privacy disabled
@@ -42,6 +42,22 @@ class Otto
42
42
  # @param env [Hash] Rack environment
43
43
  # @return [Array] Rack response tuple [status, headers, body]
44
44
  def call(env)
45
+ # Idempotency: if a prior IPPrivacyMiddleware pass already resolved the
46
+ # canonical client IP for this request, do not re-resolve or re-mask.
47
+ # This makes stacking two instances (e.g. an app-level mount plus
48
+ # Otto's built-in router mount) order-safe instead of double-masking.
49
+ return @app.call(env) if env.key?('otto.client_ip')
50
+
51
+ # Record the connecting peer's trust decision BEFORE any masking, so
52
+ # secure? can authorize X-Forwarded-Proto canonically even after
53
+ # REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
54
+ #
55
+ # This is the trusted-proxy *identity* check only — it is deliberately
56
+ # independent of count-based depth mode. Depth resolves the client IP;
57
+ # it never grants proxy trust for X-Forwarded-Proto (matching the
58
+ # downstream OneTimeSecret behavior).
59
+ env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
60
+
45
61
  if @privacy_enabled
46
62
  apply_privacy(env)
47
63
  else
@@ -63,7 +79,8 @@ class Otto
63
79
  #
64
80
  # @param env [Hash] Rack environment
65
81
  def apply_privacy(env)
66
- # Resolve the actual client IP (handling proxies)
82
+ # Resolve the actual client IP once (handling proxies). This is the
83
+ # canonical resolution step; masking below operates on this value.
67
84
  client_ip = resolve_client_ip(env)
68
85
 
69
86
  Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
@@ -75,6 +92,8 @@ class Otto
75
92
  # Update REMOTE_ADDR to the resolved client IP (even though it's not masked)
76
93
  env['REMOTE_ADDR'] = client_ip
77
94
  env['otto.original_ip'] = client_ip
95
+ # Canonical client IP downstream reads (exempt: not masked)
96
+ env['otto.client_ip'] = client_ip
78
97
  # Don't mask forwarded headers for private IPs
79
98
  Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
80
99
  return
@@ -99,6 +118,10 @@ class Otto
99
118
  # automatically uses the masked values without modification
100
119
  env['REMOTE_ADDR'] = fingerprint.masked_ip
101
120
 
121
+ # Canonical client IP downstream reads ("resolve once, read everywhere").
122
+ # Privacy-safe: holds the masked value, never the original public IP.
123
+ env['otto.client_ip'] = fingerprint.masked_ip
124
+
102
125
  # Replace User-Agent with anonymized version (consistent with IP masking)
103
126
  # CRITICAL: Always replace, even if nil, to clear original sensitive data
104
127
  env['HTTP_USER_AGENT'] = fingerprint.anonymized_ua
@@ -118,41 +141,17 @@ class Otto
118
141
  end
119
142
 
120
143
 
121
- # Resolve the actual client IP address from the request
144
+ # Resolve the actual client IP address from the request.
122
145
  #
123
- # This method handles proxy scenarios by checking X-Forwarded-For and
124
- # other proxy headers from trusted proxies, similar to Rack's logic
125
- # and Otto's client_ipaddress method.
146
+ # Delegates to the shared Otto::Utils.resolve_client_ip so the
147
+ # middleware ("resolve once") and Otto::Request#client_ipaddress (its
148
+ # no-middleware fallback) use one canonical proxy-chain resolver and
149
+ # cannot drift on which headers are trusted.
126
150
  #
127
151
  # @param env [Hash] Rack environment
128
152
  # @return [String] Resolved client IP address
129
153
  def resolve_client_ip(env)
130
- remote_addr = env['REMOTE_ADDR']
131
-
132
- # If we don't have a security config, use direct connection
133
- return remote_addr unless @security_config
134
-
135
- # If REMOTE_ADDR is not from a trusted proxy, it's the client IP
136
- return remote_addr unless trusted_proxy?(remote_addr)
137
-
138
- # REMOTE_ADDR is from a trusted proxy, check forwarded headers
139
- forwarded_ips = [
140
- env['HTTP_X_FORWARDED_FOR'],
141
- env['HTTP_X_REAL_IP'],
142
- env['HTTP_X_CLIENT_IP'],
143
- ].compact.map { |header| header.split(/,\s*/) }.flatten
144
-
145
- # Return the first valid public IP from forwarded headers
146
- forwarded_ips.each do |ip|
147
- clean_ip = validate_ip_address(ip.strip)
148
- next unless clean_ip
149
-
150
- # Return first IP that's not from a trusted proxy
151
- return clean_ip unless trusted_proxy?(clean_ip)
152
- end
153
-
154
- # Fallback to remote address if no valid forwarded IPs
155
- remote_addr
154
+ Otto::Utils.resolve_client_ip(env, @security_config)
156
155
  end
157
156
 
158
157
  # Mask X-Forwarded-For and related proxy headers
@@ -182,26 +181,6 @@ class Otto
182
181
  @security_config.trusted_proxy?(ip)
183
182
  end
184
183
 
185
- # Validate and clean IP address
186
- #
187
- # @param ip [String, nil] IP address to validate
188
- # @return [String, nil] Cleaned IP or nil if invalid
189
- def validate_ip_address(ip)
190
- return nil if ip.nil? || ip.empty?
191
-
192
- # Remove any port number
193
- clean_ip = ip.split(':').first
194
-
195
- # Basic IPv4 format validation
196
- return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
197
-
198
- # Validate each octet
199
- octets = clean_ip.split('.')
200
- return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
201
-
202
- clean_ip
203
- end
204
-
205
184
  # Apply no-privacy settings (privacy explicitly disabled)
206
185
  #
207
186
  # When privacy is disabled, original IP is available for
@@ -209,6 +188,11 @@ class Otto
209
188
  #
210
189
  # @param env [Hash] Rack environment
211
190
  def apply_no_privacy(env)
191
+ # Resolve the canonical client IP once, even with privacy disabled, so
192
+ # downstream code can read env['otto.client_ip'] instead of re-deriving
193
+ # it from REMOTE_ADDR / forwarded headers.
194
+ env['otto.client_ip'] = resolve_client_ip(env)
195
+
212
196
  # Store original values for explicit access when privacy is disabled
213
197
  if env['REMOTE_ADDR']
214
198
  env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
@@ -16,13 +16,11 @@ class Otto
16
16
  NULL_BYTE = /\0/
17
17
 
18
18
  # HTML/XSS sanitization is handled by Loofah library for better security coverage
19
-
20
- SQL_INJECTION_PATTERNS = [
21
- /('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
22
- /(union|select|insert|update|delete|drop|create|alter|exec|execute)/i,
23
- /(or|and)\s+\w+\s*=\s*\w+/i,
24
- /\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i,
25
- ].freeze
19
+ #
20
+ # NOTE: There is deliberately no SQL-injection pattern matching here.
21
+ # Input-validation blocklists for SQL are bypassable theater and produce
22
+ # false positives on legitimate input; the correct defense is
23
+ # parameterized queries / prepared statements at the data-access layer.
26
24
 
27
25
  def initialize(app, config = nil)
28
26
  @app = app
@@ -181,14 +179,7 @@ class Otto
181
179
  sanitized = Loofah.fragment(original).scrub!(:whitewash).to_s
182
180
 
183
181
  # Remove control characters (sanitize, don't block)
184
- sanitized = sanitized.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
185
-
186
- # Check for SQL injection patterns
187
- SQL_INJECTION_PATTERNS.each do |pattern|
188
- raise Otto::Security::ValidationError, 'Potential SQL injection detected' if sanitized.match?(pattern)
189
- end
190
-
191
- sanitized
182
+ sanitized.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
192
183
  end
193
184
 
194
185
  include Otto::Security::ValidationHelpers
data/lib/otto/security.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require_relative 'security/core'
6
+ require_relative 'security/constant_resolver'
6
7
  require_relative 'security/authentication/strategy_result'
7
8
  require_relative 'security/authorization_error'
8
9
  require_relative 'security/config'
data/lib/otto/utils.rb CHANGED
@@ -2,11 +2,32 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require 'ipaddr'
6
+
5
7
  class Otto
6
8
  # Utility methods for common operations and helpers
7
9
  module Utils
8
10
  extend self
9
11
 
12
+ # Forwarded-for style headers consulted (in order) when resolving the real
13
+ # client IP from behind a trusted proxy. Shared by IPPrivacyMiddleware and
14
+ # Otto::Request so the two resolvers cannot drift.
15
+ FORWARDED_FOR_HEADERS = %w[
16
+ HTTP_X_FORWARDED_FOR
17
+ HTTP_X_REAL_IP
18
+ HTTP_X_CLIENT_IP
19
+ ].freeze
20
+
21
+ # Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
22
+ # predicates do not cover but that should still be treated as non-public
23
+ # (e.g. when picking the real client out of a forwarded chain).
24
+ SPECIAL_USE_RANGES = [
25
+ IPAddr.new('0.0.0.0/8'), # "this" network / unspecified (IPv4)
26
+ IPAddr.new('224.0.0.0/4'), # IPv4 multicast
27
+ IPAddr.new('::/128'), # IPv6 unspecified
28
+ IPAddr.new('ff00::/8'), # IPv6 multicast
29
+ ].freeze
30
+
10
31
  # @return [Time] Current time in UTC
11
32
  def now
12
33
  Time.now.utc
@@ -35,5 +56,154 @@ class Otto
35
56
  def yes?(value)
36
57
  !value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
37
58
  end
59
+
60
+ # Validate and normalize an IP address (IPv4 and IPv6).
61
+ #
62
+ # Strips an optional port (IPv6-safe), validates with IPAddr, and returns
63
+ # the cleaned address string, or nil if the input is blank or malformed.
64
+ #
65
+ # @param ip [String, nil] candidate address, optionally with a port
66
+ # @return [String, nil] cleaned IP string, or nil if invalid
67
+ def normalize_ip(ip)
68
+ return nil if ip.nil? || ip.empty?
69
+
70
+ candidate = strip_ip_port(ip.strip)
71
+ return nil if candidate.nil? || candidate.empty?
72
+
73
+ # IPAddr validates both IPv4 and IPv6; raises for malformed input
74
+ IPAddr.new(candidate)
75
+ candidate
76
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
77
+ nil
78
+ end
79
+
80
+ # Strip an optional port without corrupting IPv6 addresses.
81
+ #
82
+ # Handles bracketed IPv6 with a port (`[2001:db8::1]:443`) and IPv4
83
+ # host:port (`203.0.113.5:443`). A bare IPv6 address (multiple colons,
84
+ # no brackets) is returned unchanged.
85
+ #
86
+ # @param ip [String] candidate address, possibly including a port
87
+ # @return [String] address with any port removed
88
+ def strip_ip_port(ip)
89
+ if ip.start_with?('[')
90
+ inner = ip[/\A\[([^\]]+)\]/, 1]
91
+ return inner if inner
92
+ end
93
+
94
+ return ip.split(':', 2).first if ip.count(':') == 1
95
+
96
+ ip
97
+ end
98
+
99
+ # Resolve the real client IP from a Rack env, honoring forwarded headers
100
+ # only when the connecting peer (REMOTE_ADDR) is a trusted proxy.
101
+ #
102
+ # This is the single canonical resolver shared by IPPrivacyMiddleware
103
+ # ("resolve once") and Otto::Request#client_ipaddress (its no-middleware
104
+ # fallback), so both paths agree on which headers to trust and how to walk
105
+ # a proxy chain. It walks the forwarded chain left-to-right and returns the
106
+ # first address that is not itself a trusted proxy; if the peer is not a
107
+ # trusted proxy (or there is no config) it returns REMOTE_ADDR unchanged.
108
+ #
109
+ # @param env [Hash] Rack environment
110
+ # @param security_config [Otto::Security::Config, nil] config exposing #trusted_proxy?
111
+ # @return [String, nil] resolved client IP (the raw REMOTE_ADDR when no proxy applies)
112
+ def resolve_client_ip(env, security_config)
113
+ remote_addr = env['REMOTE_ADDR']
114
+
115
+ # Count-based ("trust the last N hops") mode for non-enumerable proxy
116
+ # tiers (Fly, cloud load balancers, dynamic reverse proxies) where the
117
+ # CIDR-walk below has no enumerable proxy IPs to match. Mirrors Express
118
+ # `trust proxy = N`. Takes precedence over CIDR-walk; the two modes are
119
+ # mutually exclusive (enforced at config freeze).
120
+ return resolve_client_ip_by_depth(env, security_config) if security_config&.trusted_proxy_depth_mode?
121
+
122
+ # No config, or the peer is a direct (untrusted) connection: REMOTE_ADDR
123
+ # is the client. Don't honor forwarded headers from untrusted sources.
124
+ return remote_addr unless security_config&.trusted_proxy?(remote_addr)
125
+
126
+ forwarded_ips = FORWARDED_FOR_HEADERS
127
+ .filter_map { |header| env[header] }
128
+ .flat_map { |value| value.split(/,\s*/) }
129
+
130
+ forwarded_ips.each do |candidate|
131
+ clean_ip = normalize_ip(candidate.strip)
132
+ next unless clean_ip
133
+
134
+ # First address in the chain that isn't a known proxy is the client.
135
+ return clean_ip unless security_config.trusted_proxy?(clean_ip)
136
+ end
137
+
138
+ # Whole chain was trusted proxies (or empty): fall back to the peer.
139
+ remote_addr
140
+ end
141
+
142
+ # Resolve the client IP by trusting a fixed number of proxy hops, counted
143
+ # from the right of the forwarded chain (Express `trust proxy = N`). Used
144
+ # when the proxy tier's addresses cannot be enumerated as CIDRs.
145
+ #
146
+ # The chain is X-Forwarded-For (leftmost = client .. rightmost = nearest
147
+ # proxy) plus REMOTE_ADDR (the direct peer). With depth N the client is
148
+ # chain[-(N+1)] — exactly N trusted hops from the right, equivalent to
149
+ # Express's addrs[N]. This is robust to X-Forwarded-For padding: a forged
150
+ # leftmost entry is never reached.
151
+ #
152
+ # SECURITY: depth trust ASSUMES ORIGIN LOCKDOWN — the app must be
153
+ # unreachable except through the proxy tier. Without it, a direct client
154
+ # could pad X-Forwarded-For to land a forged value at the target index.
155
+ # This is the inherent trade vs CIDR-walk (a fixed hop count instead of
156
+ # enumerable proxy addresses).
157
+ #
158
+ # Only X-Forwarded-For is consulted; X-Real-IP / X-Client-IP are
159
+ # single-value and cannot express a hop chain. Positions are counted raw
160
+ # (never dropped), so junk padding cannot shift the index; only the
161
+ # selected entry is validated. If the chain is shorter than N+1 (a request
162
+ # that may have bypassed the proxy tier) or the selected entry is invalid,
163
+ # REMOTE_ADDR is returned rather than a spoofable forwarded value.
164
+ #
165
+ # @param env [Hash] Rack environment
166
+ # @param security_config [Otto::Security::Config] config exposing #trusted_proxy_depth
167
+ # @return [String, nil] resolved client IP (REMOTE_ADDR on short chain / invalid target)
168
+ def resolve_client_ip_by_depth(env, security_config)
169
+ remote_addr = env['REMOTE_ADDR']
170
+ depth = security_config.trusted_proxy_depth.to_i
171
+
172
+ # Split on commas keeping every position (-1 preserves trailing empty
173
+ # fields) so a malformed hop still counts as a position. The client must
174
+ # be located by counting from the right; dropping entries here would let
175
+ # padding shift the index.
176
+ forwarded = env['HTTP_X_FORWARDED_FOR'].to_s.split(',', -1)
177
+ chain = forwarded + [remote_addr]
178
+
179
+ index = chain.length - (depth + 1)
180
+ return remote_addr if index.negative? # chain shorter than depth + 1
181
+
182
+ normalize_ip(chain[index].to_s.strip) || remote_addr
183
+ end
184
+
185
+ # Whether an address is non-public: RFC1918 private, loopback, link-local,
186
+ # multicast, or unspecified — for both IPv4 and IPv6.
187
+ #
188
+ # Uses IPAddr's family-aware predicates (which also fold IPv4-mapped IPv6
189
+ # via #native) plus an explicit set of special-use ranges that the
190
+ # predicates don't cover (IPv4 0.0.0.0/8 and 224.0.0.0/4, IPv6 ::/128 and
191
+ # ff00::/8). Returns false for malformed input rather than raising.
192
+ #
193
+ # @param ip [String, IPAddr, nil] address to classify
194
+ # @return [Boolean]
195
+ def private_ip?(ip)
196
+ return false if ip.nil?
197
+ return false if ip.respond_to?(:empty?) && ip.empty?
198
+
199
+ addr = ip.is_a?(IPAddr) ? ip : IPAddr.new(strip_ip_port(ip.to_s.strip))
200
+ addr = addr.native # fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to IPv4
201
+
202
+ return true if addr.private? || addr.loopback? || addr.link_local?
203
+
204
+ SPECIAL_USE_RANGES.any? { |range| range.family == addr.family && range.include?(addr) }
205
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
206
+ false
207
+ end
38
208
  end
39
209
  end
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.2.0'
6
+ VERSION = '2.3.0'
7
7
  end
data/lib/otto.rb CHANGED
@@ -214,7 +214,15 @@ class Otto
214
214
  end
215
215
 
216
216
  class << self
217
- attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
217
+ attr_accessor :debug # rubocop:disable ThreadSafety/ClassAndModuleAttributes
218
+ attr_writer :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
219
+
220
+ # Otto's logger. Never nil: falls back to a default $stdout logger so call
221
+ # sites can log unconditionally without defensive `&.` guards. Assign your
222
+ # own logger (or a null logger to silence) via `Otto.logger = ...`.
223
+ def logger
224
+ @logger ||= Logger.new($stdout, Logger::INFO)
225
+ end
218
226
 
219
227
  # Helper method for structured logging that works with both standard Logger and structured loggers
220
228
  def structured_log(level, message, data = {})
data/otto.gemspec CHANGED
@@ -21,7 +21,14 @@ Gem::Specification.new do |spec|
21
21
  spec.required_ruby_version = ['>= 3.2', '< 4.1']
22
22
 
23
23
  spec.add_dependency 'concurrent-ruby', '~> 1.3', '< 2.0'
24
- spec.add_dependency 'ipaddr', '~> 1', '< 2.0'
24
+
25
+ # ipaddr is a default gem on every supported Ruby (3.2+), so `require
26
+ # 'ipaddr'` works without a gemspec dependency. Declaring one collides
27
+ # with bundler 2.7.x's default-gem handling: the lockfile pins a version
28
+ # newer than the activated default and bundler refuses to swap, breaking
29
+ # `bundle exec` (including `rake release`). Drop the declaration and
30
+ # rely on the runtime default gem until bundler ships default-gem
31
+ # override support for ipaddr.
25
32
 
26
33
  # Logger is not part of the default gems as of Ruby 3.5.0
27
34
  spec.add_dependency 'logger', '~> 1', '< 2.0'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -29,26 +29,6 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '2.0'
32
- - !ruby/object:Gem::Dependency
33
- name: ipaddr
34
- requirement: !ruby/object:Gem::Requirement
35
- requirements:
36
- - - "~>"
37
- - !ruby/object:Gem::Version
38
- version: '1'
39
- - - "<"
40
- - !ruby/object:Gem::Version
41
- version: '2.0'
42
- type: :runtime
43
- prerelease: false
44
- version_requirements: !ruby/object:Gem::Requirement
45
- requirements:
46
- - - "~>"
47
- - !ruby/object:Gem::Version
48
- version: '1'
49
- - - "<"
50
- - !ruby/object:Gem::Version
51
- version: '2.0'
52
32
  - !ruby/object:Gem::Dependency
53
33
  name: logger
54
34
  requirement: !ruby/object:Gem::Requirement
@@ -162,6 +142,7 @@ files:
162
142
  - docs/ipaddr-encoding-quirk.md
163
143
  - docs/migrating/v2.0.0-pre1.md
164
144
  - docs/migrating/v2.0.0-pre2.md
145
+ - docs/migrating/v2.3.0.md
165
146
  - docs/modern-authentication-authorization-landscape.md
166
147
  - docs/multi-strategy-authentication-design.md
167
148
  - examples/.gitignore
@@ -295,6 +276,7 @@ files:
295
276
  - lib/otto/security/authorization_error.rb
296
277
  - lib/otto/security/config.rb
297
278
  - lib/otto/security/configurator.rb
279
+ - lib/otto/security/constant_resolver.rb
298
280
  - lib/otto/security/core.rb
299
281
  - lib/otto/security/csrf.rb
300
282
  - lib/otto/security/middleware/csrf_middleware.rb