otto 2.5.0 → 2.6.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby-lint.yml +1 -1
  3. data/.github/workflows/yardoc.yml +1 -1
  4. data/CHANGELOG.rst +65 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/examples/advanced_routes/README.md +49 -0
  8. data/examples/advanced_routes/config.rb +15 -2
  9. data/examples/advanced_routes/routes +12 -0
  10. data/examples/lambda_handlers/README.md +128 -0
  11. data/examples/lambda_handlers/config.ru +26 -0
  12. data/examples/lambda_handlers/handlers.rb +75 -0
  13. data/examples/lambda_handlers/routes +28 -0
  14. data/lib/otto/core/configuration.rb +103 -1
  15. data/lib/otto/core/router.rb +67 -10
  16. data/lib/otto/core/uri_generator.rb +36 -2
  17. data/lib/otto/env_keys.rb +11 -0
  18. data/lib/otto/errors.rb +7 -0
  19. data/lib/otto/mcp/route_parser.rb +15 -4
  20. data/lib/otto/privacy/config.rb +37 -1
  21. data/lib/otto/privacy/core.rb +18 -1
  22. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  23. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  24. data/lib/otto/privacy.rb +1 -0
  25. data/lib/otto/request.rb +27 -0
  26. data/lib/otto/route.rb +103 -41
  27. data/lib/otto/route_definition.rb +56 -6
  28. data/lib/otto/route_handlers/base.rb +4 -0
  29. data/lib/otto/route_handlers/factory.rb +15 -0
  30. data/lib/otto/route_handlers/lambda.rb +47 -32
  31. data/lib/otto/security/config.rb +100 -5
  32. data/lib/otto/security/csp/policy.rb +135 -3
  33. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  34. data/lib/otto/security/csrf_validation.rb +75 -0
  35. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  36. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  37. data/lib/otto/security.rb +1 -0
  38. data/lib/otto/version.rb +1 -1
  39. data/lib/otto.rb +26 -2
  40. metadata +9 -2
@@ -43,9 +43,14 @@ class Otto
43
43
  # @param report_to_url [String, nil] absolute URL configured for the
44
44
  # modern Reporting API; its presence (not its value) toggles the
45
45
  # `report-to <group>` directive (omitted when nil/empty)
46
+ # @param directive_overrides [Hash, nil] per-directive overrides merged
47
+ # into the base set before reporting directives are appended. See
48
+ # {.merge_directives} for the accepted shape (replace a directive's
49
+ # sources, add a new directive, or remove one with a nil/false value).
46
50
  # @return [String] complete CSP policy string
47
- def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil)
51
+ def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil)
48
52
  directives = development_mode ? development_directives(nonce) : production_directives(nonce)
53
+ directives = merge_directives(directives, directive_overrides)
49
54
  uri_directive = report_uri_directive(report_uri)
50
55
  to_directive = report_to_directive(report_to_url)
51
56
  directives += ["#{uri_directive};"] if uri_directive
@@ -88,6 +93,133 @@ class Otto
88
93
  "report-to #{REPORTING_GROUP}"
89
94
  end
90
95
 
96
+ # Merge per-directive overrides into a base directive set.
97
+ #
98
+ # This is the customization seam the hardcoded directive sets previously
99
+ # lacked: a consuming app can adjust ANY directive (e.g. re-allow
100
+ # `data:` workers via `worker-src 'self' data: blob:`) without
101
+ # vendoring the gem. Order is
102
+ # preserved — an override that matches an existing directive replaces it
103
+ # in place; an override for a directive not in the base set is appended
104
+ # after the base directives (before any reporting directives).
105
+ #
106
+ # Override values:
107
+ # - a String → the directive's source list verbatim, e.g.
108
+ # `'worker-src' => "'self' blob:"` yields `worker-src 'self' blob:;`
109
+ # - an Array → sources joined with a single space, e.g.
110
+ # `%w['self' blob:]`
111
+ # - `nil`/`false` → REMOVE the directive from the emitted policy
112
+ #
113
+ # Directive names are matched case-insensitively (CSP directive names are
114
+ # case-insensitive) and may be given as Strings or Symbols.
115
+ #
116
+ # @note The per-request nonce is embedded in `script-src` (production)
117
+ # and cannot be reproduced in a static override, so replacing (or
118
+ # removing) `script-src` strips the nonce from the emitted header and
119
+ # DEFEATS nonce protection: the browser then accepts any inline script
120
+ # the page carries the nonce attribute on. Overriding `script-src`
121
+ # while nonce mode is enabled therefore disables nonce enforcement;
122
+ # {Otto::Security::Config} logs a warning when such an override is
123
+ # configured. Override other directives freely.
124
+ #
125
+ # @param directives [Array<String>] base directive strings, each `;`-terminated
126
+ # @param overrides [Hash, nil] directive name => source list / nil
127
+ # @return [Array<String>] merged directive strings, each `;`-terminated
128
+ # @raise [ArgumentError] if an override name or source token contains a
129
+ # `;`, newline, or carriage return (see {.build_directive})
130
+ def merge_directives(directives, overrides)
131
+ return directives if overrides.nil? || overrides.empty?
132
+
133
+ normalized = normalize_overrides(overrides)
134
+ consumed = {}
135
+
136
+ merged = directives.filter_map do |directive|
137
+ name = directive_name(directive)
138
+ next directive unless normalized.key?(name)
139
+
140
+ consumed[name] = true
141
+ build_directive(name, normalized[name])
142
+ end
143
+
144
+ normalized.each do |name, value|
145
+ next if consumed[name]
146
+
147
+ appended = build_directive(name, value)
148
+ merged << appended if appended
149
+ end
150
+
151
+ merged
152
+ end
153
+
154
+ # Normalize an overrides hash to lowercased, hyphenated String keys so
155
+ # lookups are case-insensitive and Symbol/String keys are
156
+ # interchangeable. Underscores map to hyphens (no CSP directive contains
157
+ # an underscore) so a Symbol key like `:worker_src` addresses the
158
+ # `worker-src` directive. Blank keys are dropped.
159
+ #
160
+ # @param overrides [Hash]
161
+ # @return [Hash{String=>Object}]
162
+ def normalize_overrides(overrides)
163
+ overrides.each_with_object({}) do |(key, value), acc|
164
+ name = key.to_s.strip.downcase.tr('_', '-')
165
+ acc[name] = value unless name.empty?
166
+ end
167
+ end
168
+
169
+ # The directive name (first token) of a `;`-terminated directive string,
170
+ # lowercased for case-insensitive matching.
171
+ #
172
+ # @param directive [String]
173
+ # @return [String]
174
+ def directive_name(directive)
175
+ directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase
176
+ end
177
+
178
+ # Build a single `;`-terminated directive string from a name and an
179
+ # override value, or nil when the value signals removal (nil/false).
180
+ #
181
+ # The directive name and each source token are validated against CSP's
182
+ # separator characters: a name or token containing `;` (which separates
183
+ # directives), a newline, or a carriage return raises {ArgumentError}
184
+ # rather than silently injecting extra directives — a real footgun when
185
+ # overrides come from env/config files. (The `false` removal sentinel is
186
+ # checked before {Array} so a bare `false` never becomes a `[false]`
187
+ # source list.)
188
+ #
189
+ # @param name [String] directive name
190
+ # @param value [String, Array, nil, false] source list, or nil/false to remove
191
+ # @return [String, nil]
192
+ # @raise [ArgumentError] if the name or a source token contains a `;`,
193
+ # newline, or carriage return
194
+ def build_directive(name, value)
195
+ return nil if value.nil? || value == false
196
+
197
+ reject_injection!('directive name', name)
198
+ sources = Array(value).filter_map do |token|
199
+ str = token.to_s.strip
200
+ next if str.empty?
201
+
202
+ reject_injection!("source for #{name}", str)
203
+ str
204
+ end.join(' ')
205
+ sources.empty? ? "#{name};" : "#{name} #{sources};"
206
+ end
207
+
208
+ # Raise {ArgumentError} when +text+ carries a CSP directive/token
209
+ # separator (`;`, newline, or carriage return) that would let an
210
+ # override break out of its directive and inject another.
211
+ #
212
+ # @param label [String] what is being validated (for the error message)
213
+ # @param text [String]
214
+ # @return [void]
215
+ # @raise [ArgumentError] if +text+ contains `;`, `\n`, or `\r`
216
+ def reject_injection!(label, text)
217
+ return unless text.match?(/[;\r\n]/)
218
+
219
+ raise ArgumentError,
220
+ "invalid CSP #{label}: #{text.inspect} contains a ';', newline, or carriage return"
221
+ end
222
+
91
223
  # CSP directives for the development environment.
92
224
  #
93
225
  # Development mode allows inline scripts/styles and hot reloading
@@ -108,7 +240,7 @@ class Otto
108
240
  "form-action 'self';",
109
241
  "frame-ancestors 'none';",
110
242
  "manifest-src 'self';",
111
- "worker-src 'self' data:;",
243
+ "worker-src 'self' blob:;",
112
244
  ]
113
245
  end
114
246
 
@@ -132,7 +264,7 @@ class Otto
132
264
  "form-action 'self';", # Restrict form submissions to same origin
133
265
  "frame-ancestors 'none';", # Prevent site from being embedded in frames
134
266
  "manifest-src 'self';", # Allow web app manifests from same origin
135
- "worker-src 'self' data:;", # Allow Workers from same origin and data blobs
267
+ "worker-src 'self' blob:;", # Allow Workers from same origin and blob: URLs
136
268
  ]
137
269
  end
138
270
  end
@@ -0,0 +1,68 @@
1
+ # lib/otto/security/csrf_enforcement_wrapper.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'csrf_validation'
6
+
7
+ class Otto
8
+ module Security
9
+ # Per-route CSRF enforcement, applied at the handler layer.
10
+ #
11
+ # CSRF enforcement lives here rather than in the global +CSRFMiddleware+
12
+ # because +csrf=exempt+ is a per-route option: it is only known once a
13
+ # route has been matched (the middleware runs *ahead* of route matching and
14
+ # never sees route options, so a global block could not honor exemption —
15
+ # issue #186). This wrapper runs after matching, alongside +RouteAuthWrapper+,
16
+ # where +route_definition.csrf_exempt?+ is directly available. It is composed
17
+ # by +HandlerFactory+ only when CSRF protection is enabled, and wraps outside
18
+ # +RouteAuthWrapper+ so a forged unsafe request is rejected before any
19
+ # authentication work runs.
20
+ #
21
+ # The global +CSRFMiddleware+ retains only token *injection* into HTML
22
+ # responses (a response-shaping concern that is method/content-type based,
23
+ # not route based, so it stays global).
24
+ class CSRFEnforcementWrapper
25
+ include CSRFValidation
26
+
27
+ attr_reader :wrapped_handler, :route_definition, :config
28
+
29
+ # @param wrapped_handler [#call] the handler to guard
30
+ # @param route_definition [Otto::RouteDefinition] the matched route
31
+ # @param config [Otto::Security::Config] security config exposing CSRF settings
32
+ def initialize(wrapped_handler, route_definition, config)
33
+ @wrapped_handler = wrapped_handler
34
+ @route_definition = route_definition
35
+ @config = config
36
+ end
37
+
38
+ # @param env [Hash] Rack environment
39
+ # @param extra_params [Hash] Additional parameters passed through to the handler
40
+ # @return [Array] Rack response tuple
41
+ def call(env, extra_params = {})
42
+ return wrapped_handler.call(env, extra_params) unless enforce?(env)
43
+
44
+ request = Otto::Request.new(env)
45
+ return wrapped_handler.call(env, extra_params) if valid_csrf_token?(request)
46
+
47
+ Otto.structured_log(:warn, 'CSRF validation failed',
48
+ Otto::LoggingHelpers.request_context(env).merge(
49
+ handler: route_definition.definition,
50
+ referrer: request.referrer
51
+ ))
52
+ csrf_error_response
53
+ end
54
+
55
+ private
56
+
57
+ # Whether this request must present a valid CSRF token. Only unsafe
58
+ # methods on a non-exempt route are enforced when protection is enabled.
59
+ def enforce?(env)
60
+ return false unless config&.csrf_enabled?
61
+ return false if safe_method?(env['REQUEST_METHOD'])
62
+ return false if route_definition.csrf_exempt?
63
+
64
+ true
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,75 @@
1
+ # lib/otto/security/csrf_validation.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'json'
6
+
7
+ class Otto
8
+ module Security
9
+ # Shared CSRF token mechanics.
10
+ #
11
+ # Both the global +CSRFMiddleware+ (which injects tokens into HTML
12
+ # responses) and the per-route +CSRFEnforcementWrapper+ (which enforces
13
+ # tokens on unsafe requests, honoring +csrf=exempt+) mix this in so the
14
+ # two cannot drift on what counts as a safe method, where a token may be
15
+ # carried, or how a rejection is shaped. The including object must expose a
16
+ # +@config+ (an +Otto::Security::Config+).
17
+ module CSRFValidation
18
+ # HTTP methods that never mutate state and so are exempt from token
19
+ # validation (RFC 7231 safe methods plus TRACE).
20
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
21
+
22
+ # Static 403 body. Frozen once so a rejected request does not re-serialize
23
+ # the same JSON on every call.
24
+ CSRF_ERROR_BODY = {
25
+ error: 'CSRF token validation failed',
26
+ message: 'The request could not be authenticated. Please refresh the page and try again.',
27
+ }.to_json.freeze
28
+
29
+ private
30
+
31
+ def safe_method?(method)
32
+ SAFE_METHODS.include?(method.to_s.upcase)
33
+ end
34
+
35
+ def valid_csrf_token?(request)
36
+ token = extract_csrf_token(request)
37
+ # Reject nil / blank / whitespace-only tokens up front, before creating
38
+ # a session or running HMAC verification — obviously-malformed input
39
+ # should not cause session churn (#186 review).
40
+ return false if token.nil? || token.strip.empty?
41
+
42
+ session_id = extract_session_id(request)
43
+ @config.verify_csrf_token(token, session_id)
44
+ end
45
+
46
+ def extract_csrf_token(request)
47
+ # Try form parameter first
48
+ token = request.params[@config.csrf_token_key]
49
+
50
+ # Try header if not in params
51
+ token ||= request.env[@config.csrf_header_key]
52
+
53
+ # Try alternative header format
54
+ token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
55
+
56
+ token
57
+ end
58
+
59
+ def extract_session_id(request)
60
+ @config.get_or_create_session_id(request)
61
+ end
62
+
63
+ def csrf_error_response
64
+ [
65
+ 403,
66
+ {
67
+ 'content-type' => 'application/json',
68
+ 'content-length' => CSRF_ERROR_BODY.bytesize.to_s,
69
+ },
70
+ [CSRF_ERROR_BODY],
71
+ ]
72
+ end
73
+ end
74
+ end
75
+ end
@@ -7,10 +7,18 @@ require_relative '../config'
7
7
  class Otto
8
8
  module Security
9
9
  module Middleware
10
- # Middleware that provides Cross-Site Request Forgery (CSRF) protection
10
+ # Global middleware that injects CSRF tokens into HTML responses.
11
+ #
12
+ # Token *enforcement* deliberately does NOT live here. This middleware
13
+ # runs ahead of route matching, so it cannot see per-route options like
14
+ # +csrf=exempt+ (issue #186); enforcing globally would block routes an
15
+ # operator explicitly exempted. Enforcement is applied after matching by
16
+ # +Otto::Security::CSRFEnforcementWrapper+ at the handler layer, where the
17
+ # route definition is available. This middleware keeps only the
18
+ # response-shaping half — injecting a fresh token into HTML responses so
19
+ # forms and meta tags can carry it — which is method/content-type based
20
+ # and correctly stays global.
11
21
  class CSRFMiddleware
12
- SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
13
-
14
22
  def initialize(app, config = nil)
15
23
  @app = app
16
24
  @config = config || Otto::Security::Config.new
@@ -19,60 +27,14 @@ class Otto
19
27
  def call(env)
20
28
  return @app.call(env) unless @config.csrf_enabled?
21
29
 
22
- request = Otto::Request.new(env)
23
-
24
- # Skip CSRF protection for safe methods
25
- if safe_method?(request.request_method)
26
- response = @app.call(env)
27
- response = inject_csrf_token(request, response) if html_response?(response)
28
- return response
29
- end
30
-
31
- # Validate CSRF token for unsafe methods
32
- unless valid_csrf_token?(request)
33
- # Log CSRF validation failure
34
- Otto.structured_log(:warn, "CSRF validation failed",
35
- Otto::LoggingHelpers.request_context(env).merge(
36
- referrer: request.referrer
37
- )
38
- )
39
- return csrf_error_response
40
- end
41
-
42
- @app.call(env)
30
+ request = Otto::Request.new(env)
31
+ response = @app.call(env)
32
+ response = inject_csrf_token(request, response) if html_response?(response)
33
+ response
43
34
  end
44
35
 
45
36
  private
46
37
 
47
- def safe_method?(method)
48
- SAFE_METHODS.include?(method.upcase)
49
- end
50
-
51
- def valid_csrf_token?(request)
52
- token = extract_csrf_token(request)
53
- return false if token.nil? || token.empty?
54
-
55
- session_id = @config.get_or_create_session_id(request)
56
- @config.verify_csrf_token(token, session_id)
57
- end
58
-
59
- def extract_csrf_token(request)
60
- # Try form parameter first
61
- token = request.params[@config.csrf_token_key]
62
-
63
- # Try header if not in params
64
- token ||= request.env[@config.csrf_header_key]
65
-
66
- # Try alternative header format
67
- token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
68
-
69
- token
70
- end
71
-
72
- def extract_session_id(request)
73
- @config.get_or_create_session_id(request)
74
- end
75
-
76
38
  def inject_csrf_token(request, response)
77
39
  return response unless response.is_a?(Array) && response.length >= 3
78
40
 
@@ -138,24 +100,6 @@ class Otto
138
100
  content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
139
101
  content_type&.include?('text/html')
140
102
  end
141
-
142
- def csrf_error_response
143
- [
144
- 403,
145
- {
146
- 'content-type' => 'application/json',
147
- 'content-length' => csrf_error_body.bytesize.to_s,
148
- },
149
- [csrf_error_body],
150
- ]
151
- end
152
-
153
- def csrf_error_body
154
- {
155
- error: 'CSRF token validation failed',
156
- message: 'The request could not be authenticated. Please refresh the page and try again.',
157
- }.to_json
158
- end
159
103
  end
160
104
  end
161
105
  end
@@ -69,12 +69,6 @@ class Otto
69
69
 
70
70
  private
71
71
 
72
- # Apply privacy settings to environment
73
- #
74
- # @param env [Hash] Rack environment
75
- # Apply privacy settings to environment
76
- #
77
- # @param env [Hash] Rack environment
78
72
  # Apply privacy settings to environment
79
73
  #
80
74
  # @param env [Hash] Rack environment
@@ -116,6 +110,15 @@ class Otto
116
110
  # Canonical client IP downstream reads (exempt: not masked)
117
111
  env['otto.client_ip'] = client_ip
118
112
  # Don't mask forwarded headers for private IPs
113
+ #
114
+ # This early return also means NONE of the privacy fingerprint
115
+ # values are produced for exempt IPs — no otto.privacy.fingerprint,
116
+ # masked_ip, hashed_ip, geo_country, or correlation_hash. That is
117
+ # intentional and consistent: the correlation hash targets public
118
+ # audit-trail traffic, so req.ip_correlation_hash is nil for
119
+ # localhost / RFC-1918 addresses (the default dev path) even when a
120
+ # correlation_secret is configured. Set mask_private_ips to treat
121
+ # private IPs as public and run them through the full path below.
119
122
  Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
120
123
  return
121
124
  end
@@ -134,6 +137,14 @@ class Otto
134
137
  env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
135
138
  env['otto.privacy.geo_country'] = fingerprint.country
136
139
 
140
+ # Fingerprint the FULL client IP here — while client_ip is still the
141
+ # real address, before REMOTE_ADDR is masked below — so it identifies
142
+ # the visitor, not just their /24. Uses the caller's stable secret
143
+ # (unlike hashed_ip's daily key), so the same IP matches across days.
144
+ # nil when no secret is set. The real IP is never written to env; only
145
+ # this hash leaves the middleware.
146
+ env['otto.privacy.correlation_hash'] = correlation_hash(client_ip)
147
+
137
148
  # CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked values
138
149
  # This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
139
150
  # automatically uses the masked values without modification
@@ -158,6 +169,23 @@ class Otto
158
169
  # or env['otto.original_referer']. This prevents accidental leakage of the real values.
159
170
  end
160
171
 
172
+ # Fingerprint of the full client IP, keyed with the caller's stable
173
+ # correlation secret (not hashed_ip's daily-rotating key). The same IP
174
+ # and secret always produce the same value, so it can match a visitor
175
+ # across days — which the daily hash can't.
176
+ #
177
+ # Returns nil when no secret is configured. An empty key is never used
178
+ # to hash (that would let anyone reverse it); we return nil rather than
179
+ # raise, since "no secret" just means the feature is off.
180
+ #
181
+ # @param client_ip [String] Resolved full client IP (pre-masking)
182
+ # @return [String, nil] Hex HMAC-SHA256 digest, or nil when unconfigured
183
+ def correlation_hash(client_ip)
184
+ secret = @config.correlation_secret
185
+ return nil if secret.nil? || secret.empty?
186
+
187
+ Otto::Privacy::IPPrivacy.hash_ip(client_ip, secret)
188
+ end
161
189
 
162
190
  # Set or clear a Rack env header in a SPEC-compliant way.
163
191
  #
data/lib/otto/security.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'security/authorization_error'
9
9
  require_relative 'security/config'
10
10
  require_relative 'security/configurator'
11
11
  require_relative 'security/middleware/csrf_middleware'
12
+ require_relative 'security/csrf_enforcement_wrapper'
12
13
  require_relative 'security/middleware/validation_middleware'
13
14
  require_relative 'security/middleware/rate_limit_middleware'
14
15
  require_relative 'security/middleware/ip_privacy_middleware'
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.5.0'
6
+ VERSION = '2.6.0'
7
7
  end
data/lib/otto.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require 'concurrent'
5
6
  require 'json'
6
7
  require 'logger'
7
8
  require 'securerandom'
@@ -75,7 +76,8 @@ class Otto
75
76
  end
76
77
  @logger = Logger.new($stdout, Logger::INFO)
77
78
 
78
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
79
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions,
80
+ :routes_by_definition, :option,
79
81
  :static_route, :security_config, :locale_config, :auth_config,
80
82
  :route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
81
83
  :error_handlers, :request_class, :response_class
@@ -107,6 +109,10 @@ class Otto
107
109
  end
108
110
  alias options option
109
111
 
112
+ # Read-only view of assembled instance options. LambdaHandler resolves its
113
+ # registry via otto_instance.config[:lambda_handlers].
114
+ alias config option
115
+
110
116
  # Main Rack application interface
111
117
  def call(env)
112
118
  # Freeze configuration on first request (thread-safe).
@@ -166,10 +172,24 @@ class Otto
166
172
  private
167
173
 
168
174
  def initialize_core_state
169
- @routes_static = { GET: {} }
175
+ # The GET cache is a Concurrent::Map, not a plain Hash: lazy static-file
176
+ # discovery (Core::Router#handle_request, Core::FileSafety#add_static_path)
177
+ # writes into it at request time, after freeze_configuration! has already
178
+ # deep-frozen the rest of the routing state. Deep-freezing this cache too
179
+ # would turn every as-yet-uncached static file request into a 500
180
+ # (FrozenError) in production (issue #185), so it is intentionally excluded
181
+ # from deep_freeze_value in Configuration#freeze_configuration! and kept as
182
+ # a structure that is both mutable post-freeze and safe under concurrent
183
+ # request threads.
184
+ @routes_static = { GET: Concurrent::Map.new }
170
185
  @routes = { GET: [] }
171
186
  @routes_literal = { GET: {} }
172
187
  @route_definitions = {}
188
+ # All routes per definition string, in load order. A definition string is
189
+ # not unique — the same handler can be mounted at several verb/path pairs —
190
+ # so reverse lookups (Otto#uri) consult this index instead of the
191
+ # single-route @route_definitions entry (issue #190).
192
+ @routes_by_definition = {}
173
193
  @security_config = Otto::Security::Config.new
174
194
  @middleware = Otto::Core::MiddlewareStack.new
175
195
  # Initialize @auth_config first so it can be shared with the configurator
@@ -234,6 +254,10 @@ class Otto
234
254
 
235
255
  # Initialize MCP server
236
256
  configure_mcp(opts)
257
+
258
+ # Validate and freeze the lambda handler registry (issue #41).
259
+ # Runs last so misconfiguration fails fast at construction.
260
+ configure_lambda_handlers(opts)
237
261
  end
238
262
 
239
263
  class << self
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.0
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-03 00:00:00.000000000 Z
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -203,6 +203,10 @@ files:
203
203
  - examples/caddy_tls_demo/routes
204
204
  - examples/caddy_tls_demo/standalone.ru
205
205
  - examples/error_handler_registration.rb
206
+ - examples/lambda_handlers/README.md
207
+ - examples/lambda_handlers/config.ru
208
+ - examples/lambda_handlers/handlers.rb
209
+ - examples/lambda_handlers/routes
206
210
  - examples/logging_improvements.rb
207
211
  - examples/mcp_demo/README.md
208
212
  - examples/mcp_demo/app.rb
@@ -254,6 +258,7 @@ files:
254
258
  - lib/otto/privacy/geo_resolver.rb
255
259
  - lib/otto/privacy/ip_privacy.rb
256
260
  - lib/otto/privacy/redacted_fingerprint.rb
261
+ - lib/otto/privacy/user_agent_privacy.rb
257
262
  - lib/otto/request.rb
258
263
  - lib/otto/response.rb
259
264
  - lib/otto/response_handlers.rb
@@ -302,6 +307,8 @@ files:
302
307
  - lib/otto/security/csp/report_middleware.rb
303
308
  - lib/otto/security/csp/writer.rb
304
309
  - lib/otto/security/csrf.rb
310
+ - lib/otto/security/csrf_enforcement_wrapper.rb
311
+ - lib/otto/security/csrf_validation.rb
305
312
  - lib/otto/security/middleware/csrf_middleware.rb
306
313
  - lib/otto/security/middleware/ip_privacy_middleware.rb
307
314
  - lib/otto/security/middleware/rate_limit_middleware.rb