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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/CHANGELOG.rst +65 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- 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/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +11 -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 +27 -0
- 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 +100 -5
- data/lib/otto/security/csp/policy.rb +135 -3
- 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 +9 -2
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
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -72,7 +72,8 @@ class Otto
|
|
|
72
72
|
:security_headers,
|
|
73
73
|
:csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
|
|
74
74
|
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
|
|
75
|
-
:csp_report_uri, :csp_report_to_url, :csp_violation_callback
|
|
75
|
+
:csp_report_uri, :csp_report_to_url, :csp_violation_callback,
|
|
76
|
+
:csp_directive_overrides
|
|
76
77
|
|
|
77
78
|
# Initialize security configuration with safe defaults
|
|
78
79
|
#
|
|
@@ -100,6 +101,8 @@ class Otto
|
|
|
100
101
|
@csp_report_uri = nil
|
|
101
102
|
@csp_report_to_url = nil
|
|
102
103
|
@csp_violation_callback = nil
|
|
104
|
+
@csp_directive_overrides = {}
|
|
105
|
+
@csp_script_src_override_warned = false
|
|
103
106
|
@rate_limiting_config = { custom_rules: {} }
|
|
104
107
|
@ip_privacy_config = Otto::Privacy::Config.new
|
|
105
108
|
|
|
@@ -367,19 +370,85 @@ class Otto
|
|
|
367
370
|
# Unlike enable_csp!, this doesn't set a static policy but enables the response
|
|
368
371
|
# helper to generate CSP headers with nonces on a per-request basis.
|
|
369
372
|
#
|
|
373
|
+
# Per-directive overrides may be supplied to customize the emitted nonce
|
|
374
|
+
# policy without vendoring the gem. They merge into Otto's base directive
|
|
375
|
+
# sets ({Otto::Security::CSP::Policy.development_directives} /
|
|
376
|
+
# {Otto::Security::CSP::Policy.production_directives}): a matching directive
|
|
377
|
+
# is replaced in place, a new directive is appended, and a nil/false value
|
|
378
|
+
# removes a directive. See {#csp_directive_overrides=} for the accepted
|
|
379
|
+
# shape.
|
|
380
|
+
#
|
|
370
381
|
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
|
382
|
+
# @param directives [Hash] per-directive overrides merged into the base set
|
|
371
383
|
# @return [void]
|
|
372
384
|
# @raise [FrozenError] if configuration is frozen
|
|
373
385
|
#
|
|
374
386
|
# @example
|
|
375
387
|
# config.enable_csp_with_nonce!(debug: true)
|
|
376
|
-
|
|
388
|
+
#
|
|
389
|
+
# @example Restore data: workers (blob: is the default worker-src token)
|
|
390
|
+
# config.enable_csp_with_nonce!(directives: { 'worker-src' => "'self' data: blob:" })
|
|
391
|
+
def enable_csp_with_nonce!(debug: false, directives: {})
|
|
377
392
|
ensure_not_frozen!
|
|
378
393
|
|
|
394
|
+
# Apply overrides before toggling state so a bad +directives+ argument
|
|
395
|
+
# raises without leaving the config half-updated (nonce enabled but
|
|
396
|
+
# overrides not merged).
|
|
397
|
+
merge_csp_directives(directives) unless directives.nil? || directives.empty?
|
|
379
398
|
@csp_nonce_enabled = true
|
|
380
399
|
@debug_csp = debug
|
|
381
400
|
end
|
|
382
401
|
|
|
402
|
+
# Replace the per-directive overrides applied to the nonce CSP policy.
|
|
403
|
+
#
|
|
404
|
+
# Overrides merge into Otto's base directive sets when
|
|
405
|
+
# {#generate_nonce_csp} builds the policy, so a consuming app can adjust
|
|
406
|
+
# ANY directive rather than only `report-uri`/`report-to`. Keys are
|
|
407
|
+
# directive names (String or Symbol, matched case-insensitively); values
|
|
408
|
+
# are the source list as a String (`"'self' blob:"`) or Array
|
|
409
|
+
# (`%w['self' blob:]`), or nil/false to REMOVE the directive.
|
|
410
|
+
#
|
|
411
|
+
# Keys are normalized on store (lowercased, hyphenated) via
|
|
412
|
+
# {Otto::Security::CSP::Policy.normalize_overrides}, so the stored hash
|
|
413
|
+
# never accumulates logically-identical entries under different key styles
|
|
414
|
+
# (`'WORKER-SRC'` and `:worker_src` both read back as `'worker-src'`).
|
|
415
|
+
#
|
|
416
|
+
# @note Overriding `script-src` while nonce mode is enabled disables
|
|
417
|
+
# nonce-based script protection: the per-request nonce cannot be
|
|
418
|
+
# included in a static override, so it is stripped from the emitted
|
|
419
|
+
# header. A warning is logged when a `script-src` override is stored.
|
|
420
|
+
# See {Otto::Security::CSP::Policy.merge_directives}.
|
|
421
|
+
#
|
|
422
|
+
# @param overrides [Hash] directive name => source list / nil
|
|
423
|
+
# @return [void]
|
|
424
|
+
# @raise [FrozenError] if configuration is frozen
|
|
425
|
+
#
|
|
426
|
+
# @example
|
|
427
|
+
# config.csp_directive_overrides = { 'worker-src' => "'self' data: blob:" }
|
|
428
|
+
def csp_directive_overrides=(overrides)
|
|
429
|
+
ensure_not_frozen!
|
|
430
|
+
|
|
431
|
+
normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
|
|
432
|
+
warn_if_script_src_overridden(normalized)
|
|
433
|
+
@csp_directive_overrides = normalized
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Merge additional per-directive overrides into the existing set, leaving
|
|
437
|
+
# untouched any directive not named in +overrides+ (last write wins for a
|
|
438
|
+
# repeated directive). Use this to accumulate overrides incrementally;
|
|
439
|
+
# use {#csp_directive_overrides=} to replace them wholesale.
|
|
440
|
+
#
|
|
441
|
+
# @param overrides [Hash] directive name => source list / nil
|
|
442
|
+
# @return [void]
|
|
443
|
+
# @raise [FrozenError] if configuration is frozen
|
|
444
|
+
def merge_csp_directives(overrides)
|
|
445
|
+
ensure_not_frozen!
|
|
446
|
+
|
|
447
|
+
normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
|
|
448
|
+
warn_if_script_src_overridden(normalized)
|
|
449
|
+
@csp_directive_overrides = @csp_directive_overrides.merge(normalized)
|
|
450
|
+
end
|
|
451
|
+
|
|
383
452
|
# Disable CSP nonce support
|
|
384
453
|
#
|
|
385
454
|
# @return [void]
|
|
@@ -527,8 +596,10 @@ class Otto
|
|
|
527
596
|
# Generate a CSP policy string with the provided nonce
|
|
528
597
|
#
|
|
529
598
|
# Thin facade over {Otto::Security::CSP::Policy.nonce_policy}; the directive
|
|
530
|
-
# sets and report-uri/report-to assembly live there now.
|
|
531
|
-
#
|
|
599
|
+
# sets and report-uri/report-to assembly live there now. Any configured
|
|
600
|
+
# {#csp_directive_overrides} are merged into the base directive set. Output
|
|
601
|
+
# is byte-identical to Otto's historical policy when no overrides or
|
|
602
|
+
# reporting are configured.
|
|
532
603
|
#
|
|
533
604
|
# @param nonce [String] The nonce value to include in the CSP
|
|
534
605
|
# @param development_mode [Boolean] Whether to use development-friendly directives
|
|
@@ -538,7 +609,8 @@ class Otto
|
|
|
538
609
|
nonce,
|
|
539
610
|
development_mode: development_mode,
|
|
540
611
|
report_uri: @csp_report_uri,
|
|
541
|
-
report_to_url: @csp_report_to_url
|
|
612
|
+
report_to_url: @csp_report_to_url,
|
|
613
|
+
directive_overrides: @csp_directive_overrides
|
|
542
614
|
)
|
|
543
615
|
end
|
|
544
616
|
|
|
@@ -819,6 +891,29 @@ class Otto
|
|
|
819
891
|
MSG
|
|
820
892
|
end
|
|
821
893
|
|
|
894
|
+
# Warn once per config instance when a `script-src` override is stored for
|
|
895
|
+
# the nonce CSP policy. The per-request nonce is generated at response time
|
|
896
|
+
# and therefore cannot be present in a static override, so replacing (or
|
|
897
|
+
# removing) `script-src` necessarily strips the nonce from the emitted
|
|
898
|
+
# header — the browser then accepts any inline script the page carries the
|
|
899
|
+
# nonce attribute on, voiding nonce-based protection. Overriding any other
|
|
900
|
+
# directive (e.g. `worker-src`) is unaffected and stays silent.
|
|
901
|
+
#
|
|
902
|
+
# @param normalized [Hash] normalized (lowercased/hyphenated) overrides
|
|
903
|
+
# @return [void]
|
|
904
|
+
def warn_if_script_src_overridden(normalized)
|
|
905
|
+
return unless normalized.key?('script-src')
|
|
906
|
+
return if @csp_script_src_override_warned
|
|
907
|
+
|
|
908
|
+
@csp_script_src_override_warned = true
|
|
909
|
+
Otto.structured_log(:warn, <<~MSG.gsub(/\s+/, ' ').strip, directive: 'script-src')
|
|
910
|
+
[Otto::CSP] A script-src override was configured while nonce mode is in
|
|
911
|
+
use. The per-request nonce cannot be included in a static override, so
|
|
912
|
+
nonce-based script protection is disabled for this policy. Remove the
|
|
913
|
+
script-src override to keep nonce enforcement.
|
|
914
|
+
MSG
|
|
915
|
+
end
|
|
916
|
+
|
|
822
917
|
# Freeze-time backstop: refuse to finalize a production configuration that
|
|
823
918
|
# enables CSRF with a generated (non-configured) secret. Mirrors
|
|
824
919
|
# #validate_trusted_proxy_config! so the failure surfaces at boot, before
|