otto 2.4.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 (49) 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 +135 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/README.md +54 -0
  8. data/examples/advanced_routes/README.md +49 -0
  9. data/examples/advanced_routes/config.rb +15 -2
  10. data/examples/advanced_routes/routes +12 -0
  11. data/examples/lambda_handlers/README.md +128 -0
  12. data/examples/lambda_handlers/config.ru +26 -0
  13. data/examples/lambda_handlers/handlers.rb +75 -0
  14. data/examples/lambda_handlers/routes +28 -0
  15. data/lib/otto/core/configuration.rb +103 -1
  16. data/lib/otto/core/middleware_stack.rb +1 -0
  17. data/lib/otto/core/router.rb +67 -10
  18. data/lib/otto/core/uri_generator.rb +36 -2
  19. data/lib/otto/env_keys.rb +21 -0
  20. data/lib/otto/errors.rb +7 -0
  21. data/lib/otto/mcp/route_parser.rb +15 -4
  22. data/lib/otto/privacy/config.rb +37 -1
  23. data/lib/otto/privacy/core.rb +18 -1
  24. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  25. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  26. data/lib/otto/privacy.rb +1 -0
  27. data/lib/otto/request.rb +41 -0
  28. data/lib/otto/response.rb +80 -43
  29. data/lib/otto/route.rb +103 -41
  30. data/lib/otto/route_definition.rb +56 -6
  31. data/lib/otto/route_handlers/base.rb +4 -0
  32. data/lib/otto/route_handlers/factory.rb +15 -0
  33. data/lib/otto/route_handlers/lambda.rb +47 -32
  34. data/lib/otto/security/config.rb +137 -84
  35. data/lib/otto/security/configurator.rb +16 -0
  36. data/lib/otto/security/core.rb +32 -0
  37. data/lib/otto/security/csp/emit_middleware.rb +91 -0
  38. data/lib/otto/security/csp/nonce.rb +78 -0
  39. data/lib/otto/security/csp/policy.rb +273 -0
  40. data/lib/otto/security/csp/writer.rb +197 -0
  41. data/lib/otto/security/csp.rb +20 -8
  42. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  43. data/lib/otto/security/csrf_validation.rb +75 -0
  44. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  45. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  46. data/lib/otto/security.rb +1 -0
  47. data/lib/otto/version.rb +1 -1
  48. data/lib/otto.rb +26 -2
  49. metadata +13 -2
data/lib/otto/request.rb CHANGED
@@ -24,6 +24,20 @@ class Otto
24
24
  env['HTTP_USER_AGENT']
25
25
  end
26
26
 
27
+ # Framework-owned, request-scoped CSP nonce, generated lazily on first
28
+ # access and memoized into the request env. Views call this to stamp
29
+ # `nonce="…"` onto their inline `<script>`/`<link>` tags; the same value is
30
+ # what {Otto::Security::CSP::EmitMiddleware} writes into the `script-src
31
+ # 'nonce-…'` header — so the header and the views agree structurally, not by
32
+ # convention. An untouched request generates nothing.
33
+ #
34
+ # The env key is configurable via {Otto::Security::Config#csp_nonce_key}.
35
+ #
36
+ # @return [String] this request's nonce (base64)
37
+ def csp_nonce
38
+ Otto::Security::CSP.nonce(env)
39
+ end
40
+
27
41
  # Canonical client IP for the request.
28
42
  #
29
43
  # Prefers env['otto.client_ip'] — the value resolved once, early, by
@@ -119,6 +133,33 @@ class Otto
119
133
  redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
120
134
  end
121
135
 
136
+ # Get the stable-keyed correlation hash of the client IP.
137
+ #
138
+ # Contrast with #hashed_ip: that value is keyed with a daily-rotating
139
+ # secret (great for correlating requests within a session, useless across
140
+ # days). This value is HMAC-SHA256 over the SAME full, pre-masking client
141
+ # IP but keyed with a caller-configured STABLE secret, so the same IP
142
+ # produces the same hash indefinitely — the granularity long-lived audit
143
+ # records need without ever handling the raw IP.
144
+ #
145
+ # Both are computed before masking, so both reflect the per-host address
146
+ # (not the /24 the app is otherwise left with); the raw IP itself never
147
+ # reaches the application — only the hash does.
148
+ #
149
+ # Returns nil when IP privacy is disabled, no correlation secret is
150
+ # configured (see Otto#configure_ip_privacy(correlation_secret:)), or the
151
+ # client IP is exempt from masking. By default private/localhost IPs are
152
+ # exempt (mask_private_ips is false), so this is nil for RFC-1918 and
153
+ # loopback addresses — including the common local dev path — just like
154
+ # #masked_ip and #hashed_ip. It targets public audit-trail traffic.
155
+ #
156
+ # @return [String, nil] Hexadecimal HMAC-SHA256 hash string or nil
157
+ # @example
158
+ # req.ip_correlation_hash # => 'b7e2...' (stable across days)
159
+ def ip_correlation_hash
160
+ env['otto.privacy.correlation_hash']
161
+ end
162
+
122
163
  def client_ipaddress
123
164
  # Prefer the canonical client IP resolved once by IPPrivacyMiddleware
124
165
  # ("resolve once, read everywhere"). Falls back to the shared resolver
data/lib/otto/response.rb CHANGED
@@ -14,12 +14,18 @@ class Otto
14
14
  # @example Using Otto's response in route handlers
15
15
  # def show(req, res)
16
16
  # res.send_secure_cookie('session_id', token, 3600)
17
- # res.send_csp_headers('text/html', nonce)
17
+ # res.apply_csp(req.csp_nonce)
18
18
  # res.no_cache!
19
19
  # end
20
20
  #
21
21
  # @see Otto#register_response_helpers
22
22
  class Response < Rack::Response
23
+ # One-time-per-process guard for the #send_csp_headers deprecation warning.
24
+ @send_csp_headers_deprecation_warned = false
25
+ class << self
26
+ attr_accessor :send_csp_headers_deprecation_warned # rubocop:disable ThreadSafety/ClassAndModuleAttributes
27
+ end
28
+
23
29
  # Reference to the request object (needed by some response helpers)
24
30
  # @return [Otto::Request]
25
31
  attr_accessor :request
@@ -97,56 +103,65 @@ class Otto
97
103
  headers
98
104
  end
99
105
 
100
- # Set Content Security Policy (CSP) headers with nonce support
106
+ # Apply a nonce-based Content-Security-Policy to this response.
107
+ #
108
+ # This is THE emission helper: it routes through the single apply core
109
+ # ({Otto::Security::CSP::Writer}), so all the invariants — enabled-only,
110
+ # nonce-present, HTML-only, lowercase key, no duplicate — hold here exactly as
111
+ # they do in the middleware, with no guard logic duplicated. The response's
112
+ # Content-Type must already be set (it decides HTML-only); this helper does
113
+ # NOT set it.
114
+ #
115
+ # `mode: :override` (the default) is the deliberate per-request call: it
116
+ # REPLACES any existing CSP. Pass `mode: :backstop` to defer to an existing
117
+ # policy instead.
101
118
  #
102
- # This method generates and sets CSP headers with the provided nonce value,
103
- # following the same usage pattern as send_cookie methods. The CSP policy
104
- # is generated dynamically based on the security configuration and environment.
119
+ # @param nonce [String] the per-request nonce (typically {Otto::Request#csp_nonce})
120
+ # @param mode [Symbol] `:override` or `:backstop` (see {Otto::Security::CSP::Writer::MODES})
121
+ # @param development_mode [Boolean] use development-friendly CSP directives
122
+ # @param security_config [Otto::Security::Config, nil] config to use; resolved
123
+ # from the request env when omitted
124
+ # @return [Otto::Security::CSP::Writer::Result] the outcome (applied?, policy,
125
+ # skip_reason) for uniform observability
105
126
  #
106
- # @param content_type [String] Content-Type header value to set
127
+ # @example
128
+ # res['content-type'] = 'text/html; charset=utf-8'
129
+ # res.apply_csp(req.csp_nonce)
130
+ def apply_csp(nonce, mode: :override, development_mode: false, security_config: nil)
131
+ config = security_config || (request&.env && request.env['otto.security_config'])
132
+ Otto::Security::CSP::Writer.apply(
133
+ headers, nonce,
134
+ config: config, mode: mode, development_mode: development_mode
135
+ )
136
+ end
137
+
138
+ # @deprecated Use {#apply_csp} instead. Retained as a thin shim over the apply
139
+ # core so existing callers keep working while its historical quirks are
140
+ # fixed: a nil/empty nonce no longer emits a broken `script-src 'nonce-'`
141
+ # (it skips), a CSP is no longer emitted for non-HTML responses, and the
142
+ # override notice goes through {Otto.logger} instead of a bare `warn` to
143
+ # stderr. Unlike {#apply_csp}, it still sets the Content-Type for you and
144
+ # emits in `:override` mode.
145
+ #
146
+ # @param content_type [String] Content-Type to set if not already set
107
147
  # @param nonce [String] Nonce value to include in CSP directives
108
- # @param opts [Hash] Options for CSP generation
148
+ # @param opts [Hash] Options
109
149
  # @option opts [Otto::Security::Config] :security_config Security config to use
110
150
  # @option opts [Boolean] :development_mode Use development-friendly CSP directives
111
- # @option opts [Boolean] :debug Enable debug logging for this request
112
- # @return [void]
113
- #
114
- # @example Basic usage
115
- # nonce = SecureRandom.base64(16)
116
- # res.send_csp_headers('text/html; charset=utf-8', nonce)
117
- #
118
- # @example With options
119
- # res.send_csp_headers('text/html; charset=utf-8', nonce, {
120
- # development_mode: Rails.env.development?,
121
- # debug: true
122
- # })
151
+ # @return [Otto::Security::CSP::Writer::Result]
123
152
  def send_csp_headers(content_type, nonce, opts = {})
124
- # Set content type if not already set
125
- headers['content-type'] ||= content_type
153
+ warn_send_csp_headers_deprecated
126
154
 
127
- # Warn if CSP header already exists but don't skip
128
- warn 'CSP header already set, overriding with nonce-based policy' if headers['content-security-policy']
129
-
130
- # Get security configuration
131
- security_config = opts[:security_config] ||
132
- (request&.env && request.env['otto.security_config']) ||
133
- nil
134
-
135
- # Skip if CSP nonce support is not enabled
136
- return unless security_config&.csp_nonce_enabled?
137
-
138
- # Generate CSP policy with nonce
139
- development_mode = opts[:development_mode] || false
140
- csp_policy = security_config.generate_nonce_csp(nonce, development_mode: development_mode)
141
-
142
- # Debug logging if enabled
143
- debug_enabled = opts[:debug] || security_config.debug_csp?
144
- if debug_enabled && defined?(Otto.logger)
145
- Otto.logger.debug "[CSP] #{csp_policy}"
146
- end
155
+ # Historical behavior the shim keeps (apply_csp does not): default the
156
+ # Content-Type so an HTML response is recognized as HTML.
157
+ headers['content-type'] ||= content_type
147
158
 
148
- # Set the CSP header
149
- headers['content-security-policy'] = csp_policy
159
+ apply_csp(
160
+ nonce,
161
+ mode: :override,
162
+ development_mode: opts[:development_mode] || false,
163
+ security_config: opts[:security_config]
164
+ )
150
165
  end
151
166
 
152
167
  # Set cache control headers to prevent caching
@@ -187,5 +202,27 @@ class Otto
187
202
  paths.unshift(request.env['SCRIPT_NAME']) if request&.env&.[]('SCRIPT_NAME')
188
203
  paths.join('/').gsub('//', '/')
189
204
  end
205
+
206
+ private
207
+
208
+ # Emit the #send_csp_headers deprecation notice at most once per process
209
+ # (Response is per-request, so the guard lives on the class).
210
+ #
211
+ # The check-then-set on the class flag is deliberately unsynchronized: the
212
+ # race is benign — worst case, two threads racing on the very first call each
213
+ # log the notice once. The flag gates only a log line, never any behavior, so
214
+ # a mutex would add contention on a hot path to save at most a couple of
215
+ # duplicate deprecation lines at startup.
216
+ def warn_send_csp_headers_deprecated
217
+ return if self.class.send_csp_headers_deprecation_warned
218
+ return unless defined?(Otto.logger) && Otto.logger
219
+
220
+ self.class.send_csp_headers_deprecation_warned = true
221
+ Otto.logger.warn(
222
+ '[Otto::Response] #send_csp_headers is deprecated and will be removed in a ' \
223
+ 'future release; use #apply_csp(nonce, mode: :override) (set Content-Type first), ' \
224
+ 'or mount Otto::Security::CSP::EmitMiddleware via #enable_csp_emission!.'
225
+ )
226
+ end
190
227
  end
191
228
  end
data/lib/otto/route.rb CHANGED
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require 'concurrent'
6
+
5
7
  require_relative 'security/constant_resolver'
6
8
 
7
9
  class Otto
@@ -24,9 +26,44 @@ class Otto
24
26
  #
25
27
  #
26
28
  class Route
27
- # Class methods for Route providing Otto instance access
29
+ # Class methods for Route providing Otto instance access.
30
+ #
31
+ # `route.rb` and `route_handlers/base.rb` `extend` this onto the target
32
+ # class on every request and set `.otto = otto_instance` so app code can
33
+ # read `self.class.otto` from within a handler method. A plain class
34
+ # ivar here would be shared, mutable state: two `Otto` instances sharing
35
+ # a controller/logic class, or concurrent threads/fibers serving
36
+ # requests under different `Otto` instances, would race and clobber
37
+ # `klass.otto`, leaking one request's security_config/auth_config into
38
+ # another's handler (issue #188). Backing the accessor with a
39
+ # `Concurrent::FiberLocalVar` scopes each assignment to the fiber/thread
40
+ # actually serving that request instead.
41
+ #
42
+ # NOTE (Otto v3): this whole class-level accessor is ambient per-request
43
+ # state and exists only as a convenience so handler code can reach otto via
44
+ # `self.class.otto`. The clean design carries no ambient state at all —
45
+ # the handler instance already receives its `Otto` explicitly
46
+ # (`BaseHandler.new(route_definition, otto_instance)`), so app code should
47
+ # read it from an instance-level `#otto` reader instead. Recommended for
48
+ # Otto v3: expose `otto` on the handler instance, deprecate
49
+ # `self.class.otto`, and drop `ClassMethods` — then there is no shared slot
50
+ # to race, reset, or leak, and this fiber-local workaround goes away.
28
51
  module ClassMethods
29
- attr_accessor :otto
52
+ # Per-fiber storage keyed by target class. Deliberately
53
+ # `FiberLocalVar`, not `ThreadLocalVar`: fiber-per-request schedulers
54
+ # (Falcon/Async) run many requests as fibers in one thread, so
55
+ # thread-scoped storage would let those fibers clobber each other's
56
+ # `klass.otto` — the same race, one level down. The default block gives
57
+ # each fiber its own class => otto hash on first access.
58
+ OTTO_INSTANCES = Concurrent::FiberLocalVar.new { {} }
59
+
60
+ def otto=(instance)
61
+ OTTO_INSTANCES.value[self] = instance
62
+ end
63
+
64
+ def otto
65
+ OTTO_INSTANCES.value[self]
66
+ end
30
67
  end
31
68
  # @return [Otto::RouteDefinition] The immutable route definition
32
69
  attr_reader :route_definition
@@ -52,8 +89,15 @@ class Otto
52
89
  # Create immutable route definition
53
90
  @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
54
91
 
55
- # Resolve the class
56
- @klass = Otto::Security::ConstantResolver.safe_const_get(@route_definition.klass_name)
92
+ # Resolve the class.
93
+ # Lambda routes carry a registry KEY in klass_name, not a Ruby constant.
94
+ # Skip constant resolution (it would raise on a lowercase/unregistered key
95
+ # and the loader would silently drop the route).
96
+ @klass = if @route_definition.kind == :lambda
97
+ nil
98
+ else
99
+ Otto::Security::ConstantResolver.safe_const_get(@route_definition.klass_name)
100
+ end
57
101
  end
58
102
 
59
103
  # Delegate common methods to route_definition for backward compatibility
@@ -102,14 +146,37 @@ class Otto
102
146
  # @return [Array] Rack response array [status, headers, body]
103
147
  def call(env, extra_params = {})
104
148
  extra_params ||= {}
105
- req = otto.request_class.new(env)
106
- res = otto.response_class.new
149
+
150
+ # Pluggable route handler factory (Phase 4). The handler owns
151
+ # request/response construction and decoration — param merging,
152
+ # indifferent access, security headers, CSRF/validation helpers all
153
+ # happen once in BaseHandler#setup_request_response. Building them here
154
+ # too would duplicate that work on objects that get discarded (issue #189).
155
+ if otto&.route_handler_factory
156
+ # Make security config, route definition, and options available to
157
+ # middleware and handlers before delegating, so wrappers that run
158
+ # ahead of the handler's own setup (RouteAuthWrapper, the centralized
159
+ # error handler) can see them.
160
+ env['otto.security_config'] = otto.security_config if otto.respond_to?(:security_config) && otto.security_config
161
+ env['otto.route_definition'] = @route_definition
162
+ env['otto.route_options'] = @route_definition.options
163
+
164
+ handler = otto.route_handler_factory.create_handler(@route_definition, otto)
165
+ return handler.call(env, extra_params)
166
+ end
167
+
168
+ # Fallback to legacy behavior for backward compatibility. Build req/res
169
+ # before touching env, preserving the exact ordering this path always
170
+ # had — a custom request_class/response_class#initialize that reads env
171
+ # must keep seeing it unpopulated, same as before #189 (review follow-up).
172
+ req = otto.request_class.new(env)
173
+ res = otto.response_class.new
107
174
  res.request = req
108
175
 
109
176
  # Make security config available to response helpers
110
177
  env['otto.security_config'] = otto.security_config if otto.respond_to?(:security_config) && otto.security_config
111
178
 
112
- # NEW: Make route definition and options available to middleware and handlers
179
+ # Make route definition and options available to middleware and handlers
113
180
  env['otto.route_definition'] = @route_definition
114
181
  env['otto.route_options'] = @route_definition.options
115
182
 
@@ -124,8 +191,11 @@ class Otto
124
191
  end
125
192
  end
126
193
 
127
- klass.extend Otto::Route::ClassMethods
128
- klass.otto = otto
194
+ # No target class for lambda routes (klass is nil); skip class extension.
195
+ if klass
196
+ klass.extend Otto::Route::ClassMethods
197
+ klass.otto = otto
198
+ end
129
199
 
130
200
  # Add security helpers if CSRF is enabled
131
201
  if otto.respond_to?(:security_config) && otto.security_config&.csrf_enabled?
@@ -135,39 +205,31 @@ class Otto
135
205
  # Add validation helpers
136
206
  res.extend Otto::Security::ValidationHelpers
137
207
 
138
- # NEW: Use the pluggable route handler factory (Phase 4)
139
- # This replaces the hardcoded execution pattern with a factory approach
140
- if otto&.route_handler_factory
141
- handler = otto.route_handler_factory.create_handler(@route_definition, otto)
142
- handler.call(env, extra_params)
143
- else
144
- # Fallback to legacy behavior for backward compatibility
145
- inst = nil
146
- result = case kind
147
- when :instance
148
- inst = klass.new req, res
149
- inst.send(name)
150
- when :class
151
- klass.send(name, req, res)
152
- else
153
- raise "Unsupported kind for #{definition}: #{kind}"
154
- end
155
-
156
- # Handle response based on route options
157
- response_type = @route_definition.response_type
158
- if response_type != 'default'
159
- context = {
160
- logic_instance: (kind == :instance ? inst : nil),
161
- status_code: nil,
162
- redirect_path: nil,
163
- }
164
-
165
- Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
166
- end
167
-
168
- res.body = [res.body] unless res.body.respond_to?(:each)
169
- res.finish
208
+ inst = nil
209
+ result = case kind
210
+ when :instance
211
+ inst = klass.new req, res
212
+ inst.send(name)
213
+ when :class
214
+ klass.send(name, req, res)
215
+ else
216
+ raise "Unsupported kind for #{definition}: #{kind}"
217
+ end
218
+
219
+ # Handle response based on route options
220
+ response_type = @route_definition.response_type
221
+ if response_type != 'default'
222
+ context = {
223
+ logic_instance: (kind == :instance ? inst : nil),
224
+ status_code: nil,
225
+ redirect_path: nil,
226
+ }
227
+
228
+ Otto::ResponseHandlers::HandlerFactory.handle_response(result, res, response_type, context)
170
229
  end
230
+
231
+ res.body = [res.body] unless res.body.respond_to?(:each)
232
+ res.finish
171
233
  end
172
234
 
173
235
  private
@@ -2,10 +2,18 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative 'errors'
6
+
5
7
  class Otto
6
8
  # Immutable data class representing a complete route definition
7
9
  # This encapsulates all aspects of a route: path, target, and options
8
10
  class RouteDefinition
11
+ # Options that gate access to a route. A malformed token for one of these
12
+ # (e.g. `csrf` instead of `csrf=exempt`, or a bare `auth`) must not fall
13
+ # back to the default behavior silently — the route would serve without
14
+ # its intended protection (issue #191).
15
+ SECURITY_GATING_OPTIONS = %w[auth role csrf].freeze
16
+
9
17
  # @return [String] The HTTP verb (GET, POST, etc.)
10
18
  attr_reader :verb
11
19
 
@@ -112,6 +120,32 @@ class Otto
112
120
  option(:response, 'default')
113
121
  end
114
122
 
123
+ # Parse a single whitespace-delimited `key=value` option token, applying
124
+ # the security-gating fail-fast rule shared by normal routes and the MCP
125
+ # RouteParser (issue #191 and its MCP/TOOL follow-up).
126
+ # @param part [String] a single option token, e.g. "auth=session"
127
+ # @param context [String] human-readable source description for the
128
+ # raised error message, e.g. "route definition \"GET /admin ...\""
129
+ # @return [Array(Symbol, String), nil] the [key, value] pair to store,
130
+ # or nil if the token is malformed and should only be warned about
131
+ # @raise [Otto::RouteDefinitionError] if a security-gating option
132
+ # (auth/role/csrf) is malformed
133
+ def self.parse_option_token(part, context)
134
+ key, value = part.split('=', 2)
135
+ normalized_key = key&.downcase
136
+
137
+ if SECURITY_GATING_OPTIONS.include?(normalized_key)
138
+ if key != normalized_key || value.nil? || value.empty?
139
+ raise Otto::RouteDefinitionError,
140
+ "Malformed security option #{part.inspect} in #{context}: " \
141
+ "expected #{normalized_key}=value"
142
+ end
143
+ [key.to_sym, value]
144
+ elsif key && !key.empty? && value
145
+ [key.to_sym, value]
146
+ end
147
+ end
148
+
115
149
  # Check if CSRF is exempt for this route
116
150
  # @return [Boolean]
117
151
  def csrf_exempt?
@@ -168,18 +202,21 @@ class Otto
168
202
  # Parse route definition into target and options
169
203
  # @param definition [String] The route definition
170
204
  # @return [Hash] Hash with :target and :options keys
205
+ # @raise [Otto::RouteDefinitionError] if a security-gating option token
206
+ # (auth/role/csrf) has no value — failing fast instead of serving the
207
+ # route with default (less safe) behavior
171
208
  def parse_definition(definition)
172
209
  parts = definition.split(/\s+/)
173
210
  target = parts.shift
174
211
  options = {}
175
212
 
176
213
  parts.each do |part|
177
- key, value = part.split('=', 2)
178
- if key && value
179
- options[key.to_sym] = value
180
- elsif Otto.debug
181
- # Malformed parameter, log warning if debug enabled
182
- Otto.logger.warn "Ignoring malformed route parameter: #{part}"
214
+ pair = self.class.parse_option_token(part, "route definition #{definition.inspect}")
215
+ if pair
216
+ options[pair[0]] = pair[1]
217
+ else
218
+ Otto.structured_log(:warn, 'Malformed route option ignored',
219
+ { option: part, definition: definition })
183
220
  end
184
221
  end
185
222
 
@@ -191,6 +228,19 @@ class Otto
191
228
  # @return [Hash] Hash with :klass_name, :method_name, and :kind
192
229
  def parse_target(target)
193
230
  case target
231
+ when /^&/
232
+ # Lambda handler: '&name' references a proc pre-registered in the
233
+ # lambda_handlers registry. The entire remainder after '&' is the exact
234
+ # O(1) Hash lookup key (may contain '.', '#', '::' — all inert here;
235
+ # resolution is string equality, never eval/const_get). Issue #41 security.
236
+ name = target[1..].to_s
237
+ if name.strip.empty?
238
+ raise ArgumentError,
239
+ "Invalid lambda handler target #{target.inspect}: handler name " \
240
+ "after '&' cannot be empty (expected '&handler_name')"
241
+ end
242
+ { klass_name: name, method_name: nil, kind: :lambda }
243
+
194
244
  when /^(.+)\.(.+)$/
195
245
  # Class.method - call class method directly
196
246
  { klass_name: ::Regexp.last_match(1), method_name: ::Regexp.last_match(2), kind: :class }
@@ -125,6 +125,10 @@ class Otto
125
125
  # @param env [Hash] Rack environment
126
126
  # @param extra_params [Hash] Additional parameters
127
127
  def setup_request_response(req, res, env, extra_params)
128
+ # Expose the raw path-capture hash to invoke_target (LambdaHandler
129
+ # passes it as the proc's 3rd arg). Inert for other subclasses.
130
+ @extra_params = extra_params
131
+
128
132
  # Set request reference (helpers are already included in class)
129
133
  res.request = req
130
134
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  require_relative 'base'
6
6
  require_relative '../security/authentication/route_auth_wrapper'
7
+ require_relative '../security/csrf_enforcement_wrapper'
7
8
 
8
9
  class Otto
9
10
  module RouteHandlers
@@ -19,6 +20,7 @@ class Otto
19
20
  when :logic then LogicClassHandler
20
21
  when :instance then InstanceMethodHandler
21
22
  when :class then ClassMethodHandler
23
+ when :lambda then LambdaHandler
22
24
  else
23
25
  raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
24
26
  end
@@ -37,6 +39,19 @@ class Otto
37
39
  )
38
40
  end
39
41
 
42
+ # Enforce CSRF at the handler layer, where `csrf=exempt` is visible
43
+ # (the global CSRFMiddleware runs ahead of route matching and cannot
44
+ # honor per-route exemption — issue #186). Wrapped OUTSIDE
45
+ # RouteAuthWrapper so a forged unsafe request is rejected before any
46
+ # authentication work runs. Only added when protection is enabled.
47
+ if otto_instance&.security_config&.csrf_enabled?
48
+ handler = Otto::Security::CSRFEnforcementWrapper.new(
49
+ handler,
50
+ route_definition,
51
+ otto_instance.security_config
52
+ )
53
+ end
54
+
40
55
  apply_handler_wrappers(handler, route_definition, otto_instance)
41
56
  end
42
57
 
@@ -1,46 +1,61 @@
1
1
  # lib/otto/route_handlers/lambda.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
- require 'securerandom'
5
4
 
6
5
  require_relative 'base'
7
6
 
8
7
  class Otto
9
8
  module RouteHandlers
10
- # Custom handler for lambda/proc definitions (future extension)
9
+ # Handler for pre-registered lambda/proc route targets (issue #41).
10
+ #
11
+ # Route syntax `GET /ping &health_check` parses to kind :lambda with
12
+ # klass_name = "health_check" (the registry KEY, not a Ruby constant) and
13
+ # method_name = nil. The proc is looked up O(1) by name from the Otto
14
+ # instance's pre-registered lambda_handlers — no eval, no dynamic constants.
15
+ #
16
+ # Reuses BaseHandler#call: implements #invoke_target and guards the base's
17
+ # constant-resolution steps (#target_class / #handler_name).
11
18
  class LambdaHandler < BaseHandler
12
- def call(env, extra_params = {})
13
- start_time = Otto::Utils.now_in_μs
14
- req = otto_instance ? otto_instance.request_class.new(env) : Otto::Request.new(env)
15
- res = otto_instance ? otto_instance.response_class.new : Otto::Response.new
16
-
17
- begin
18
- # Security: Lambda handlers require pre-configured procs from Otto instance
19
- # This prevents code injection via eval and maintains security
20
- handler_name = route_definition.klass_name
21
- lambda_registry = otto_instance&.config&.dig(:lambda_handlers) || {}
22
-
23
- lambda_proc = lambda_registry[handler_name]
24
- unless lambda_proc.respond_to?(:call)
25
- raise ArgumentError, "Lambda handler '#{handler_name}' not found in registry or not callable"
26
- end
27
-
28
- result = lambda_proc.call(req, res, extra_params)
29
-
30
- handle_response(result, res, {
31
- lambda: lambda_proc,
32
- request: req,
33
- })
34
- rescue StandardError => e
35
- # Store handler context in env for centralized error handler
36
- handler_name = "Lambda[#{route_definition.klass_name}]"
37
- env['otto.handler'] = handler_name
38
- env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
39
-
40
- raise e # Re-raise to let Otto's centralized error handler manage the response
19
+ protected
20
+
21
+ # No Ruby constant backs a lambda route. Returning nil (a) prevents
22
+ # ConstantResolver.safe_const_get from raising on a registry key and
23
+ # (b) makes BaseHandler#setup_request_response skip its target_class
24
+ # extension block.
25
+ def target_class
26
+ nil
27
+ end
28
+
29
+ # Derive the log/handler name from the route, not target_class.name
30
+ # (which would be nil.name -> NoMethodError inside handle_execution_error).
31
+ def handler_name
32
+ "Lambda[#{route_definition.klass_name}]"
33
+ end
34
+
35
+ # Look up the pre-registered proc and invoke it with (req, res, extra_params).
36
+ # @return [Array] [result, context] consumed by BaseHandler#handle_response
37
+ def invoke_target(req, res)
38
+ handler_key = route_definition.klass_name
39
+ lambda_proc = lambda_registry[handler_key]
40
+
41
+ unless lambda_proc.respond_to?(:call)
42
+ raise ArgumentError,
43
+ "Lambda handler '#{handler_key}' is not registered or not callable"
41
44
  end
42
45
 
43
- res.finish
46
+ result = lambda_proc.call(req, res, @extra_params || {})
47
+ [result, { request: req }]
48
+ end
49
+
50
+ private
51
+
52
+ # O(1) read of the frozen registry from Otto config. Tolerates the
53
+ # direct-testing context (no otto_instance / no :config) by returning {},
54
+ # so invoke_target raises the clear "not registered" ArgumentError.
55
+ def lambda_registry
56
+ return {} unless otto_instance.respond_to?(:config)
57
+
58
+ (otto_instance.config && otto_instance.config[:lambda_handlers]) || {}
44
59
  end
45
60
  end
46
61
  end