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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/CHANGELOG.rst +135 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/README.md +54 -0
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +1 -0
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +37 -1
- data/lib/otto/privacy/core.rb +18 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +1 -0
- data/lib/otto/request.rb +41 -0
- data/lib/otto/response.rb +80 -43
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/config.rb +137 -84
- data/lib/otto/security/configurator.rb +16 -0
- data/lib/otto/security/core.rb +32 -0
- data/lib/otto/security/csp/emit_middleware.rb +91 -0
- data/lib/otto/security/csp/nonce.rb +78 -0
- data/lib/otto/security/csp/policy.rb +273 -0
- data/lib/otto/security/csp/writer.rb +197 -0
- data/lib/otto/security/csp.rb +20 -8
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
- data/lib/otto/security.rb +1 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -2
- 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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
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
|
-
# @
|
|
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
|
|
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
|
-
# @
|
|
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
|
-
|
|
125
|
-
headers['content-type'] ||= content_type
|
|
153
|
+
warn_send_csp_headers_deprecated
|
|
126
154
|
|
|
127
|
-
#
|
|
128
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
#
|
|
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.
|
|
128
|
-
klass
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
178
|
-
if
|
|
179
|
-
options[
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|