otto 2.10.0 → 2.12.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 (45) 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/.rubocop_todo.yml +3 -2
  10. data/AGENTS.md +18 -0
  11. data/CHANGELOG.rst +132 -0
  12. data/Gemfile +2 -2
  13. data/Gemfile.lock +8 -8
  14. data/README.md +6 -0
  15. data/docs/guides/configuration_freezing.md +17 -6
  16. data/docs/guides/forwarded-authority.md +5 -1
  17. data/docs/guides/privacy.md +5 -0
  18. data/docs/guides/routing.md +180 -5
  19. data/docs/guides/testing-guide.md +115 -2
  20. data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
  21. data/lib/otto/core/configuration.rb +9 -4
  22. data/lib/otto/core/error_handler.rb +40 -2
  23. data/lib/otto/core/file_safety.rb +24 -8
  24. data/lib/otto/core/router.rb +53 -24
  25. data/lib/otto/core/static_mounts.rb +172 -0
  26. data/lib/otto/core.rb +1 -0
  27. data/lib/otto/env_keys.rb +2 -1
  28. data/lib/otto/privacy/config.rb +19 -13
  29. data/lib/otto/response.rb +5 -2
  30. data/lib/otto/route.rb +1 -1
  31. data/lib/otto/route_handlers/base.rb +1 -1
  32. data/lib/otto/route_handlers/logic_class.rb +86 -34
  33. data/lib/otto/security/config.rb +349 -303
  34. data/lib/otto/security/configurator.rb +82 -45
  35. data/lib/otto/security/core.rb +4 -2
  36. data/lib/otto/security/csp/report_middleware.rb +17 -1
  37. data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
  38. data/lib/otto/security/rate_limiter.rb +8 -5
  39. data/lib/otto/security/trusted_proxy_config.rb +396 -0
  40. data/lib/otto/static.rb +45 -6
  41. data/lib/otto/testing.rb +148 -0
  42. data/lib/otto/utils.rb +63 -14
  43. data/lib/otto/version.rb +1 -1
  44. data/lib/otto.rb +126 -2
  45. metadata +5 -2
@@ -0,0 +1,396 @@
1
+ # lib/otto/security/trusted_proxy_config.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'ipaddr'
6
+ require_relative '../core/freezable'
7
+
8
+ class Otto
9
+ module Security
10
+ # Trusted-proxy resolution settings for one Otto application.
11
+ #
12
+ # An application answers "which peer may speak for the client?" in at most
13
+ # one of three ways (#mode):
14
+ #
15
+ # - :filter — enumerated proxy entries (IP, CIDR, Regexp, or a legacy
16
+ # string prefix); the client IP is found by walking the forwarded chain
17
+ # past trusted hops.
18
+ # - :depth — trust the last N hops, for proxy tiers whose addresses cannot
19
+ # be enumerated (Fly, cloud load balancers, dynamic reverse proxies).
20
+ # - :none — the explicit operator assertion that no proxy is trusted.
21
+ #
22
+ # #header picks the forwarded header depth mode counts hops from.
23
+ #
24
+ # This object owns the rules that keep those settings coherent. #mode is
25
+ # derived from the stored settings rather than stored beside them, and
26
+ # every mutator checks the state it would produce with #ensure_compatible!,
27
+ # the same check #validate! runs at freeze, so each rule is written once.
28
+ #
29
+ # Rules that involve other objects stay with Otto::Security::Config: the
30
+ # ip_privacy geo_header vs depth conflict, and pinning Rack's
31
+ # process-global forwarding family. Config keeps this object private and
32
+ # routes every change through its own setters (add_trusted_proxy,
33
+ # trusted_proxy_depth=, trusted_proxy_header=, trust_no_proxies!), which
34
+ # add those checks; #check_depth! and #check_header! let Config run them
35
+ # before anything is stored.
36
+ class TrustedProxyConfig
37
+ include Otto::Core::Freezable
38
+
39
+ # Error raised when the two mutually-exclusive trusted-proxy resolution
40
+ # modes are configured together: CIDR-walk (enumerated trusted_proxies)
41
+ # and count-based depth (trusted_proxy_depth >= 1).
42
+ PROXY_MODE_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
43
+ Cannot configure both trusted_proxies (CIDR filter mode) and
44
+ trusted_proxy_depth >= 1 (count mode). Enumerate proxy CIDRs OR set a
45
+ hop count, not both.
46
+ MSG
47
+
48
+ # Error raised when the explicit "trust no proxy" assertion
49
+ # (trust_no_proxies!, `trusted_proxies: :none`) is combined with an
50
+ # actual trust grant (enumerated CIDRs or a depth >= 1). The two say
51
+ # opposite things about the same peer, so the combination is refused at
52
+ # configuration time rather than silently resolved in one direction.
53
+ TRUST_NO_PROXIES_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
54
+ Cannot combine trusted_proxies: :none (trust no proxy) with
55
+ trusted_proxies CIDRs or trusted_proxy_depth >= 1. Assert :none OR
56
+ grant trust, not both.
57
+ MSG
58
+
59
+ # Error raised when the trust-nobody sentinel arrives as a proxy ENTRY
60
+ # (`trusted_proxies: ['none']`, as a YAML/JSON list naturally yields, or
61
+ # `add_trusted_proxy('none')`) instead of as the whole option. Inside a
62
+ # list it would otherwise register a legacy string-prefix matcher that
63
+ # matches nothing: peers would be untrusted, but trust_no_proxies? would
64
+ # stay false and the config would stake a forwarding-family claim, so the
65
+ # explicit assertion would be silently replaced by a lookalike.
66
+ TRUST_NO_PROXIES_ENTRY_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
67
+ trusted_proxies entry :none is the trust-nobody assertion, not a proxy
68
+ address. Pass trusted_proxies: :none as the whole option (not inside a
69
+ list) or call trust_no_proxies! instead.
70
+ MSG
71
+
72
+ # Error raised when a non-default header is combined with CIDR filter
73
+ # mode. Otto's CIDR-walk resolves the client IP from the X-Forwarded-For
74
+ # family only (X-Forwarded-For, then X-Real-IP, then X-Client-IP —
75
+ # Otto::Utils::FORWARDED_FOR_HEADERS), never RFC 7239 Forwarded, while
76
+ # trusted_proxy_header also pins Rack's forwarding family; honoring
77
+ # 'Forwarded' or 'Both' there would make Rack read a header Otto ignores,
78
+ # recreating the disagreement the pin exists to close.
79
+ FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
80
+ Cannot configure trusted_proxy_header 'Forwarded' or 'Both' together
81
+ with trusted_proxies (CIDR filter mode): CIDR-walk resolves client IPs
82
+ from the X-Forwarded-For family only (X-Forwarded-For, X-Real-IP,
83
+ X-Client-IP), never RFC 7239 Forwarded. Use trusted_proxy_depth (count
84
+ mode) to read the RFC 7239 Forwarded header.
85
+ MSG
86
+
87
+ # Sentinel accepted wherever a trusted_proxies list is accepted, meaning
88
+ # "the operator asserts that NO proxy is trusted". See #trust_none!.
89
+ TRUST_NO_PROXIES = :none
90
+
91
+ # Forwarded-header sources depth mode can count hops from:
92
+ # X-Forwarded-For (default), the RFC 7239 Forwarded header, or Both
93
+ # (Forwarded when present, else X-Forwarded-For). Mirrors OneTimeSecret's
94
+ # site.network.trusted_proxy.header. Only consulted in depth mode;
95
+ # CIDR-walk is unaffected.
96
+ HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
97
+ DEFAULT_HEADER = 'X-Forwarded-For'
98
+
99
+ # Whether a trusted_proxies option value is the trust-nobody sentinel.
100
+ # Accepts the symbol and the String spelling 'none' (case-insensitive),
101
+ # which is what YAML/ENV-driven configuration naturally produces; without
102
+ # this, 'none' would fall through to #add and install a legacy
103
+ # string-prefix matcher, silently inverting the assertion.
104
+ #
105
+ # @param value [Object] raw trusted_proxies option
106
+ # @return [Boolean]
107
+ def self.trust_no_proxies_option?(value)
108
+ (value.is_a?(Symbol) || value.is_a?(String)) && value.to_s.casecmp?('none')
109
+ end
110
+
111
+ # Proxy entries in registration order (filter mode).
112
+ # @return [Array<String, Regexp>]
113
+ attr_reader :proxies
114
+
115
+ # Count-based depth; nil or 0 disables depth mode.
116
+ # @return [Integer, nil]
117
+ attr_reader :depth
118
+
119
+ # Canonical forwarded header depth mode counts hops from.
120
+ # @return [String] one of HEADERS
121
+ attr_reader :header
122
+
123
+ def initialize
124
+ @proxies = []
125
+ @matchers = []
126
+ @trust_none = false
127
+ @depth = nil
128
+ @header = DEFAULT_HEADER
129
+ end
130
+
131
+ # The active resolution mode, or nil when proxy trust is unconfigured.
132
+ #
133
+ # @return [Symbol, nil] :filter, :depth, :none, or nil
134
+ def mode
135
+ return :filter if filter?
136
+ return :depth if depth?
137
+
138
+ :none if trust_none?
139
+ end
140
+
141
+ # Whether any mode is configured. When false, Otto leaves
142
+ # env['otto.via_trusted_proxy'] absent (the tri-state contract).
143
+ #
144
+ # @return [Boolean]
145
+ def configured?
146
+ !mode.nil?
147
+ end
148
+
149
+ # Whether proxy entries are registered (CIDR filter mode).
150
+ #
151
+ # @return [Boolean]
152
+ def filter?
153
+ @matchers.any?
154
+ end
155
+
156
+ # Whether count-based depth mode is active. Integer-strict, so a value
157
+ # that never passed #depth= cannot enable it.
158
+ #
159
+ # @return [Boolean] true when depth is an Integer >= 1
160
+ def depth?
161
+ @depth.is_a?(Integer) && @depth >= 1
162
+ end
163
+
164
+ # Whether the operator asserted that no proxy is trusted.
165
+ #
166
+ # @return [Boolean]
167
+ def trust_none?
168
+ @trust_none
169
+ end
170
+
171
+ # Whether request handling reads a forwarded chain, and so depends on
172
+ # Rack's process-global forwarding family. True in filter and depth mode;
173
+ # false under trust-nobody (reads nothing) and when unconfigured.
174
+ #
175
+ # @return [Boolean]
176
+ def forwarding_family_dependent?
177
+ filter? || depth?
178
+ end
179
+
180
+ # Whether #header is the X-Forwarded-For default.
181
+ #
182
+ # @return [Boolean]
183
+ def default_header?
184
+ @header == DEFAULT_HEADER
185
+ end
186
+
187
+ # Register one entry or a list of entries (filter mode). The whole list
188
+ # is validated before anything is registered, so a rejected list leaves
189
+ # this object untouched.
190
+ #
191
+ # @param proxy [String, Regexp, Array<String, Regexp>] entry or entries
192
+ # @raise [ArgumentError] on a mode conflict, a trust-nobody sentinel
193
+ # inside the list, or an unsupported type
194
+ # @raise [FrozenError] if frozen
195
+ # @return [void]
196
+ def add(proxy)
197
+ ensure_not_frozen!
198
+ # Adding claims filter mode even when the list is empty, so the
199
+ # conflict surfaces at the call that introduced it.
200
+ ensure_compatible!(filter: true)
201
+ Array(proxy).each do |entry|
202
+ raise ArgumentError, TRUST_NO_PROXIES_ENTRY_MESSAGE if self.class.trust_no_proxies_option?(entry)
203
+ end
204
+
205
+ case proxy
206
+ when String, Regexp
207
+ @proxies << proxy
208
+ @matchers << build_matcher(proxy)
209
+ when Array
210
+ # Build every matcher before touching state, so a failure partway
211
+ # through cannot leave entries and matchers out of step.
212
+ matchers = proxy.map { |entry| build_matcher(entry) }
213
+ @proxies.concat(proxy)
214
+ @matchers.concat(matchers)
215
+ else
216
+ raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
217
+ end
218
+ end
219
+
220
+ # Assert that no proxy is trusted.
221
+ #
222
+ # @raise [ArgumentError] if entries or a depth >= 1 are configured
223
+ # @raise [FrozenError] if frozen
224
+ # @return [void]
225
+ def trust_none!
226
+ ensure_not_frozen!
227
+ ensure_compatible!(trust_none: true)
228
+ @trust_none = true
229
+ end
230
+
231
+ # Raise unless depth could be assigned: a non-negative Integer or nil,
232
+ # compatible with the current mode. Stores nothing.
233
+ #
234
+ # @param depth [Object] candidate value
235
+ # @raise [ArgumentError] if invalid or conflicting
236
+ # @return [Integer, nil] depth
237
+ def check_depth!(depth)
238
+ validate_depth_value!(depth)
239
+ ensure_compatible!(depth: depth.to_i >= 1)
240
+ depth
241
+ end
242
+
243
+ # @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
244
+ # @raise [ArgumentError] if invalid or conflicting (see #check_depth!)
245
+ # @raise [FrozenError] if frozen
246
+ def depth=(depth)
247
+ ensure_not_frozen!
248
+ @depth = check_depth!(depth)
249
+ end
250
+
251
+ # Raise unless header could be assigned, and return its canonical
252
+ # spelling. Matching is case-insensitive and ignores surrounding
253
+ # whitespace; an unrecognized value fails loud instead of silently
254
+ # resolving from the wrong header. Stores nothing.
255
+ #
256
+ # @param header [Object] candidate value
257
+ # @raise [ArgumentError] if unrecognized or conflicting with filter mode
258
+ # @return [String] canonical header (one of HEADERS)
259
+ def check_header!(header)
260
+ candidate = header.to_s.strip
261
+ canonical = HEADERS.find { |allowed| allowed.casecmp?(candidate) }
262
+ raise ArgumentError, invalid_header_message(header) unless canonical
263
+
264
+ ensure_compatible!(header: canonical)
265
+ canonical
266
+ end
267
+
268
+ # @param header [String] one of HEADERS (case-insensitive)
269
+ # @raise [ArgumentError] if invalid or conflicting (see #check_header!)
270
+ # @raise [FrozenError] if frozen
271
+ def header=(header)
272
+ ensure_not_frozen!
273
+ @header = check_header!(header)
274
+ end
275
+
276
+ # Whether ip matches a registered entry.
277
+ #
278
+ # String entries that parse as an IP or CIDR range are matched with
279
+ # proper IPAddr containment (IPv4 and IPv6). Entries that are not valid
280
+ # IPs (e.g. a bare prefix like '172.16.') fall back to the legacy
281
+ # exact/prefix string match for backward compatibility. Regexp entries
282
+ # are matched against the raw IP string. Entries are parsed once at
283
+ # registration, never per request.
284
+ #
285
+ # @param ip [String] IP address to check
286
+ # @return [Boolean]
287
+ def trusted?(ip)
288
+ return false if @matchers.empty? || ip.nil? || ip.empty?
289
+
290
+ # Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack
291
+ # peer presented in mapped form still matches an IPv4 proxy entry.
292
+ client = parse_ipaddr(ip)&.native
293
+
294
+ @matchers.any? do |entry, range|
295
+ if range
296
+ # Pre-parsed IP/CIDR entry -> proper containment
297
+ client && ip_in_range?(range, client)
298
+ elsif entry.is_a?(Regexp)
299
+ entry.match?(ip)
300
+ elsif entry.is_a?(String)
301
+ # Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match
302
+ ip == entry || ip.start_with?(entry)
303
+ else
304
+ false
305
+ end
306
+ end
307
+ end
308
+
309
+ # Re-check every rule against the stored state. The mutators already
310
+ # enforce them; this is the freeze-time backstop for state that bypassed
311
+ # them (a direct instance-variable write).
312
+ #
313
+ # @raise [ArgumentError] if any rule is violated
314
+ # @return [void]
315
+ def validate!
316
+ raise ArgumentError, invalid_header_message(@header) unless HEADERS.include?(@header)
317
+
318
+ validate_depth_value!(@depth)
319
+ ensure_compatible!
320
+ end
321
+
322
+ private
323
+
324
+ def ensure_not_frozen!
325
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
326
+ end
327
+
328
+ # The mutual-exclusion rules, in one place. Each flag says whether that
329
+ # mode would be active; a mutator overrides the one it is about to
330
+ # change and the rest default to the stored state.
331
+ def ensure_compatible!(filter: filter?, depth: depth?, trust_none: trust_none?, header: @header)
332
+ raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if filter && depth
333
+ raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if trust_none && (filter || depth)
334
+ raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE if filter && header != DEFAULT_HEADER
335
+ end
336
+
337
+ # Type and range only, so an invalid value raises a clear ArgumentError
338
+ # instead of a downstream NoMethodError from #to_i coercion.
339
+ def validate_depth_value!(depth)
340
+ return if depth.nil?
341
+
342
+ unless depth.is_a?(Integer)
343
+ raise ArgumentError,
344
+ "trusted_proxy_depth must be an Integer or nil, got #{depth.class}"
345
+ end
346
+
347
+ raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
348
+ end
349
+
350
+ def invalid_header_message(header)
351
+ "trusted_proxy_header must be one of #{HEADERS.join(', ')}, got #{header.inspect}"
352
+ end
353
+
354
+ # Parse a value into an IPAddr, returning nil for invalid / non-IP input.
355
+ def parse_ipaddr(value)
356
+ IPAddr.new(value)
357
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
358
+ nil
359
+ end
360
+
361
+ # Build a cached [raw_entry, parsed_range_or_nil] tuple at registration.
362
+ #
363
+ # The parsed range is folded through IPAddr#native, to match the fold
364
+ # #trusted? applies to the client address. Without it a mapped-IPv6
365
+ # proxy entry (::ffff:10.0.0.0/104) could never match, because
366
+ # #ip_in_range?'s family check would reject the folded IPv4 client — a
367
+ # proxy silently untrusted, which is what gates otto.via_trusted_proxy,
368
+ # secure?, and geo-header trust. #native returns self for entries that
369
+ # are not IPv4-mapped/compatible.
370
+ def build_matcher(entry)
371
+ return [entry, nil] unless entry.is_a?(String)
372
+
373
+ range = parse_ipaddr(entry)&.native
374
+ warn_legacy_proxy_entry(entry) unless range
375
+ [entry, range]
376
+ end
377
+
378
+ def warn_legacy_proxy_entry(entry)
379
+ Otto.logger.warn(
380
+ "[Otto::Security::Config] trusted proxy #{entry.inspect} is not a " \
381
+ 'valid IP or CIDR; using legacy string-prefix matching. Prefer a ' \
382
+ "CIDR range (e.g. '172.16.0.0/12')."
383
+ )
384
+ end
385
+
386
+ # CIDR/host containment that is safe across address families.
387
+ def ip_in_range?(range, client)
388
+ return false unless range.family == client.family
389
+
390
+ range.include?(client)
391
+ rescue IPAddr::InvalidAddressError
392
+ false
393
+ end
394
+ end
395
+ end
396
+ end
data/lib/otto/static.rb CHANGED
@@ -7,20 +7,59 @@ class Otto
7
7
  module Static
8
8
  extend self
9
9
 
10
- def server_error
11
- [500, security_headers.merge({ 'content-type' => 'text/plain' }), ['Server error']]
10
+ def server_error(security_config = nil)
11
+ [500, security_headers(security_config).merge({ 'content-type' => 'text/plain' }), ['Server error']]
12
12
  end
13
13
 
14
- def not_found
15
- [404, security_headers.merge({ 'content-type' => 'text/plain' }), ['Not Found']]
14
+ def not_found(security_config = nil)
15
+ [404, security_headers(security_config).merge({ 'content-type' => 'text/plain' }), ['Not Found']]
16
16
  end
17
17
 
18
- def security_headers
18
+ # Return a per-request copy of a Rack triple so callers can never hand a
19
+ # shared object back to the Rack stack.
20
+ #
21
+ # Middleware above Otto (rack-session, Otto's own CSRF middleware, anything
22
+ # that calls +Rack::Utils.set_cookie_header!+) writes response headers in
23
+ # place. Returning a configured triple by reference lets those writes
24
+ # accumulate on the shared object for the life of the process, so every
25
+ # subsequent 404/500 replays every Set-Cookie any earlier one committed.
26
+ #
27
+ # The copy is intentionally shallow-plus-one: the headers container keeps
28
+ # its class (a +Rack::Headers+ stays case-insensitive), each Array-valued
29
+ # header (Rack 3's representation of a repeated header) is copied so an
30
+ # append cannot reach the shared Array, and an Array body is copied so a
31
+ # middleware appending chunks cannot grow the shared body. A frozen
32
+ # configured triple yields an unfrozen copy, so cookie middleware works
33
+ # after configuration freezing as well.
34
+ #
35
+ # @param response [Array] a Rack triple +[status, headers, body]+
36
+ # @return [Array] a new triple that shares no mutable container with +response+
37
+ def copy_response(response)
38
+ status, headers, body = response
39
+ [status, copy_headers(headers), body.is_a?(Array) ? body.dup : body]
40
+ end
41
+
42
+ # Copy a Rack headers container, keeping its class and copying Array values.
43
+ #
44
+ # @param headers [Hash, Rack::Headers, nil] the headers to copy
45
+ # @return [Hash, Rack::Headers] a new container of the same class
46
+ def copy_headers(headers)
47
+ return {} if headers.nil?
48
+
49
+ copied = headers.dup
50
+ copied.each_pair do |key, value|
51
+ copied[key] = value.dup if value.is_a?(Array)
52
+ end
53
+ copied
54
+ end
55
+
56
+ def security_headers(security_config = nil)
19
57
  {
20
58
  'x-frame-options' => 'DENY',
21
59
  'x-content-type-options' => 'nosniff',
22
60
  'x-xss-protection' => '1; mode=block',
23
- 'referrer-policy' => 'strict-origin-when-cross-origin',
61
+ 'referrer-policy' => security_config&.referrer_policy ||
62
+ Otto::Security::Config::DEFAULT_REFERRER_POLICY,
24
63
  }
25
64
  end
26
65
 
@@ -0,0 +1,148 @@
1
+ # lib/otto/testing.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'rack/mock'
6
+ require_relative '../otto'
7
+
8
+ class Otto
9
+ # Test support for applications built on Otto, independent of the test
10
+ # framework. `require 'otto'` does not load this file; require
11
+ # 'otto/testing' from a test helper. Loading it is the opt-in for the
12
+ # resets below, which production code must never call.
13
+ #
14
+ # @example RSpec
15
+ # require 'otto/testing'
16
+ # RSpec.configure { |c| c.before { Otto::Testing.reset! } }
17
+ #
18
+ # @example Minitest
19
+ # require 'otto/testing'
20
+ # class Minitest::Test
21
+ # def before_setup
22
+ # super
23
+ # Otto::Testing.reset!
24
+ # end
25
+ # end
26
+ #
27
+ # @example Tryouts (setup runs once per file, so reset inside each case)
28
+ # ## a depth-mode app reading Forwarded
29
+ # Otto::Testing.reset!
30
+ # Otto.new(nil, trusted_proxy_depth: 1, trusted_proxy_header: 'Forwarded')
31
+ module Testing
32
+ # Stands in for the application behind IPPrivacyMiddleware; only the env
33
+ # the middleware leaves behind is of interest.
34
+ RESOLVED_APP = ->(_env) { [200, {}, []] }
35
+ private_constant :RESOLVED_APP
36
+
37
+ module_function
38
+
39
+ # Clear the process-global state Otto accumulates across Otto.new calls,
40
+ # so one test's applications cannot decide whether the next test's raise.
41
+ #
42
+ # Today that is the forwarding-family registry and
43
+ # Rack::Request.forwarded_priority, which Otto pins from
44
+ # trusted_proxy_header. Rack's setting is one per process, so a second
45
+ # application choosing a different family raises ArgumentError; in a
46
+ # suite that builds applications with different families, the outcome
47
+ # would otherwise depend on test order.
48
+ #
49
+ # Call it before every test (or after every test), not once per suite.
50
+ #
51
+ # @return [nil]
52
+ def reset!
53
+ Otto::Security::Config.send(:reset_rack_forwarding_family!)
54
+ nil
55
+ end
56
+
57
+ # Build a Rack env as IPPrivacyMiddleware leaves it for a request arriving
58
+ # directly from client_ip: env['otto.client_ip'] (masked under the
59
+ # privacy profile in force), env['otto.ip_match'] over the unmasked
60
+ # address, the rewritten REMOTE_ADDR, and the proxy-trust verdict when
61
+ # trust is configured.
62
+ #
63
+ # Use it instead of writing env['otto.client_ip'] by hand. A hand-written
64
+ # value looks to the middleware like a prior pass, so the precise
65
+ # otto.ip_match is never built and every CIDR check denies.
66
+ #
67
+ # security_config is required because the app's own IPPrivacyMiddleware
68
+ # keeps whatever this resolution produced: pass the application's
69
+ # (otto.security_config) so the privacy profile and proxy trust match the
70
+ # app under test. nil means an unconfigured middleware: public addresses
71
+ # masked, no proxy trust.
72
+ #
73
+ # The request is direct, so the headers in
74
+ # Otto::Utils::CLIENT_ADDRESS_HEADERS (X-Forwarded-For, X-Real-IP,
75
+ # X-Client-IP, Forwarded) raise ArgumentError: under a config
76
+ # that trusts the peer they would move the resolved address away from
77
+ # client_ip. For a relayed request, build the env with REMOTE_ADDR and the
78
+ # forwarded headers and call {.resolve_client_ip!}.
79
+ #
80
+ # @param uri [String] passed to Rack::MockRequest.env_for
81
+ # @param client_ip [String, nil] the connecting address; nil builds a
82
+ # request with no resolvable client IP, whose otto.ip_match denies
83
+ # every range and whose env['otto.client_ip'] is nil
84
+ # @param security_config [Otto::Security::Config, nil]
85
+ # @param rack_options [Hash] remaining Rack::MockRequest.env_for options
86
+ # (method:, params:, input:, and String env keys such as
87
+ # 'HTTP_USER_AGENT')
88
+ # @return [Hash] the env
89
+ # @raise [ArgumentError] if rack_options carry a forwarded-for header
90
+ #
91
+ # @example
92
+ # env = Otto::Testing.env_for('/admin', client_ip: '203.0.113.9',
93
+ # security_config: otto.security_config)
94
+ # env['otto.client_ip'] # => "203.0.113.0"
95
+ # env['otto.ip_match'].call(['203.0.113.9/32']) # => true
96
+ def env_for(uri = '/', client_ip:, security_config:, **rack_options)
97
+ forwarded = Otto::Utils::CLIENT_ADDRESS_HEADERS & rack_options.keys
98
+ unless forwarded.empty?
99
+ raise ArgumentError, "env_for builds a direct request from client_ip; #{forwarded.join(', ')} " \
100
+ 'would change which address resolves. Build the env yourself and call ' \
101
+ 'Otto::Testing.resolve_client_ip! for a relayed request.'
102
+ end
103
+
104
+ env = Rack::MockRequest.env_for(uri, rack_options)
105
+ if client_ip.nil?
106
+ env.delete('REMOTE_ADDR')
107
+ else
108
+ env['REMOTE_ADDR'] = client_ip
109
+ end
110
+ resolve_client_ip!(env, security_config)
111
+ end
112
+
113
+ # Run IPPrivacyMiddleware over an existing env, in place, so it carries
114
+ # the keys the middleware writes, all derived from one resolution. Use it
115
+ # for relayed requests: REMOTE_ADDR is the proxy and the forwarded headers
116
+ # carry the client.
117
+ #
118
+ # Pass the application's security config. Its proxy trust decides whether
119
+ # the forwarded headers are read at all; resolved without it, the proxy
120
+ # becomes the client, and the app's middleware keeps that result.
121
+ #
122
+ # @param env [Hash] Rack env, with REMOTE_ADDR and any forwarded headers
123
+ # @param security_config [Otto::Security::Config, nil] see {.env_for}
124
+ # @return [Hash] the same env
125
+ # @raise [ArgumentError] if env was already resolved, since the
126
+ # middleware would keep the earlier result instead of applying
127
+ # security_config
128
+ # @raise [RuntimeError] if the middleware returned without installing
129
+ # otto.ip_match, which every path that resolves an env writes
130
+ def resolve_client_ip!(env, security_config)
131
+ if env.key?('otto.client_ip') || env.key?('otto.ip_match')
132
+ raise ArgumentError, 'env already carries otto.client_ip or otto.ip_match, so IPPrivacyMiddleware ' \
133
+ 'would keep that result instead of resolving under this security_config'
134
+ end
135
+
136
+ Otto::Security::Middleware::IPPrivacyMiddleware.new(RESOLVED_APP, security_config).call(env)
137
+ # The middleware's response is discarded, so check its effect instead: a
138
+ # path that answered before resolving would otherwise hand back an env
139
+ # that looks built and is not.
140
+ unless env.key?('otto.ip_match')
141
+ raise 'IPPrivacyMiddleware returned without installing otto.ip_match; Otto::Testing ' \
142
+ 'no longer matches the middleware and needs updating'
143
+ end
144
+
145
+ env
146
+ end
147
+ end
148
+ end