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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.rubocop_todo.yml +3 -2
- data/AGENTS.md +18 -0
- data/CHANGELOG.rst +132 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/configuration_freezing.md +17 -6
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +180 -5
- data/docs/guides/testing-guide.md +115 -2
- data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
- data/lib/otto/core/configuration.rb +9 -4
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +24 -8
- data/lib/otto/core/router.rb +53 -24
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- data/lib/otto/env_keys.rb +2 -1
- data/lib/otto/privacy/config.rb +19 -13
- data/lib/otto/response.rb +5 -2
- data/lib/otto/route.rb +1 -1
- data/lib/otto/route_handlers/base.rb +1 -1
- data/lib/otto/route_handlers/logic_class.rb +86 -34
- data/lib/otto/security/config.rb +349 -303
- data/lib/otto/security/configurator.rb +82 -45
- data/lib/otto/security/core.rb +4 -2
- data/lib/otto/security/csp/report_middleware.rb +17 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
- data/lib/otto/security/rate_limiter.rb +8 -5
- data/lib/otto/security/trusted_proxy_config.rb +396 -0
- data/lib/otto/static.rb +45 -6
- data/lib/otto/testing.rb +148 -0
- data/lib/otto/utils.rb +63 -14
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +126 -2
- 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.
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
61
|
-
candidate = File.join(
|
|
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,
|
|
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(
|
|
87
|
+
StaticFile.new(root, real_path, real_path.delete_prefix(root + File::SEPARATOR))
|
|
72
88
|
end
|
|
73
89
|
|
|
74
90
|
def safe_file?(path)
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
146
|
-
#
|
|
147
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 +
|
|
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
|
-
|
|
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
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
|