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.
- 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 +2 -2
- data/CHANGELOG.rst +100 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +68 -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 +5 -3
- data/lib/otto/core/router.rb +16 -20
- 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 +7 -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 +7 -2
- metadata +4 -2
|
@@ -23,34 +23,60 @@ class Otto
|
|
|
23
23
|
@auth_config = auth_config || { auth_strategies: {}, default_auth_strategy: 'noauth' }
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Options accepted by #configure, with their defaults. A new security
|
|
27
|
+
# knob is a new entry here, not another keyword parameter.
|
|
28
|
+
CONFIGURE_DEFAULTS = {
|
|
29
|
+
csrf_protection: false,
|
|
30
|
+
request_validation: false,
|
|
31
|
+
rate_limiting: false,
|
|
32
|
+
trusted_proxies: [].freeze,
|
|
33
|
+
trusted_proxy_depth: nil,
|
|
34
|
+
trusted_proxy_header: nil,
|
|
35
|
+
security_headers: {}.freeze,
|
|
36
|
+
hsts: false,
|
|
37
|
+
csp: false,
|
|
38
|
+
frame_protection: false,
|
|
39
|
+
authentication: false,
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# The options #configure resolved: CONFIGURE_DEFAULTS overlaid with the
|
|
43
|
+
# caller's, read through accessors so a misspelled read in #configure
|
|
44
|
+
# raises instead of returning nil.
|
|
45
|
+
ConfigureOptions = Data.define(*CONFIGURE_DEFAULTS.keys)
|
|
46
|
+
|
|
26
47
|
# Unified security configuration method with sensible defaults
|
|
27
48
|
#
|
|
28
49
|
# Provides a comprehensive, one-stop configuration method for Otto's security features.
|
|
29
50
|
# This method allows configuring multiple security aspects in a single call, with flexible options.
|
|
51
|
+
# Every option is optional; see CONFIGURE_DEFAULTS.
|
|
30
52
|
#
|
|
31
|
-
# @param
|
|
53
|
+
# @param options [Hash] security options
|
|
54
|
+
# @option options [Boolean, Hash] :csrf_protection Enable CSRF protection
|
|
32
55
|
# - `true`: Enable with default settings
|
|
33
56
|
# - `Hash`: Provide custom CSRF configuration
|
|
34
|
-
# @
|
|
35
|
-
# @
|
|
57
|
+
# @option options [Boolean] :request_validation Enable input validation and sanitization
|
|
58
|
+
# @option options [Boolean, Hash] :rate_limiting Enable rate limiting
|
|
36
59
|
# - `true`: Enable with default settings
|
|
37
60
|
# - `Hash`: Provide custom rate limiting rules
|
|
38
|
-
# @
|
|
39
|
-
# CIDR ranges to trust, or :none to assert that no proxy
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
61
|
+
# @option options [String, Array<String>, Symbol] :trusted_proxies IP
|
|
62
|
+
# addresses or CIDR ranges to trust, or :none to assert that no proxy
|
|
63
|
+
# is trusted
|
|
64
|
+
# @option options [Integer, nil] :trusted_proxy_depth Count-based proxy
|
|
65
|
+
# depth ("trust the last N hops") for non-enumerable proxy tiers;
|
|
66
|
+
# mutually exclusive with trusted_proxies
|
|
67
|
+
# @option options [String, nil] :trusted_proxy_header Forwarded header
|
|
68
|
+
# depth mode counts hops from: 'X-Forwarded-For' (default), 'Forwarded'
|
|
69
|
+
# (RFC 7239), or 'Both'. Otto reads it only in depth mode, but setting
|
|
70
|
+
# it always pins Rack::Request.forwarded_priority (process-global) to
|
|
71
|
+
# that family and claims the family for this process; see
|
|
48
72
|
# Otto::Security::Config.apply_rack_forwarding_family!.
|
|
49
|
-
# @
|
|
50
|
-
# @
|
|
51
|
-
# @
|
|
52
|
-
# @
|
|
53
|
-
# @
|
|
73
|
+
# @option options [Hash] :security_headers Custom security headers to merge with defaults
|
|
74
|
+
# @option options [Boolean] :hsts Enable HTTP Strict Transport Security
|
|
75
|
+
# @option options [Boolean, String] :csp Enable Content Security Policy
|
|
76
|
+
# @option options [Boolean, String] :frame_protection Enable frame protection
|
|
77
|
+
# @option options [Boolean] :authentication Accepted for compatibility;
|
|
78
|
+
# authentication is configured through strategies (#add_auth_strategy)
|
|
79
|
+
# @raise [ArgumentError] for an unrecognized option
|
|
54
80
|
#
|
|
55
81
|
# @example Configure multiple security features in one call
|
|
56
82
|
# otto.security.configure(
|
|
@@ -63,41 +89,31 @@ class Otto
|
|
|
63
89
|
# csp: "default-src 'self'",
|
|
64
90
|
# frame_protection: 'SAMEORIGIN'
|
|
65
91
|
# )
|
|
66
|
-
def configure(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
hsts: false,
|
|
75
|
-
csp: false,
|
|
76
|
-
frame_protection: false,
|
|
77
|
-
authentication: false
|
|
78
|
-
)
|
|
79
|
-
enable_csrf_protection! if csrf_protection
|
|
80
|
-
enable_request_validation! if request_validation
|
|
81
|
-
enable_rate_limiting!(rate_limiting.is_a?(Hash) ? rate_limiting : {}) if rate_limiting
|
|
82
|
-
|
|
83
|
-
if Otto::Security::Config.trust_no_proxies_option?(trusted_proxies)
|
|
92
|
+
def configure(**options)
|
|
93
|
+
opts = resolve_configure_options(options)
|
|
94
|
+
|
|
95
|
+
enable_csrf_protection! if opts.csrf_protection
|
|
96
|
+
enable_request_validation! if opts.request_validation
|
|
97
|
+
enable_rate_limiting!(opts.rate_limiting.is_a?(Hash) ? opts.rate_limiting : {}) if opts.rate_limiting
|
|
98
|
+
|
|
99
|
+
if Otto::Security::Config.trust_no_proxies_option?(opts.trusted_proxies)
|
|
84
100
|
trust_no_proxies!
|
|
85
101
|
else
|
|
86
102
|
# Pass the list whole so add_trusted_proxy validates every entry
|
|
87
103
|
# before registering any; a mixed list like ['10.0.0.0/8', 'none']
|
|
88
104
|
# must not leave the first half installed.
|
|
89
|
-
add_trusted_proxy(Array(trusted_proxies)) unless Array(trusted_proxies).empty?
|
|
105
|
+
add_trusted_proxy(Array(opts.trusted_proxies)) unless Array(opts.trusted_proxies).empty?
|
|
90
106
|
end
|
|
91
|
-
self.trusted_proxy_depth = trusted_proxy_depth unless trusted_proxy_depth.nil?
|
|
92
|
-
self.trusted_proxy_header = trusted_proxy_header unless trusted_proxy_header.nil?
|
|
107
|
+
self.trusted_proxy_depth = opts.trusted_proxy_depth unless opts.trusted_proxy_depth.nil?
|
|
108
|
+
self.trusted_proxy_header = opts.trusted_proxy_header unless opts.trusted_proxy_header.nil?
|
|
93
109
|
# Proxy trust configured here (after Otto.new) commits the app to its
|
|
94
110
|
# forwarding family now rather than at freeze.
|
|
95
111
|
@security_config.commit_rack_forwarding_family!
|
|
96
|
-
self.security_headers = security_headers unless security_headers.empty?
|
|
112
|
+
self.security_headers = opts.security_headers unless opts.security_headers.empty?
|
|
97
113
|
|
|
98
|
-
enable_hsts! if hsts
|
|
99
|
-
enable_csp! if csp
|
|
100
|
-
enable_frame_protection! if frame_protection
|
|
114
|
+
enable_hsts! if opts.hsts
|
|
115
|
+
enable_csp! if opts.csp
|
|
116
|
+
enable_frame_protection! if opts.frame_protection
|
|
101
117
|
end
|
|
102
118
|
|
|
103
119
|
# Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
|
|
@@ -195,6 +211,13 @@ class Otto
|
|
|
195
211
|
@security_config.security_headers.merge!(headers)
|
|
196
212
|
end
|
|
197
213
|
|
|
214
|
+
# Set the Referrer-Policy value added to Otto responses.
|
|
215
|
+
#
|
|
216
|
+
# @param policy [String] one W3C Referrer Policy HTTP token
|
|
217
|
+
def referrer_policy=(policy)
|
|
218
|
+
@security_config.referrer_policy = policy
|
|
219
|
+
end
|
|
220
|
+
|
|
198
221
|
# Enable HTTP Strict Transport Security (HSTS) header.
|
|
199
222
|
# WARNING: This can make your domain inaccessible if HTTPS is not properly
|
|
200
223
|
# configured. Only enable this when you're certain HTTPS is working correctly.
|
|
@@ -249,7 +272,8 @@ class Otto
|
|
|
249
272
|
# inject {Otto::Security::CSP::ReportMiddleware} pinned OUTERMOST so it
|
|
250
273
|
# intercepts report POSTs ahead of CSRF regardless of enable order.
|
|
251
274
|
#
|
|
252
|
-
# @param report_uri [String] path browsers POST reports to
|
|
275
|
+
# @param report_uri [String] site-absolute path browsers POST reports to,
|
|
276
|
+
# including any mount prefix (see {Otto::Security::Config#csp_report_uri=})
|
|
253
277
|
# @param endpoint_url [String, nil] absolute URL for the modern Reporting
|
|
254
278
|
# API endpoint (emits `report-to` + `Reporting-Endpoints`); nil emits
|
|
255
279
|
# only the legacy `report-uri`
|
|
@@ -267,7 +291,8 @@ class Otto
|
|
|
267
291
|
# Configure the CSP violation report path without injecting middleware.
|
|
268
292
|
# Prefer {#enable_csp_reporting!} for the full turnkey setup.
|
|
269
293
|
#
|
|
270
|
-
# @param uri [String, nil] report path
|
|
294
|
+
# @param uri [String, nil] site-absolute report path, including any mount
|
|
295
|
+
# prefix (see {Otto::Security::Config#csp_report_uri=}), or nil to disable
|
|
271
296
|
def csp_report_uri=(uri)
|
|
272
297
|
@security_config.csp_report_uri = uri
|
|
273
298
|
end
|
|
@@ -327,6 +352,18 @@ class Otto
|
|
|
327
352
|
|
|
328
353
|
private
|
|
329
354
|
|
|
355
|
+
# Overlay the caller's options on CONFIGURE_DEFAULTS. An unrecognized
|
|
356
|
+
# key raises the ArgumentError a keyword parameter list raises, checked
|
|
357
|
+
# here because Data.new would also accept a String spelling of a member.
|
|
358
|
+
def resolve_configure_options(options)
|
|
359
|
+
unknown = options.keys - CONFIGURE_DEFAULTS.keys
|
|
360
|
+
unless unknown.empty?
|
|
361
|
+
raise ArgumentError, "unknown keyword#{'s' if unknown.size > 1}: #{unknown.map(&:inspect).join(', ')}"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
ConfigureOptions.new(**CONFIGURE_DEFAULTS, **options)
|
|
365
|
+
end
|
|
366
|
+
|
|
330
367
|
def middleware_enabled?(middleware_class)
|
|
331
368
|
@middleware_stack.includes?(middleware_class)
|
|
332
369
|
end
|
data/lib/otto/security/core.rb
CHANGED
|
@@ -221,8 +221,10 @@ class Otto
|
|
|
221
221
|
# emits a `report-to` directive plus a `Reporting-Endpoints` header so those
|
|
222
222
|
# browsers deliver `application/reports+json` to the same receiver.
|
|
223
223
|
#
|
|
224
|
-
# @param report_uri [String] path browsers POST reports to
|
|
225
|
-
#
|
|
224
|
+
# @param report_uri [String] site-absolute path browsers POST reports to,
|
|
225
|
+
# including any mount prefix (e.g. `/_/csp-report`, or
|
|
226
|
+
# `/api/_/csp-report` when mounted at `/api`). See
|
|
227
|
+
# {Otto::Security::Config#csp_report_uri=}.
|
|
226
228
|
# @param endpoint_url [String, nil] absolute URL for the modern Reporting
|
|
227
229
|
# API endpoint (e.g. `https://example.com/_/csp-report`); nil emits only
|
|
228
230
|
# the legacy `report-uri`.
|
|
@@ -22,6 +22,8 @@ class Otto
|
|
|
22
22
|
# not configured the middleware is a transparent pass-through.
|
|
23
23
|
# - Only intercepts a POST whose path matches the configured report URI.
|
|
24
24
|
# Everything else (other paths, other methods) passes through untouched.
|
|
25
|
+
# The match is on the full request path (mount prefix included), since
|
|
26
|
+
# the report URI is the site-absolute path the browser POSTs to.
|
|
25
27
|
# - Short-circuits BEFORE inner middleware, so CSRF, auth, and rate
|
|
26
28
|
# limiting never see the request. This is why browsers can POST reports
|
|
27
29
|
# with no CSRF token: the report never reaches the CSRF middleware.
|
|
@@ -76,6 +78,19 @@ class Otto
|
|
|
76
78
|
# True only when reporting is configured AND this is a POST to the
|
|
77
79
|
# configured report path.
|
|
78
80
|
#
|
|
81
|
+
# The configured path is site-absolute: it is emitted verbatim as the
|
|
82
|
+
# `report-uri` directive, and the browser resolves it against the site
|
|
83
|
+
# root. So it is compared against the request's full path, SCRIPT_NAME
|
|
84
|
+
# plus PATH_INFO, not the mount-relative PATH_INFO the router matches.
|
|
85
|
+
# Under `map '/api' { run otto }` a report to `/api/csp-report` arrives
|
|
86
|
+
# as SCRIPT_NAME=/api, PATH_INFO=/csp-report and still matches a
|
|
87
|
+
# configured `/api/csp-report`. Unmounted, SCRIPT_NAME is empty and the
|
|
88
|
+
# two forms coincide.
|
|
89
|
+
#
|
|
90
|
+
# Both sides go through Otto::Utils.normalize_path, so a trailing slash
|
|
91
|
+
# or percent-encoded spelling of the report path is intercepted the way
|
|
92
|
+
# the router would normalize it.
|
|
93
|
+
#
|
|
79
94
|
# @param env [Hash]
|
|
80
95
|
# @return [Boolean]
|
|
81
96
|
def report_request?(env)
|
|
@@ -83,7 +98,8 @@ class Otto
|
|
|
83
98
|
return false if report_uri.nil? || report_uri.empty?
|
|
84
99
|
return false unless env['REQUEST_METHOD'] == 'POST'
|
|
85
100
|
|
|
86
|
-
env['PATH_INFO']
|
|
101
|
+
request_path = Otto::Utils.normalize_path("#{env['SCRIPT_NAME']}#{env['PATH_INFO']}")
|
|
102
|
+
request_path == Otto::Utils.normalize_path(report_uri)
|
|
87
103
|
end
|
|
88
104
|
|
|
89
105
|
# Read (capped), parse, and dispatch. Never raises; parse/dispatch
|
|
@@ -232,7 +232,8 @@ class Otto
|
|
|
232
232
|
'[IPPrivacyMiddleware] otto.client_ip was set outside this ' \
|
|
233
233
|
'middleware, so otto.ip_match could not be built from the ' \
|
|
234
234
|
'unmasked address; installing a fail-closed check (every CIDR ' \
|
|
235
|
-
'test returns false). Let IPPrivacyMiddleware resolve the client IP
|
|
235
|
+
'test returns false). Let IPPrivacyMiddleware resolve the client IP; ' \
|
|
236
|
+
'test harnesses can build the env with Otto::Testing.env_for.'
|
|
236
237
|
)
|
|
237
238
|
env['otto.ip_match'] = ->(_cidrs) { false }
|
|
238
239
|
end
|
|
@@ -499,10 +500,7 @@ class Otto
|
|
|
499
500
|
#
|
|
500
501
|
# @param env [Hash] Rack environment
|
|
501
502
|
def scrub_forwarded_headers(env)
|
|
502
|
-
env.delete(
|
|
503
|
-
env.delete('HTTP_X_REAL_IP')
|
|
504
|
-
env.delete('HTTP_X_CLIENT_IP')
|
|
505
|
-
env.delete('HTTP_FORWARDED')
|
|
503
|
+
Otto::Utils::CLIENT_ADDRESS_HEADERS.each { |key| env.delete(key) }
|
|
506
504
|
end
|
|
507
505
|
|
|
508
506
|
# Mask X-Forwarded-For and related proxy headers
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
require 'json'
|
|
6
6
|
|
|
7
7
|
require_relative '../optional_dependency'
|
|
8
|
+
require_relative '../utils'
|
|
8
9
|
|
|
9
10
|
class Otto
|
|
10
11
|
module Security
|
|
@@ -31,12 +32,14 @@ class Otto
|
|
|
31
32
|
default_requests_per_minute = config.fetch(:requests_per_minute, 100)
|
|
32
33
|
|
|
33
34
|
# General request throttling. Internal paths (/_mcp, /_status, ...)
|
|
34
|
-
# are skipped by default.
|
|
35
|
-
# prepends SCRIPT_NAME, so with Otto mounted
|
|
36
|
-
# internal /_mcp request read as /api/_mcp and
|
|
37
|
-
#
|
|
35
|
+
# are skipped by default. The mount-relative routing path, not
|
|
36
|
+
# Rack::Request#path: #path prepends SCRIPT_NAME, so with Otto mounted
|
|
37
|
+
# under `map '/api'` the internal /_mcp request read as /api/_mcp and
|
|
38
|
+
# was counted here while the MCP throttle never saw it at all. And the
|
|
39
|
+
# routing path, not raw PATH_INFO, which counted /%5Fmcp while the
|
|
40
|
+
# router dispatched it as /_mcp.
|
|
38
41
|
Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
|
|
39
|
-
request.ip unless request.
|
|
42
|
+
request.ip unless Otto::Utils.routing_path(request.env).start_with?('/_')
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
# Apply custom rules if provided
|