otto 2.11.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.
@@ -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,12 +7,12 @@ 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
18
  # Return a per-request copy of a Rack triple so callers can never hand a
@@ -53,12 +53,13 @@ class Otto
53
53
  copied
54
54
  end
55
55
 
56
- def security_headers
56
+ def security_headers(security_config = nil)
57
57
  {
58
58
  'x-frame-options' => 'DENY',
59
59
  'x-content-type-options' => 'nosniff',
60
60
  'x-xss-protection' => '1; mode=block',
61
- 'referrer-policy' => 'strict-origin-when-cross-origin',
61
+ 'referrer-policy' => security_config&.referrer_policy ||
62
+ Otto::Security::Config::DEFAULT_REFERRER_POLICY,
62
63
  }
63
64
  end
64
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
data/lib/otto/utils.rb CHANGED
@@ -45,6 +45,14 @@ class Otto
45
45
  # fallback scan cannot drift.
46
46
  RELAY_MARKER_HEADERS = (FORWARDED_FOR_HEADERS + FORWARDED_AUTHORITY_HEADERS).uniq.freeze
47
47
 
48
+ # Headers the client-IP resolver may read an address from: the
49
+ # forwarded-for family (the CIDR walk, and X-Forwarded-For in depth mode)
50
+ # and RFC 7239 Forwarded (depth mode with trusted_proxy_header 'Forwarded'
51
+ # or 'Both'). A header resolve_client_ip starts reading belongs here, so
52
+ # that IPPrivacyMiddleware deletes it when no client IP resolves and
53
+ # Otto::Testing.env_for refuses it in a request it builds as direct.
54
+ CLIENT_ADDRESS_HEADERS = (FORWARDED_FOR_HEADERS + %w[HTTP_FORWARDED]).freeze
55
+
48
56
  # Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
49
57
  # predicates do not cover but that should still be treated as non-public
50
58
  # (e.g. when picking the real client out of a forwarded chain).
@@ -100,20 +108,21 @@ class Otto
100
108
  # scrub invalid/undefined bytes, and strip a single trailing slash.
101
109
  #
102
110
  # This is the SINGLE SOURCE OF TRUTH shared by the router
103
- # (Otto::Core::Router#handle_request, which compares the result against its
104
- # literal-route table) and Otto::CaddyTLS::LocalhostGuard (which compares it
105
- # against the guarded endpoint). The two MUST normalize identically: if a
106
- # crafted path — a trailing slash, a percent-encoded byte, an invalid UTF-8
107
- # byte — normalized differently in the guard than in the router, the router
108
- # could dispatch a request the guard let through, bypassing the loopback
109
- # check. One implementation makes that drift impossible.
110
- #
111
- # Mirrors the empty-path handling and :replace scrubbing the router applies.
112
- # Robust to invalid input: Rack::Utils.unescape raises ArgumentError on an
113
- # already-invalid byte sequence (a raw \xFF in the path), so that is caught
114
- # and the raw string is scrubbed instead — a percent-encoded invalid byte
115
- # (%FF) decodes to the same invalid byte and is scrubbed identically, so the
116
- # two crafted forms normalize alike. The method itself does not raise.
111
+ # (Otto::Core::Router#handle_request, through #routing_path) and every guard
112
+ # that compares a request path or a configured path against what the router
113
+ # dispatches (Otto::CaddyTLS::LocalhostGuard, Otto::MCP.endpoint_path?).
114
+ # For a request, call #routing_path rather than passing PATH_INFO here
115
+ # yourself. Guard and router MUST normalize identically: if a crafted
116
+ # path — a trailing slash, a percent-encoded byte, an invalid UTF-8 byte —
117
+ # normalized differently in the guard than in the router, the router could
118
+ # dispatch a request the guard let through. One implementation makes that
119
+ # drift impossible.
120
+ #
121
+ # Robust to invalid input. Rack::Utils.unescape raises ArgumentError on a
122
+ # malformed escape (%zz, a trailing %) and on an invalid byte in a
123
+ # UTF-8-tagged string (a raw \xFF); either way the raw string is kept.
124
+ # Invalid UTF-8 is scrubbed after that, so a raw \xFF and a percent-encoded
125
+ # %FF normalize alike. The method itself does not raise.
117
126
  #
118
127
  # @param raw_path [String, nil] a raw PATH_INFO or a configured endpoint
119
128
  # @return [String] normalized path suitable for exact literal comparison
@@ -131,6 +140,46 @@ class Otto
131
140
  .gsub(%r{/$}, '')
132
141
  end
133
142
 
143
+ # The path Otto's router matches for this request. Use it in any code that
144
+ # judges a request by its path before the router sees it: guards,
145
+ # throttles, session skips, audit filters.
146
+ #
147
+ # The router does not match raw PATH_INFO. It matches this value, and
148
+ # Otto::Core::Router#handle_request calls this method to get it, so a guard
149
+ # that reads routing_path sees the path the router dispatches on. A guard
150
+ # that reads anything else can see a different path, and when it matches
151
+ # less than the router does the difference is a bypass: GET /%63olonel is
152
+ # '/%63olonel' as raw PATH_INFO and '/colonel' to the router.
153
+ #
154
+ # By default the result is mount-relative. Rack::URLMap
155
+ # (`map '/api' { run otto }`) moves the mount prefix into SCRIPT_NAME and
156
+ # leaves the remainder in PATH_INFO, which is all the router sees; this is
157
+ # the form to compare against paths as written in a routes file.
158
+ #
159
+ # With +include_mount: true+ SCRIPT_NAME and PATH_INFO are joined and then
160
+ # normalized as one string, giving the request's full path. That is
161
+ # the form for middleware shared by several mounted apps and configured
162
+ # with external URLs: inside an app mounted at /api/v2, '/status' is the
163
+ # mount-relative path and '/api/v2/status' the mounted one, and matching
164
+ # the mount-relative form would also match every other app's /status.
165
+ #
166
+ # The value is normalize_path output, so root is '' (the router's literal
167
+ # table keys root the same way) and a configured path must go through
168
+ # normalize_path before an exact comparison. Never raises: a malformed
169
+ # escape such as %zz is kept as written.
170
+ #
171
+ # Not memoized: middleware may rewrite PATH_INFO or SCRIPT_NAME, and the
172
+ # router must see the value as it stands at dispatch.
173
+ #
174
+ # @param env [Hash] Rack environment
175
+ # @param include_mount [Boolean] prepend SCRIPT_NAME (the mount prefix)
176
+ # @return [String] normalized path
177
+ def routing_path(env, include_mount: false)
178
+ path = env['PATH_INFO']
179
+ path = "#{env['SCRIPT_NAME']}#{path}" if include_mount
180
+ normalize_path(path)
181
+ end
182
+
134
183
  # Validate and normalize an IP address (IPv4 and IPv6).
135
184
  #
136
185
  # Strips an optional port (IPv6-safe), validates with IPAddr, and returns
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.11.0'
6
+ VERSION = '2.12.0'
7
7
  end