otto 2.6.0 → 2.7.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +218 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +12 -10
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/simple_geo_resolver.rb +38 -5
  20. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  21. data/lib/otto/core/middleware_stack.rb +72 -25
  22. data/lib/otto/env_keys.rb +32 -0
  23. data/lib/otto/logging_helpers.rb +50 -1
  24. data/lib/otto/mcp/rate_limiting.rb +5 -2
  25. data/lib/otto/privacy/config.rb +245 -3
  26. data/lib/otto/privacy/core.rb +93 -14
  27. data/lib/otto/privacy/geo_resolver.rb +228 -128
  28. data/lib/otto/privacy/ip_privacy.rb +24 -0
  29. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  30. data/lib/otto/privacy.rb +3 -1
  31. data/lib/otto/request.rb +8 -1
  32. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  33. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  34. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  36. data/lib/otto/security/config.rb +23 -1
  37. data/lib/otto/security/core.rb +4 -1
  38. data/lib/otto/security/csp/report_middleware.rb +3 -1
  39. data/lib/otto/security/middleware/ip_privacy_middleware.rb +201 -12
  40. data/lib/otto/security/rate_limiter.rb +7 -1
  41. data/lib/otto/utils.rb +100 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +11 -3
  44. metadata +6 -6
@@ -2,8 +2,6 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
- require 'ipaddr'
6
-
7
5
  require_relative '../utils'
8
6
 
9
7
  class Otto
@@ -22,11 +20,15 @@ class Otto
22
20
  #
23
21
  # == Security: authenticate the RAW peer, not the resolved client IP
24
22
  #
25
- # The guard reads the ORIGINAL +env['REMOTE_ADDR']+ the TCP socket peer —
26
- # and MUST run before +IPPrivacyMiddleware+ rewrites +REMOTE_ADDR+ from
27
- # forwarded headers. Installed via +Otto#use+ (appended, hence outermost in
28
- # the reduce-built stack) it always executes ahead of +IPPrivacyMiddleware+
29
- # (which is pinned innermost), so it inspects the true socket peer.
23
+ # The guard authenticates the TCP socket peer as it arrived, before
24
+ # +IPPrivacyMiddleware+ rewrites +REMOTE_ADDR+ from forwarded headers.
25
+ # +IPPrivacyMiddleware+ is pinned OUTERMOST (issue #219), so it runs ahead
26
+ # of this guard and records its verdict on the untouched peer as
27
+ # +env['otto.peer_loopback']+ a boolean, never an address. The guard reads
28
+ # that record when present and falls back to evaluating +REMOTE_ADDR+
29
+ # itself when it is not (no Otto privacy middleware in the stack, or the
30
+ # guard mounted outside Otto). Either way the decision is made on the raw
31
+ # peer.
30
32
  #
31
33
  # Reading Otto's resolved +otto.client_ip+ (or the rewritten +REMOTE_ADDR+)
32
34
  # would be exploitable: a co-located reverse proxy on loopback is itself a
@@ -92,11 +94,28 @@ class Otto
92
94
  # @param env [Hash] Rack environment
93
95
  # @return [Boolean]
94
96
  def direct_local_call?(env)
95
- loopback_peer?(env['REMOTE_ADDR']) && !relayed?(env)
97
+ loopback_peer?(env) && !relayed?(env)
96
98
  end
97
99
 
98
100
  # Whether any forwarding header is present (request came via a proxy).
99
101
  #
102
+ # Unlike the peer check, this reads header STATE, which IPPrivacyMiddleware
103
+ # has already touched by the time the guard runs. That is safe in both of
104
+ # its paths, but only for a reason worth writing down:
105
+ #
106
+ # - Masking REWRITES a forwarded header to the masked IP rather than
107
+ # removing it, so a relayed request still looks relayed. Correct — it was.
108
+ # - The no-resolvable-client-IP path DELETES them, which would make a
109
+ # relayed request look direct. That path is reached only when REMOTE_ADDR
110
+ # is absent or blank, which forces otto.peer_loopback to false, so
111
+ # #direct_local_call? denies on the peer check before this one matters.
112
+ #
113
+ # So header deletion upstream cannot turn a deny into an allow — but that
114
+ # rests on the peer check failing closed for a blank address. Anything that
115
+ # makes an unresolvable-IP request keep a loopback peer verdict would need
116
+ # to record the relay state pre-scrub too (an otto.peer_relayed sibling to
117
+ # otto.peer_loopback).
118
+ #
100
119
  # @param env [Hash] Rack environment
101
120
  # @return [Boolean]
102
121
  def relayed?(env)
@@ -125,28 +144,27 @@ class Otto
125
144
  Otto::Utils.normalize_path(path)
126
145
  end
127
146
 
128
- # Whether the connecting peer is a loopback address. Fails closed: a
129
- # blank or otherwise unparseable value is treated as non-loopback
130
- # (denied) rather than raising on the hot path.
147
+ # Whether the connecting peer is a loopback address.
131
148
  #
132
- # +.native+ folds IPv4-mapped IPv6 (+::ffff:127.0.0.1+, which dual-stack
133
- # servers commonly present) so it is correctly recognized as loopback;
134
- # plain +IPAddr#loopback?+ returns false for the mapped form.
149
+ # Prefers +env['otto.peer_loopback']+ IPPrivacyMiddleware's verdict on
150
+ # the ORIGINAL peer, recorded before it rewrites +REMOTE_ADDR+ (it runs
151
+ # outermost, so by the time this guard sees the env the address may
152
+ # already be the resolved-and-masked client IP). Only a real Boolean is
153
+ # honored; anything else falls through to evaluating +REMOTE_ADDR+, which
154
+ # is the correct source when no privacy middleware ran.
135
155
  #
136
- # A conforming Rack server sets +REMOTE_ADDR+ to a bare IP (the peer's
137
- # port lives in +REMOTE_PORT+). We deliberately do NOT strip a +:port+
138
- # suffix here: an unexpected format is a signal something upstream is
139
- # non-standard, so denying (fail-closed) is safer than coercing it.
156
+ # Both paths share +Otto::Utils.loopback_address?+, so the recorded
157
+ # verdict and the fallback cannot disagree. It fails closed: a blank,
158
+ # ported, or unparseable value is treated as non-loopback (denied) rather
159
+ # than raising on the hot path.
140
160
  #
141
- # @param remote_addr [String, nil] the raw socket peer address
161
+ # @param env [Hash] Rack environment
142
162
  # @return [Boolean]
143
- def loopback_peer?(remote_addr)
144
- addr = remote_addr.to_s.strip
145
- return false if addr.empty?
163
+ def loopback_peer?(env)
164
+ recorded = env['otto.peer_loopback']
165
+ return recorded if [true, false].include?(recorded)
146
166
 
147
- IPAddr.new(addr).native.loopback?
148
- rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
149
- false
167
+ Otto::Utils.loopback_address?(env['REMOTE_ADDR'])
150
168
  end
151
169
 
152
170
  # @return [Array] 401 Rack response tuple
@@ -13,12 +13,24 @@ class Otto
13
13
  include Enumerable
14
14
  include Otto::Core::Freezable
15
15
 
16
+ # Pin tiers honored by #ordered_stack, outward-ascending. #wrap folds the
17
+ # stack with reduce, so a LATER array position is a FURTHER OUT wrapper;
18
+ # sorting by tier therefore sorts by how early the middleware sees the
19
+ # request. Unpinned entries are tier 0 and keep their insertion order.
20
+ #
21
+ # A tier is recorded on the ENTRY (as entry[:pin_tier]), not on the
22
+ # middleware class. Entries are identified by (class, args, options), so
23
+ # the same class can legitimately be registered more than once with
24
+ # different configuration; a class-wide pin would drag those other
25
+ # registrations into the pinned tier along with it.
26
+ PIN_TIERS = {
27
+ outermost: 1,
28
+ entrypoint: 2,
29
+ }.freeze
30
+
16
31
  def initialize
17
32
  @stack = []
18
33
  @middleware_set = Set.new
19
- # Classes pinned to run OUTERMOST regardless of insertion order (see
20
- # the :outermost position in #add_with_position and #ordered_stack).
21
- @outermost = Set.new
22
34
  @on_change_callback = nil
23
35
  end
24
36
 
@@ -52,8 +64,15 @@ class Otto
52
64
 
53
65
  # Add middleware with position hint for optimal ordering
54
66
  #
55
- # Positions:
56
- # - :first innermost (runs last, closest to the app)
67
+ # Positions name a place in the ARRAY; #wrap folds the array with reduce,
68
+ # so array order is the REVERSE of execution order the last entry is the
69
+ # outermost wrapper and therefore the first to see a request.
70
+ #
71
+ # - :first/:innermost — innermost: the LAST middleware to see the request,
72
+ # closest to the app. Note the trap in the older `:first`
73
+ # spelling: it is first-in-array, hence last-to-execute.
74
+ # `:innermost` says the same thing in execution terms and is
75
+ # the preferred spelling.
57
76
  # - :last/nil — append (outermost among currently-registered middleware,
58
77
  # but a later append displaces it)
59
78
  # - :outermost — pin to run OUTERMOST (first to see the request) and STAY
@@ -62,10 +81,18 @@ class Otto
62
81
  # at build time. Use for middleware that must short-circuit
63
82
  # ahead of everything else (e.g. the CSP report receiver,
64
83
  # which must intercept before CSRF).
84
+ # - :entrypoint — pin OUTSIDE even the :outermost tier: the very first
85
+ # middleware to touch a request. Reserved for middleware
86
+ # that must normalize the request before anything else can
87
+ # observe it. Otto pins IPPrivacyMiddleware here so every
88
+ # other middleware — its own, an :outermost pin, and
89
+ # anything the app adds via Otto#use — reads a masked
90
+ # REMOTE_ADDR and the canonical env['otto.client_ip'].
65
91
  #
66
92
  # @param middleware_class [Class] Middleware class
67
93
  # @param args [Array] Middleware arguments
68
- # @param position [Symbol, nil] Position hint (:first, :last, :outermost, or nil)
94
+ # @param position [Symbol, nil] Position hint (:first, :innermost, :last,
95
+ # :outermost, :entrypoint, or nil)
69
96
  def add_with_position(middleware_class, *args, position: nil, **options)
70
97
  raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
71
98
 
@@ -81,11 +108,10 @@ class Otto
81
108
  entry = { middleware: middleware_class, args: args, options: options }
82
109
 
83
110
  case position
84
- when :first
111
+ when :first, :innermost
85
112
  @stack.unshift(entry)
86
- when :outermost
87
- @stack << entry
88
- @outermost.add(middleware_class)
113
+ when *PIN_TIERS.keys
114
+ @stack << entry.merge(pin_tier: PIN_TIERS.fetch(position))
89
115
  else
90
116
  @stack << entry # :last / nil — default append
91
117
  end
@@ -160,9 +186,9 @@ class Otto
160
186
  # Update middleware set if any matching entries were found
161
187
  return unless matches
162
188
 
163
- # Rebuild the set of unique middleware classes
189
+ # Rebuild the set of unique middleware classes. Pins need no cleanup:
190
+ # each removed entry took its own tier with it.
164
191
  @middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
165
- @outermost.delete(middleware_class)
166
192
  # Notify of change
167
193
  @on_change_callback&.call
168
194
  end
@@ -179,7 +205,6 @@ class Otto
179
205
 
180
206
  @stack.clear
181
207
  @middleware_set.clear
182
- @outermost.clear
183
208
  # Notify of change
184
209
  @on_change_callback&.call
185
210
  end
@@ -192,9 +217,15 @@ class Otto
192
217
  # Build Rack application with middleware chain
193
218
  #
194
219
  # The stack folds via reduce, so the LAST entry becomes the OUTERMOST
195
- # wrapper (first to see the request). #ordered_stack moves any :outermost-
196
- # pinned middleware to the end so it stays outermost regardless of the
197
- # order middleware was registered in.
220
+ # wrapper (first to see the request). #ordered_stack moves any pinned
221
+ # middleware (:outermost, :entrypoint) to the end so it stays outermost
222
+ # regardless of the order middleware was registered in.
223
+ #
224
+ # NOT a request-path method. Its only caller is Otto's build_app!, which
225
+ # runs at construction and again whenever the stack changes; requests are
226
+ # served by the chain it returns. So the ordering work here (and in
227
+ # #ordered_stack) is per-BUILD, not per-request — don't add caching
228
+ # machinery on the assumption that it is hot.
198
229
  def wrap(base_app, security_config = nil)
199
230
  ordered_stack.reduce(base_app) do |app, entry|
200
231
  middleware = entry[:middleware]
@@ -215,11 +246,23 @@ class Otto
215
246
  end
216
247
  end
217
248
 
218
- # Returns list of middleware classes in order
249
+ # Returns list of middleware classes in REGISTRATION order — the order
250
+ # they were added, which is the reverse of execution order and ignores pin
251
+ # tiers. Use #execution_order to see what actually runs first.
219
252
  def middleware_list
220
253
  @stack.map { |entry| entry[:middleware] }
221
254
  end
222
255
 
256
+ # Returns middleware classes in EXECUTION order: the first entry is the
257
+ # outermost wrapper #wrap builds, i.e. the first to see a request. This is
258
+ # #middleware_list resolved through the pin tiers and reversed, so it
259
+ # answers "what does this stack actually do?" without building the app.
260
+ #
261
+ # @return [Array<Class>] outermost (first to execute) first
262
+ def execution_order
263
+ ordered_stack.reverse.map { |entry| entry[:middleware] }
264
+ end
265
+
223
266
  # Detailed introspection
224
267
  def middleware_details
225
268
  @stack.map do |entry|
@@ -254,16 +297,20 @@ class Otto
254
297
 
255
298
  private
256
299
 
257
- # The stack ordered for #wrap: identical to @stack unless some middleware
258
- # is pinned :outermost, in which case pinned entries are moved to the end
259
- # (outermost) while preserving the relative order of both groups. Returns
260
- # @stack itself (no copy) in the common no-pin case, so ordinary apps are
261
- # completely unaffected.
300
+ # The stack ordered for #wrap: identical to @stack unless some entry is
301
+ # pinned, in which case entries are sorted by pin tier (PIN_TIERS,
302
+ # outward-ascending) so pinned entries move to the end (outermost) while
303
+ # the relative order within every tier is preserved. Ruby's sort_by is not
304
+ # stable, hence the explicit index tiebreak. Returns @stack itself (no
305
+ # copy) in the common no-pin case, so ordinary apps are unaffected.
306
+ #
307
+ # Runs per build, not per request — see #wrap.
262
308
  def ordered_stack
263
- return @stack if @outermost.empty?
309
+ return @stack if @stack.none? { |entry| entry[:pin_tier] }
264
310
 
265
- pinned, rest = @stack.partition { |entry| @outermost.include?(entry[:middleware]) }
266
- rest + pinned
311
+ @stack.each_with_index
312
+ .sort_by { |entry, index| [entry[:pin_tier] || 0, index] }
313
+ .map(&:first)
267
314
  end
268
315
 
269
316
  def middleware_needs_config?(middleware_class)
data/lib/otto/env_keys.rb CHANGED
@@ -82,6 +82,17 @@ class Otto
82
82
  # without depending on the (masked) REMOTE_ADDR
83
83
  VIA_TRUSTED_PROXY = 'otto.via_trusted_proxy'
84
84
 
85
+ # Whether the connecting peer was the loopback interface.
86
+ # Type: Boolean
87
+ # Set by: IPPrivacyMiddleware (every request, evaluated on the ORIGINAL
88
+ # socket peer BEFORE REMOTE_ADDR is masked/rewritten). A boolean, never
89
+ # an address, so it carries no identifying data.
90
+ # Used by: Otto::CaddyTLS::LocalhostGuard to authenticate a direct local
91
+ # call now that IPPrivacyMiddleware runs outermost. Deliberately the raw
92
+ # peer, not the resolved client IP: forwarded headers must play no part
93
+ # in a localhost trust decision.
94
+ PEER_LOOPBACK = 'otto.peer_loopback'
95
+
85
96
  # =========================================================================
86
97
  # LOCALIZATION (I18N)
87
98
  # =========================================================================
@@ -134,6 +145,27 @@ class Otto
134
145
  # Note: presence also acts as the idempotency guard for the middleware
135
146
  CLIENT_IP = 'otto.client_ip'
136
147
 
148
+ # Verdict-only CIDR membership check over the resolved, UNMASKED client IP
149
+ # Type: Proc — call with an Enumerable of CIDR strings or IPAddr objects,
150
+ # returns true/false
151
+ # Set by: IPPrivacyMiddleware (every path that resolves an IP, all
152
+ # privacy profiles)
153
+ # Used by: Downstream IP policy code (allowlists, denylists, network
154
+ # zones) that needs full /32-/128 precision without changing the
155
+ # observability posture — CLIENT_IP is masked under the default
156
+ # profile, so it cannot express a single host
157
+ # Note: the unmasked IP never lands in env; only this closure does, and a
158
+ # Proc serializes to nothing useful, so env dumps and loggers cannot
159
+ # leak the address accidentally. Returns false when the request had
160
+ # no resolvable client IP (fail-closed for allowlist callers);
161
+ # raises IPAddr::InvalidAddressError for invalid CIDR entries
162
+ # (configuration error). See Otto::Utils.ip_in_cidrs?.
163
+ # Note: setting CLIENT_IP yourself is out of contract — it trips the
164
+ # middleware's idempotency guard, so the unmasked address is never
165
+ # captured and this capability degrades to a logged fail-closed
166
+ # check that denies every range.
167
+ IP_MATCH = 'otto.ip_match'
168
+
137
169
  # Privacy-safe masked IP address
138
170
  # Type: String (e.g., '192.168.1.0')
139
171
  # Set by: IPPrivacyMiddleware
@@ -76,12 +76,61 @@ class Otto
76
76
  {
77
77
  method: env['REQUEST_METHOD'],
78
78
  path: env['PATH_INFO'],
79
- ip: env['otto.client_ip'] || env['REMOTE_ADDR'], # Canonical client IP (masked when privacy on)
79
+ ip: privacy_safe_ip(env), # Canonical client IP (masked when privacy on)
80
80
  country: env['otto.privacy.geo_country'],
81
81
  user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
82
82
  }.compact
83
83
  end
84
84
 
85
+ # A client IP that is safe to write to a log.
86
+ #
87
+ # Prefers the canonical env['otto.client_ip'] that IPPrivacyMiddleware
88
+ # resolves once per request: masked when privacy is on, and deliberately
89
+ # the real address when the app has disabled privacy. That key is present
90
+ # for anything running inside Otto, since the middleware is pinned
91
+ # outermost.
92
+ #
93
+ # It is ABSENT for code that runs outside Otto's stack — notably
94
+ # Rack::Attack, which the hosting app mounts ahead of Otto (see
95
+ # Otto::Security::Middleware::RateLimitMiddleware), so its 'rack.attack'
96
+ # subscriber observes the raw peer no matter how Otto orders its own
97
+ # middleware. There we mask the address ourselves rather than log it whole:
98
+ # a public IP is reduced with the default precision (privacy config is not
99
+ # reachable from a global subscriber), and private/localhost addresses pass
100
+ # through unmasked, matching the middleware's own exemption so development
101
+ # logs stay readable.
102
+ #
103
+ # Never raises and never returns a raw public address: an unparseable value
104
+ # (a ported REMOTE_ADDR, a malformed forwarded entry) becomes '[redacted]'.
105
+ #
106
+ # @param env [Hash] Rack environment hash
107
+ # @param fallback_ip [String, nil] address to fall back to when the env
108
+ # carries no canonical client IP — e.g. Rack::Request#ip, which factors in
109
+ # forwarded headers. Defaults to REMOTE_ADDR.
110
+ # @return [String, nil] log-safe address, or nil when there is none
111
+ def self.privacy_safe_ip(env, fallback_ip = nil)
112
+ canonical = env['otto.client_ip']
113
+ return canonical unless canonical.to_s.empty?
114
+
115
+ candidate = fallback_ip.to_s.empty? ? env['REMOTE_ADDR'] : fallback_ip
116
+ redact_ip(candidate)
117
+ end
118
+
119
+ # Reduce a raw address to a log-safe form. See .privacy_safe_ip.
120
+ #
121
+ # @param ip [String, nil] raw address
122
+ # @return [String, nil] masked address, the address itself when
123
+ # private/localhost, '[redacted]' when unparseable, nil when blank
124
+ def self.redact_ip(ip)
125
+ address = ip.to_s.strip
126
+ return nil if address.empty?
127
+ return address if Otto::Privacy::IPPrivacy.private_or_localhost?(address)
128
+
129
+ Otto::Privacy::IPPrivacy.mask_ip(address) || '[redacted]'
130
+ rescue ArgumentError
131
+ '[redacted]'
132
+ end
133
+
85
134
  # Log a timed operation with consistent timing and error handling
86
135
  #
87
136
  # @param level [Symbol] The log level (:debug, :info, :warn, :error)
@@ -113,14 +113,17 @@ class Otto
113
113
  def self.configure_mcp_logging
114
114
  return unless defined?(ActiveSupport::Notifications)
115
115
 
116
+ # Masked address only — see the note on the sibling subscriber in
117
+ # Otto::Security::RateLimiting.configure_rack_attack! (issue #219).
116
118
  ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
117
119
  req = payload[:request]
118
120
  endpoint = req.env['otto.mcp_http_endpoint'] || '/_mcp'
121
+ ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
119
122
 
120
123
  if req.path.start_with?(endpoint)
121
- Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
124
+ Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
122
125
  else
123
- Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
126
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
124
127
  end
125
128
  end
126
129
  end