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.
@@ -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 csrf_protection [Boolean, Hash] Enable CSRF protection
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
- # @param request_validation [Boolean] Enable input validation and sanitization
35
- # @param rate_limiting [Boolean, Hash] Enable rate limiting
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
- # @param trusted_proxies [String, Array<String>, Symbol] IP addresses or
39
- # CIDR ranges to trust, or :none to assert that no proxy is trusted
40
- # @param trusted_proxy_depth [Integer, nil] Count-based proxy depth ("trust
41
- # the last N hops") for non-enumerable proxy tiers; mutually exclusive
42
- # with trusted_proxies (validated at configuration freeze)
43
- # @param trusted_proxy_header [String, nil] Forwarded header depth mode
44
- # counts hops from: 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239),
45
- # or 'Both'. Otto reads it only in depth mode, but setting it always
46
- # pins Rack::Request.forwarded_priority (process-global) to that family
47
- # and claims the family for this process; see
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
- # @param security_headers [Hash] Custom security headers to merge with defaults
50
- # @param hsts [Boolean] Enable HTTP Strict Transport Security
51
- # @param csp [Boolean, String] Enable Content Security Policy
52
- # @param frame_protection [Boolean, String] Enable frame protection
53
- # @param authentication [Boolean] Enable authentication
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
- csrf_protection: false,
68
- request_validation: false,
69
- rate_limiting: false,
70
- trusted_proxies: [],
71
- trusted_proxy_depth: nil,
72
- trusted_proxy_header: nil,
73
- security_headers: {},
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 (matched against PATH_INFO)
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 (matched against PATH_INFO), or nil to disable
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
@@ -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 (matched against
225
- # `PATH_INFO`, e.g. `/_/csp-report`).
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'] == report_uri
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('HTTP_X_FORWARDED_FOR')
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. PATH_INFO, not Rack::Request#path: #path
35
- # prepends SCRIPT_NAME, so with Otto mounted under `map '/api'` the
36
- # internal /_mcp request read as /api/_mcp and was counted here while
37
- # the MCP throttle, comparing the same way, never saw it at all.
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.path_info.start_with?('/_')
42
+ request.ip unless Otto::Utils.routing_path(request.env).start_with?('/_')
40
43
  end
41
44
 
42
45
  # Apply custom rules if provided