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
@@ -41,6 +41,7 @@ require 'rack/mock'
41
41
  require 'rspec'
42
42
  require 'tempfile'
43
43
  require 'otto'
44
+ require 'otto/testing'
44
45
 
45
46
  module OttoAppSpecHelpers
46
47
  def rack_env(path = '/', method: 'GET', headers: {}, params: {})
@@ -69,6 +70,10 @@ end
69
70
  RSpec.configure do |config|
70
71
  config.include OttoAppSpecHelpers
71
72
 
73
+ config.before do
74
+ Otto::Testing.reset!
75
+ end
76
+
72
77
  config.after do
73
78
  Array(@route_files).each(&:unlink)
74
79
  end
@@ -79,6 +84,46 @@ Within Otto itself, use the existing helpers in
79
84
  [`spec/support/test_helpers.rb`](../../spec/support/test_helpers.rb) instead of
80
85
  copying this application-level helper.
81
86
 
87
+ ## Reset Otto's process-global state between tests
88
+
89
+ `require 'otto'` does not load `otto/testing`. Require it from the test helper;
90
+ it does not depend on RSpec.
91
+
92
+ `Otto.new` pins `Rack::Request.forwarded_priority` from `trusted_proxy_header`
93
+ whenever an application configures proxy trust or names a header. Rack keeps one
94
+ priority per process, so Otto records the family and raises `ArgumentError`
95
+ when a later application in the same process chooses a different one. A suite
96
+ that builds one application with `trusted_proxy_header: 'Forwarded'` and
97
+ another with `trusted_proxies:` fails or passes depending on test order unless
98
+ the record is cleared between tests. `Otto::Testing.reset!` clears it and
99
+ restores Rack's priority to the value Otto saw at load time.
100
+
101
+ Call it before every test:
102
+
103
+ ```ruby
104
+ # RSpec
105
+ RSpec.configure { |config| config.before { Otto::Testing.reset! } }
106
+
107
+ # Minitest
108
+ class Minitest::Test
109
+ def before_setup
110
+ super
111
+ Otto::Testing.reset!
112
+ end
113
+ end
114
+
115
+ # Tryouts: at the start of each test case that builds an Otto app
116
+ ## a depth-mode app reading Forwarded
117
+ Otto::Testing.reset!
118
+ Otto.new(nil, trusted_proxy_depth: 1, trusted_proxy_header: 'Forwarded')
119
+ ```
120
+
121
+ Tryouts runs a file's setup section once, before all of its test cases, so a
122
+ reset placed there does not separate the cases from each other.
123
+
124
+ `Otto::Security::Config.reset_rack_forwarding_family_for_testing!` does the
125
+ same but raises unless RSpec is loaded. It remains for existing callers.
126
+
82
127
  ## Test Logic classes as plain Ruby objects
83
128
 
84
129
  Logic classes receive an authentication result, merged parameters, and a locale.
@@ -260,7 +305,9 @@ Otto's parser. Send an `application/json` body through a real Otto instance or
260
305
 
261
306
  Current behavior to cover explicitly:
262
307
 
263
- - a JSON object is merged into the Logic parameters;
308
+ - a JSON object is merged into the Logic parameters below path captures, the
309
+ query string and any form body, which all win over it;
310
+ - a JSON body on `GET` or `HEAD` is ignored;
264
311
  - a valid non-object JSON value is ignored;
265
312
  - malformed JSON is logged and the Logic class continues with other parameters;
266
313
  - non-JSON bodies are not parsed by the Logic handler.
@@ -343,6 +390,52 @@ octets, or set `mask_private_ips = true` to include private and localhost
343
390
  addresses. For application-facing tests, prefer a real Otto instance configured
344
391
  through `configure_ip_privacy`.
345
392
 
393
+ ### Hand a harness a resolved client IP
394
+
395
+ Code that runs behind `IPPrivacyMiddleware` reads `env['otto.client_ip']` and,
396
+ for access decisions, `env['otto.ip_match']`. Do not write `otto.client_ip` by
397
+ hand. The middleware treats its presence as a sign that it already ran, so it
398
+ never builds `otto.ip_match` from the full address. Instead it installs a check
399
+ that returns `false` for every range and logs a warning. Allowlist tests then
400
+ deny, and when the application uses CIDR proxy trust it also treats the peer as
401
+ untrusted.
402
+
403
+ `Otto::Testing.env_for` runs the middleware over a Rack env for a request
404
+ arriving directly from `client_ip`, so both keys come from one resolution:
405
+
406
+ ```ruby
407
+ env = Otto::Testing.env_for('/admin', client_ip: '203.0.113.9',
408
+ security_config: otto.security_config)
409
+
410
+ env['otto.client_ip'] # => "203.0.113.0" (masked)
411
+ env['otto.ip_match'].call(['203.0.113.9/32']) # => true
412
+ ```
413
+
414
+ `security_config:` is required. The application's own middleware keeps what
415
+ this resolution produced, so pass `otto.security_config` to get the masking and
416
+ proxy trust of the application under test; `nil` means an unconfigured
417
+ middleware (public addresses masked, no proxy trust). `client_ip: nil` builds a
418
+ request with no resolvable client IP: `env['otto.client_ip']` is `nil` and
419
+ `otto.ip_match` denies every range. Other keywords and String env keys go to
420
+ `Rack::MockRequest.env_for`.
421
+
422
+ `env_for` models a direct request, so it raises `ArgumentError` when given
423
+ `X-Forwarded-For`, `X-Real-IP`, `X-Client-IP` or `Forwarded`: under a
424
+ configuration that trusts the peer, those would resolve an address other than
425
+ `client_ip`. For a request relayed by a proxy, build the env with `REMOTE_ADDR`
426
+ and the forwarded headers, then resolve it under the application's
427
+ configuration:
428
+
429
+ ```ruby
430
+ env = Rack::MockRequest.env_for('/admin', 'REMOTE_ADDR' => '10.0.0.5',
431
+ 'HTTP_X_FORWARDED_FOR' => '203.0.113.9')
432
+ Otto::Testing.resolve_client_ip!(env, otto.security_config)
433
+ ```
434
+
435
+ Resolved under a different configuration, the proxy can become the client and
436
+ the application keeps that answer. `resolve_client_ip!` raises on an env that
437
+ already carries `otto.client_ip` or `otto.ip_match` for the same reason.
438
+
346
439
  See the [privacy guide](privacy.md) and the maintained privacy specs:
347
440
 
348
441
  - [`spec/otto/ip_privacy_spec.rb`](../../spec/otto/ip_privacy_spec.rb)
@@ -369,13 +462,33 @@ Otto's default route responses include:
369
462
  - `x-xss-protection: 1; mode=block`
370
463
  - `referrer-policy: strict-origin-when-cross-origin`
371
464
 
465
+ Configure exactly one W3C policy token when constructing the application; Otto
466
+ applies it to routed responses, static files, and authentication failures:
467
+
468
+ ```ruby
469
+ otto = Otto.new('routes.txt', referrer_policy: 'no-referrer')
470
+ ```
471
+
472
+ The same setting is available as
473
+ `otto.security_config.referrer_policy = 'no-referrer'` and
474
+ `otto.security.referrer_policy = 'no-referrer'` during boot, before the first
475
+ request freezes configuration. An unknown policy token raises `ArgumentError`
476
+ at configuration time. Comma-separated policy fallback lists are not accepted.
477
+ Existing applications that pass `referrer-policy`
478
+ through `security_headers` remain supported and receive the same validation.
479
+ A route handler that explicitly sets `res['referrer-policy']` keeps its
480
+ response-specific value.
481
+
372
482
  `x-frame-options` is not a default header. Call
373
483
  `otto.enable_frame_protection!` before the first request if a test should expect
374
484
  `x-frame-options: SAMEORIGIN`.
375
485
 
376
486
  Test security behavior at the narrowest useful level, but do not require every
377
487
  unrelated unit test to repeat header and privacy assertions. Keep those checks in
378
- focused middleware or request specs.
488
+ focused middleware or request specs. When testing a custom referrer policy,
489
+ exercise a complete response from each response family the application uses
490
+ (for example, routed HTML, `Rack::Files`, and an authentication failure), rather
491
+ than asserting only against `security_config.security_headers`.
379
492
 
380
493
  ## Maintained examples by task
381
494
 
@@ -120,16 +120,16 @@ class Otto
120
120
  Otto::Utils.relayed_request?(env)
121
121
  end
122
122
 
123
- # Whether this request is for the protected endpoint. Normalizes
124
- # +PATH_INFO+ through the same +Otto::Utils.normalize_path+ the router
125
- # uses for literal matching, so a percent-encoded, invalid-byte, or
126
- # trailing-slash variant the router would still route cannot slip past the
127
- # guard by normalizing differently here than at dispatch.
123
+ # Whether this request is for the protected endpoint. Compares the path
124
+ # the router itself dispatches on (+Otto::Utils.routing_path+), so a
125
+ # percent-encoded, invalid-byte, or trailing-slash variant the router
126
+ # would still route cannot slip past the guard by normalizing differently
127
+ # here than at dispatch.
128
128
  #
129
129
  # @param env [Hash] Rack environment
130
130
  # @return [Boolean]
131
131
  def targets_endpoint?(env)
132
- normalize_path(env['PATH_INFO']) == @endpoint
132
+ Otto::Utils.routing_path(env) == @endpoint
133
133
  end
134
134
 
135
135
  # Router-equivalent path normalization. Delegates to the single shared
@@ -85,10 +85,12 @@ class Otto
85
85
  @security_config.trusted_proxy_header = opts[:trusted_proxy_header]
86
86
  end
87
87
 
88
- # Set custom security headers
89
- return unless opts[:security_headers]
88
+ # Keep the generic security_headers option compatible while routing a
89
+ # Referrer-Policy entry through the same validated collection as the
90
+ # dedicated setting. When both are present, the dedicated option wins.
91
+ set_security_headers(opts[:security_headers]) if opts[:security_headers]
90
92
 
91
- set_security_headers(opts[:security_headers])
93
+ @security_config.referrer_policy = opts[:referrer_policy] if opts.key?(:referrer_policy)
92
94
  end
93
95
 
94
96
  def configure_authentication(opts)
@@ -308,11 +310,14 @@ class Otto
308
310
  deep_freeze_value(@routes_literal) if @routes_literal
309
311
  deep_freeze_value(@route_definitions) if @route_definitions
310
312
  deep_freeze_value(@routes_by_definition) if @routes_by_definition
313
+ # Explicit static mounts are already immutable snapshots; freezing the
314
+ # array here records that fact and makes any in-place mutation raise.
315
+ deep_freeze_value(@static_mounts) if @static_mounts
311
316
 
312
317
  @configuration_frozen = true
313
318
 
314
319
  duration = Otto::Utils.now_in_μs - start_time
315
- frozen_objects = %w[security_config locale_config middleware auth_config option routes]
320
+ frozen_objects = %w[security_config locale_config middleware auth_config option routes static_mounts]
316
321
  Otto.structured_log(:info, 'Freezing completed',
317
322
  {
318
323
  duration: duration,
@@ -71,8 +71,8 @@ class Otto
71
71
  # Content negotiation for built-in error response
72
72
  return json_error_response(error_id) if wants_json_response?(env)
73
73
 
74
- # Fallback to built-in error response
75
- @server_error || secure_error_response(error_id)
74
+ # Fallback to the configured server_error response, else the built-in one
75
+ server_error_response(env, error, error_id)
76
76
  end
77
77
 
78
78
  # Register an error handler for expected business logic errors
@@ -268,6 +268,44 @@ class Otto
268
268
  end
269
269
  end
270
270
 
271
+ # Build the fallback 500 response for an unhandled error.
272
+ #
273
+ # A configured +server_error+ callable is invoked per request with
274
+ # +env+ and the original +error+, trimmed to the positional parameters
275
+ # it declares; +env+ carries +otto.error_id+ so the response can
276
+ # reference the logged error. A configured static triple is copied per
277
+ # request (see {Otto::Static.copy_response}) so header writes by cookie
278
+ # middleware cannot accumulate on the shared object. A callable that
279
+ # raises is logged and replaced by the built-in secure response,
280
+ # mirroring how a failing custom +/500+ route is handled.
281
+ #
282
+ # @param env [Hash] Rack environment
283
+ # @param error [Exception] the unhandled error
284
+ # @param error_id [String] correlation id already logged for +error+
285
+ # @return [Array] a fresh Rack triple
286
+ def server_error_response(env, error, error_id)
287
+ fallback = @server_error
288
+ return secure_error_response(error_id) if fallback.nil?
289
+
290
+ env['otto.error_id'] = error_id
291
+ resolve_fallback_response(:server_error, fallback, env, error)
292
+ rescue StandardError => e
293
+ fallback_error_id = SecureRandom.hex(8)
294
+ base_context = Otto::LoggingHelpers.request_context(env)
295
+
296
+ Otto.structured_log(:error, 'Error in server_error fallback',
297
+ base_context.merge(
298
+ error: e.message,
299
+ error_class: e.class.name,
300
+ error_id: fallback_error_id,
301
+ original_error_id: error_id
302
+ ))
303
+ Otto::LoggingHelpers.log_backtrace(e,
304
+ base_context.merge(error_id: fallback_error_id, original_error_id: error_id))
305
+
306
+ secure_error_response(error_id)
307
+ end
308
+
271
309
  def secure_error_response(error_id)
272
310
  body = if Otto.env?(:dev, :development)
273
311
  "Server error (ID: #{error_id}). Check logs for details."
@@ -36,16 +36,32 @@ class Otto
36
36
  # callers never have to re-run realpath (one resolution per request).
37
37
  StaticFile = Struct.new(:root, :path, :relative)
38
38
 
39
- # Resolve a request path to a canonical, contained, servable file.
39
+ # Resolve a request path to a canonical, contained, servable file under
40
+ # the implicit +public:+ directory.
40
41
  #
41
42
  # @param path [String, nil] request-relative path (may start with '/')
42
43
  # @return [StaticFile, nil] the validated file, or nil when unsafe
43
44
  def resolve_static_file(path)
44
45
  return nil if option[:public].nil? || option[:public].empty?
45
- return nil if path.nil? || path.empty?
46
46
 
47
- public_dir = canonical_public_dir
48
- return nil if public_dir.nil?
47
+ resolve_file_under(canonical_public_dir, path)
48
+ end
49
+
50
+ # Resolve +path+ against an already-canonical +root+ and return it only
51
+ # when it is a contained, readable, owned regular file.
52
+ #
53
+ # Shared by the implicit public directory and explicit static mounts
54
+ # (Otto::Core::StaticMounts) so both apply one containment policy.
55
+ # +root+ must be a File.realpath result: containment compares canonical
56
+ # strings on a separator boundary, so a non-canonical root would never
57
+ # match the canonicalized candidate.
58
+ #
59
+ # @param root [String, nil] canonical directory
60
+ # @param path [String, nil] root-relative path (may start with '/')
61
+ # @return [StaticFile, nil] the validated file, or nil when unsafe
62
+ def resolve_file_under(root, path)
63
+ return nil if root.nil? || root.empty?
64
+ return nil if path.nil? || path.empty?
49
65
 
50
66
  # A NUL byte in a request path is never legitimate; it is a truncation
51
67
  # attack on downstream C string handling. Reject it rather than
@@ -57,18 +73,18 @@ class Otto
57
73
 
58
74
  # Join, then canonicalize: realpath resolves '..', '.' AND every
59
75
  # symlink component, so the containment check below cannot be fooled
60
- # by a link that points outside the public directory.
61
- candidate = File.join(public_dir, clean_path)
76
+ # by a link that points outside the root.
77
+ candidate = File.join(root, clean_path)
62
78
  real_path = safe_realpath(candidate)
63
79
  return nil if real_path.nil?
64
80
 
65
- return nil unless contained?(real_path, public_dir)
81
+ return nil unless contained?(real_path, root)
66
82
 
67
83
  # Second gate: it must be a readable regular file we (or our group) own.
68
84
  return nil unless File.file?(real_path) && File.readable?(real_path)
69
85
  return nil unless File.owned?(real_path) || File.grpowned?(real_path)
70
86
 
71
- StaticFile.new(public_dir, real_path, real_path.delete_prefix(public_dir + File::SEPARATOR))
87
+ StaticFile.new(root, real_path, real_path.delete_prefix(root + File::SEPARATOR))
72
88
  end
73
89
 
74
90
  def safe_file?(path)
@@ -105,22 +105,16 @@ class Otto
105
105
  # symlink-free root (issue #257). A missing root leaves @static_route
106
106
  # nil and the request falls through to normal routing (404), rather
107
107
  # than raising at construction.
108
- @static_route ||= build_static_route
109
- path_info = Rack::Utils.unescape(env['PATH_INFO'])
110
- path_info = '/' if path_info.to_s.empty?
111
-
112
- begin
113
- # Shared with Otto::CaddyTLS::LocalhostGuard so the guard and the
114
- # router cannot normalize a path differently (which would be a guard
115
- # bypass). See Otto::Utils.normalize_path.
116
- path_info_clean = Otto::Utils.normalize_path(env['PATH_INFO'])
117
- rescue ArgumentError => e
118
- # Log the error but don't expose details
119
- Otto.logger.error '[Otto.handle_request] Path encoding error'
120
- Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
121
- # Set a default value or use the original path_info
122
- path_info_clean = path_info
123
- end
108
+ @static_route ||= build_static_route
109
+
110
+ # The one path every dispatch stage matches. It comes from the public
111
+ # Otto::Utils.routing_path so that guards running before the router
112
+ # (Otto::CaddyTLS::LocalhostGuard, an application's own middleware)
113
+ # judge exactly this value rather than re-deriving it; a guard that
114
+ # normalized differently would be a bypass. It never raises: a
115
+ # malformed escape (%zz) is kept as written and routed like any other
116
+ # path.
117
+ path_info_clean = Otto::Utils.routing_path(env)
124
118
 
125
119
  http_verb = env['REQUEST_METHOD'].upcase.to_sym
126
120
  literal_routes = routes_literal[http_verb] || {}
@@ -142,9 +136,12 @@ class Otto
142
136
 
143
137
  static_candidate = !static_route.nil? && http_verb == :GET
144
138
 
145
- # Dispatch precedence is fixed: literal routes, then static files, then
146
- # dynamic routes. Static-file requests always pass through containment
147
- # validation before they are served (issues #257 and #260).
139
+ # Dispatch precedence is fixed: literal routes, then explicit static
140
+ # mounts (longest prefix first), then the implicit public directory,
141
+ # then dynamic routes. Every static-file request passes through
142
+ # containment validation before it is served (issues #257, #260 and
143
+ # #267). A mount or the public directory claims files, not paths: when
144
+ # the file is absent the request falls through to the next stage.
148
145
  if literal_routes.has_key?(path_info_clean)
149
146
  route = literal_routes[path_info_clean]
150
147
  Otto.structured_log(:debug, 'Route matched',
@@ -158,6 +155,13 @@ class Otto
158
155
  @route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
159
156
  end
160
157
  route.call(env)
158
+ elsif http_verb == :GET && (mounted = resolve_mounted_file(dispatch_path))
159
+ mount, static_file = mounted
160
+ Otto.structured_log(:debug, 'Route matched',
161
+ Otto::LoggingHelpers.request_context(env).merge(
162
+ type: 'static_mount', prefix: mount.display_prefix
163
+ ))
164
+ serve_static_file(env, static_file, mount.files)
161
165
  elsif static_candidate && (static_file = resolve_static_file(dispatch_path))
162
166
  Otto.structured_log(:debug, 'Route matched',
163
167
  Otto::LoggingHelpers.request_context(env).merge(type: 'static'))
@@ -201,12 +205,18 @@ class Otto
201
205
  # Rack::Files could still redirect the open. Closing that requires an
202
206
  # O_NOFOLLOW-per-component or fd-based serve, i.e. replacing
203
207
  # Rack::Files. Accepted for now; see issue #257.
204
- def serve_static_file(env, static_file)
208
+ #
209
+ # +files+ is the Rack::Files instance rooted at +static_file.root+: a
210
+ # mount's own frozen instance, or (by default) the public-directory one
211
+ # that #static_route_for keeps in step with the current root.
212
+ def serve_static_file(env, static_file, files = static_route_for(static_file.root))
205
213
  static_env = env.dup
206
214
  # Rack::Files unescapes PATH_INFO, so escape the canonical path to
207
215
  # survive the round trip (escape_path preserves '/').
208
216
  static_env['PATH_INFO'] = "/#{Rack::Utils.escape_path(static_file.relative)}"
209
- static_route_for(static_file.root).call(static_env)
217
+ status, headers, body = files.call(static_env)
218
+ headers['referrer-policy'] ||= @security_config.referrer_policy
219
+ [status, headers, body]
210
220
  end
211
221
 
212
222
  # Rack::Files rooted at the root +static_file+ was validated against.
@@ -235,9 +245,9 @@ class Otto
235
245
  end
236
246
 
237
247
  # +dispatch_path+ is the normalized path from #handle_request (see the
238
- # +dispatch_path+ comment there): +Otto::Utils.normalize_path+ output with
248
+ # +dispatch_path+ comment there): +Otto::Utils.routing_path+ output with
239
249
  # root's empty string mapped back to '/' so the anchored route regexes can
240
- # match. It is deliberately NOT the raw +normalize_path+ value.
250
+ # match. It is deliberately NOT the raw +routing_path+ value.
241
251
  def match_dynamic_route(env, dispatch_path, http_verb, literal_routes)
242
252
  extra_params = {}
243
253
  found_route = nil
@@ -287,10 +297,29 @@ class Otto
287
297
  Otto::LoggingHelpers.request_context(env).merge(
288
298
  fallback_to: 'default_not_found'
289
299
  ))
290
- @not_found || Otto::Static.not_found
300
+ not_found_response(env)
291
301
  end
292
302
  end
293
303
 
304
+ # Build the response for a request that matched no route and has no
305
+ # +/404+ route configured.
306
+ #
307
+ # A configured +not_found+ callable is invoked with +env+ on every miss.
308
+ # A configured static triple is copied per request (see
309
+ # {Otto::Static.copy_response}) so header writes by cookie middleware
310
+ # cannot accumulate on, or leak between requests through, the shared
311
+ # object. With nothing configured the built-in {Otto::Static.not_found}
312
+ # response is used.
313
+ #
314
+ # @param env [Hash] Rack environment
315
+ # @return [Array] a fresh Rack triple
316
+ def not_found_response(env)
317
+ fallback = @not_found
318
+ return Otto::Static.not_found(@security_config) if fallback.nil?
319
+
320
+ resolve_fallback_response(:not_found, fallback, env)
321
+ end
322
+
294
323
  def build_route_params(route, values)
295
324
  if route.keys.any?
296
325
  route.keys.zip(values).each_with_object({}) do |(k, v), hash|
@@ -0,0 +1,172 @@
1
+ # lib/otto/core/static_mounts.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'rack/files'
6
+
7
+ class Otto
8
+ module Core
9
+ # Explicit static-file registration (issue #267).
10
+ #
11
+ # A static mount binds a URL prefix to one directory on disk:
12
+ #
13
+ # otto.mount_static('/assets', root: 'public/assets')
14
+ #
15
+ # Requests for GET /assets/<rest> are then resolved against
16
+ # public/assets/<rest> using the same containment policy as the implicit
17
+ # +public:+ directory (Otto::Core::FileSafety): the candidate is
18
+ # canonicalized with File.realpath and must land inside the mount's
19
+ # canonical root, be a regular readable file, and be owned by the process
20
+ # user or group. A mount never authorizes anything outside its own root,
21
+ # so several mounts can point into unrelated directories without exposing
22
+ # their parents or siblings.
23
+ #
24
+ # Registration is a boot-time operation. The root is canonicalized once,
25
+ # when the mount is registered, and every failure mode (missing,
26
+ # unreadable, not a directory, escaping symlink, malformed prefix,
27
+ # duplicate prefix) raises ArgumentError immediately so a misconfigured
28
+ # application does not start. Mounts participate in configuration
29
+ # freezing: mount_static raises FrozenError after freeze_configuration!,
30
+ # and the mount table is an immutable, sorted snapshot that dispatch reads
31
+ # without any per-request mutation.
32
+ #
33
+ # Dispatch precedence is fixed: literal routes, then static mounts (longest
34
+ # prefix first), then the implicit +public:+ directory, then dynamic
35
+ # routes. A mount claims files, not the prefix: when no mount root
36
+ # contains the requested file the request falls through to the next
37
+ # stage exactly as an unregistered path would. See Otto::Core::Router.
38
+ module StaticMounts
39
+ # One registered mount. +prefix+ is the normalized URL prefix ('' for a
40
+ # root mount), +root+ the canonical directory, +files+ the Rack::Files
41
+ # instance rooted there. Instances are frozen at construction.
42
+ StaticMount = Struct.new(:prefix, :root, :files) do
43
+ # Request-relative path under this mount, or nil when +path+ is not
44
+ # beneath the prefix. The prefix itself (the directory) never matches:
45
+ # a mount serves files, not directory listings.
46
+ #
47
+ # @param path [String] normalized dispatch path (leading '/')
48
+ # @return [String, nil]
49
+ def relative_path_for(path)
50
+ if prefix.empty?
51
+ path
52
+ elsif path.start_with?("#{prefix}/")
53
+ path[(prefix.length + 1)..]
54
+ end
55
+ end
56
+
57
+ # Prefix as an operator would write it ('/' for a root mount).
58
+ def display_prefix
59
+ prefix.empty? ? '/' : prefix
60
+ end
61
+ end
62
+
63
+ # Registered mounts, longest prefix first. Frozen snapshot; a new array
64
+ # replaces it on every registration so readers never observe a partial
65
+ # update.
66
+ #
67
+ # @return [Array<StaticMount>]
68
+ def static_mounts
69
+ @static_mounts
70
+ end
71
+
72
+ # Serve the files under +root+ at URLs beneath +prefix+.
73
+ #
74
+ # @param prefix [String] URL prefix starting with '/'. A trailing slash
75
+ # is ignored; '/' mounts the root at the top level.
76
+ # @param root [String] directory path; relative paths resolve against
77
+ # the process working directory and are canonicalized immediately.
78
+ # @return [StaticMount] the registered mount
79
+ # @raise [ArgumentError] on a malformed prefix, a duplicate prefix, or a
80
+ # root that is missing, unreadable, not a directory, not owned by the
81
+ # process user or group, or that cannot be canonicalized.
82
+ # @raise [FrozenError] after configuration freezing
83
+ def mount_static(prefix, root:)
84
+ ensure_not_frozen!
85
+
86
+ clean_prefix = normalize_mount_prefix(prefix)
87
+ if @static_mounts.any? { |mount| mount.prefix == clean_prefix }
88
+ raise ArgumentError,
89
+ "Static mount prefix #{display_mount_prefix(clean_prefix).inspect} is already registered"
90
+ end
91
+
92
+ canonical_root = canonicalize_mount_root(clean_prefix, root)
93
+ mount = StaticMount.new(clean_prefix, canonical_root, Rack::Files.new(canonical_root).freeze).freeze
94
+
95
+ # Longest prefix first so an overlay ('/assets/vendor') is consulted
96
+ # before the mount that contains it ('/assets'). Ties cannot happen:
97
+ # prefixes are unique. Rebuild rather than mutate so in-flight readers
98
+ # keep their snapshot.
99
+ @static_mounts = (@static_mounts + [mount]).sort_by { |m| -m.prefix.length }.freeze
100
+
101
+ Otto.structured_log(:debug, 'Static mount registered',
102
+ { prefix: mount.display_prefix, root: mount.root })
103
+ mount
104
+ end
105
+
106
+ private
107
+
108
+ # Resolve +path+ through the registered mounts, longest prefix first.
109
+ # Read-only: safe to call from concurrent request threads.
110
+ #
111
+ # @param path [String] normalized dispatch path (leading '/')
112
+ # @return [Array(StaticMount, Otto::Core::FileSafety::StaticFile), nil]
113
+ def resolve_mounted_file(path)
114
+ @static_mounts.each do |mount|
115
+ relative = mount.relative_path_for(path)
116
+ next if relative.nil?
117
+
118
+ static_file = resolve_file_under(mount.root, relative)
119
+ return [mount, static_file] if static_file
120
+ end
121
+ nil
122
+ end
123
+
124
+ # Validate and normalize a mount prefix. Returns '' for the root mount
125
+ # and a leading-slash, no-trailing-slash prefix otherwise, matching the
126
+ # normalized request path the router compares against.
127
+ def normalize_mount_prefix(prefix)
128
+ raise ArgumentError, "Static mount prefix must be a String, got #{prefix.class}" unless prefix.is_a?(String)
129
+ raise ArgumentError, "Static mount prefix #{prefix.inspect} contains a NUL byte" if prefix.include?("\0")
130
+ raise ArgumentError, "Static mount prefix #{prefix.inspect} must start with '/'" unless prefix.start_with?('/')
131
+
132
+ clean = prefix.sub(%r{/+\z}, '')
133
+ return '' if clean.empty?
134
+
135
+ segments = clean.split('/', -1).drop(1)
136
+ if segments.any? { |segment| segment.empty? || segment == '.' || segment == '..' }
137
+ raise ArgumentError,
138
+ "Static mount prefix #{prefix.inspect} must not contain empty, '.', or '..' segments"
139
+ end
140
+
141
+ clean.freeze
142
+ end
143
+
144
+ # Canonicalize a mount root under Otto's static-file safety policy and
145
+ # fail loudly on anything that could not be served safely.
146
+ def canonicalize_mount_root(prefix, root)
147
+ label = "Static mount #{display_mount_prefix(prefix).inspect}"
148
+ raise ArgumentError, "#{label} root must be a String, got #{root.class}" unless root.is_a?(String)
149
+ raise ArgumentError, "#{label} root must not be empty" if root.strip.empty?
150
+ raise ArgumentError, "#{label} root #{root.inspect} contains a NUL byte" if root.include?("\0")
151
+
152
+ real = safe_realpath(File.expand_path(root))
153
+ if real.nil?
154
+ raise ArgumentError,
155
+ "#{label} root #{root.inspect} cannot be resolved " \
156
+ '(missing, unreadable component, or symlink loop)'
157
+ end
158
+ raise ArgumentError, "#{label} root #{root.inspect} is not a directory" unless File.directory?(real)
159
+ raise ArgumentError, "#{label} root #{root.inspect} is not readable" unless File.readable?(real)
160
+
161
+ owned = File.owned?(real) || File.grpowned?(real)
162
+ raise ArgumentError, "#{label} root #{root.inspect} is not owned by the process user or group" unless owned
163
+
164
+ real.freeze
165
+ end
166
+
167
+ def display_mount_prefix(prefix)
168
+ prefix.empty? ? '/' : prefix
169
+ end
170
+ end
171
+ end
172
+ end
data/lib/otto/core.rb CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  require_relative 'core/router'
6
6
  require_relative 'core/file_safety'
7
+ require_relative 'core/static_mounts'
7
8
  require_relative 'core/configuration'
8
9
  require_relative 'core/error_handler'
9
10
  require_relative 'core/uri_generator'
data/lib/otto/env_keys.rb CHANGED
@@ -217,7 +217,8 @@ class Otto
217
217
  # Note: setting CLIENT_IP yourself is out of contract — it trips the
218
218
  # middleware's idempotency guard, so the unmasked address is never
219
219
  # captured and this capability degrades to a logged fail-closed
220
- # check that denies every range.
220
+ # check that denies every range. Test harnesses build both keys
221
+ # with Otto::Testing.env_for (require 'otto/testing').
221
222
  IP_MATCH = 'otto.ip_match'
222
223
 
223
224
  # Privacy-safe masked IP address