otto 2.5.0 → 2.7.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/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +283 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +14 -12
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +172 -0
- data/docs/reverse-proxy-network-services.md +19 -6
- 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/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +43 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +281 -3
- data/lib/otto/privacy/core.rb +104 -8
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +4 -1
- data/lib/otto/request.rb +35 -1
- 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/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +123 -6
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/policy.rb +135 -3
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- 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 +232 -15
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +37 -5
- metadata +13 -6
data/lib/otto/request.rb
CHANGED
|
@@ -133,6 +133,33 @@ class Otto
|
|
|
133
133
|
redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
|
|
134
134
|
end
|
|
135
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
|
+
|
|
136
163
|
def client_ipaddress
|
|
137
164
|
# Prefer the canonical client IP resolved once by IPPrivacyMiddleware
|
|
138
165
|
# ("resolve once, read everywhere"). Falls back to the shared resolver
|
|
@@ -195,8 +222,15 @@ class Otto
|
|
|
195
222
|
# Check direct HTTPS connection
|
|
196
223
|
return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'
|
|
197
224
|
|
|
225
|
+
# rack.url_scheme is server-/middleware-set (never a client header), so a
|
|
226
|
+
# scheme normalized upstream the canonical Rack way counts as authoritative
|
|
227
|
+
# — keeping this answer aligned with Rack::Request#scheme, which the
|
|
228
|
+
# session Secure-cookie gate and CSRF middleware read.
|
|
229
|
+
return true if env['rack.url_scheme'] == 'https'
|
|
230
|
+
|
|
198
231
|
# Only trust forwarded proto headers when the request actually arrived via
|
|
199
|
-
# a trusted proxy.
|
|
232
|
+
# a trusted proxy. Stricter than Rack::Request#scheme, which honors
|
|
233
|
+
# X-Forwarded-Proto unconditionally.
|
|
200
234
|
return false unless forwarded_by_trusted_proxy?
|
|
201
235
|
|
|
202
236
|
# X-Scheme is set by nginx; X-Forwarded-Proto by elastic load balancer
|
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
|
|
@@ -6,10 +6,44 @@ class Otto
|
|
|
6
6
|
module Security
|
|
7
7
|
module Authentication
|
|
8
8
|
# Failure result for authentication failures
|
|
9
|
-
AuthFailure = Data.define(:failure_reason, :auth_method) do
|
|
9
|
+
AuthFailure = Data.define(:failure_reason, :auth_method, :terminal) do
|
|
10
10
|
# AuthFailure represents authentication failure
|
|
11
11
|
# Returned by strategies when authentication fails
|
|
12
12
|
# Contains failure reason for error messages
|
|
13
|
+
#
|
|
14
|
+
# TERMINAL FAILURES
|
|
15
|
+
# -----------------
|
|
16
|
+
# By default a failure is non-terminal: RouteAuthWrapper records it and
|
|
17
|
+
# consults the next strategy in the route's chain (OR logic), so a
|
|
18
|
+
# request without credentials can still fall through to an
|
|
19
|
+
# anonymous-capable strategy like noauth.
|
|
20
|
+
#
|
|
21
|
+
# A failure constructed with `terminal: true` means "this request
|
|
22
|
+
# explicitly presented credentials and they were examined and
|
|
23
|
+
# rejected — do not consult further strategies." RouteAuthWrapper
|
|
24
|
+
# halts the chain and renders the 401 with this failure's reason,
|
|
25
|
+
# regardless of where the strategy sits in the chain. This lets mixed
|
|
26
|
+
# credentialed/anonymous chains (e.g. auth=basicauth,noauth) fail
|
|
27
|
+
# closed on invalid credentials instead of silently degrading to
|
|
28
|
+
# anonymous.
|
|
29
|
+
#
|
|
30
|
+
# Only reject terminally when credentials were EXPLICITLY presented
|
|
31
|
+
# (e.g. an Authorization header). Ambient credentials such as session
|
|
32
|
+
# cookies should fail non-terminally so a logged-out browser can still
|
|
33
|
+
# degrade to anonymous on noauth-capable routes.
|
|
34
|
+
|
|
35
|
+
# terminal defaults to false so existing keyword construction
|
|
36
|
+
# (failure_reason:, auth_method:) is unaffected.
|
|
37
|
+
def initialize(failure_reason:, auth_method:, terminal: false)
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if this failure halts the strategy chain
|
|
42
|
+
#
|
|
43
|
+
# @return [Boolean] True when the chain must fail closed
|
|
44
|
+
def terminal?
|
|
45
|
+
terminal
|
|
46
|
+
end
|
|
13
47
|
|
|
14
48
|
# Check if authenticated - always false for failures
|
|
15
49
|
#
|
|
@@ -36,7 +70,7 @@ class Otto
|
|
|
36
70
|
#
|
|
37
71
|
# @return [String] Debug representation
|
|
38
72
|
def inspect
|
|
39
|
-
"#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
|
|
73
|
+
"#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}#{' terminal' if terminal}>"
|
|
40
74
|
end
|
|
41
75
|
end
|
|
42
76
|
end
|
|
@@ -42,11 +42,21 @@ class Otto
|
|
|
42
42
|
# Use for a missing, invalid, or expired credential. RouteAuthWrapper maps
|
|
43
43
|
# this to 401 Unauthorized. For a VALID credential that is not permitted,
|
|
44
44
|
# use #authorization_failure instead (403 Forbidden).
|
|
45
|
-
|
|
45
|
+
#
|
|
46
|
+
# Pass terminal: true when the request EXPLICITLY presented credentials
|
|
47
|
+
# (e.g. an Authorization header) that were examined and rejected.
|
|
48
|
+
# RouteAuthWrapper then halts the strategy chain and fails closed with
|
|
49
|
+
# 401 instead of consulting further strategies — so an anonymous-capable
|
|
50
|
+
# strategy later (or earlier) in the chain cannot silently accept the
|
|
51
|
+
# request as anonymous. Leave it false for missing credentials and for
|
|
52
|
+
# ambient credentials (session cookies), which should keep today's OR
|
|
53
|
+
# fallthrough. See AuthFailure.
|
|
54
|
+
def failure(reason = nil, terminal: false)
|
|
46
55
|
Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
|
|
47
56
|
Otto::Security::Authentication::AuthFailure.new(
|
|
48
57
|
failure_reason: reason || 'Authentication failed',
|
|
49
|
-
auth_method: strategy_auth_method
|
|
58
|
+
auth_method: strategy_auth_method,
|
|
59
|
+
terminal: terminal
|
|
50
60
|
)
|
|
51
61
|
end
|
|
52
62
|
|
|
@@ -26,6 +26,13 @@ class Otto
|
|
|
26
26
|
# StrategyResult. This type covers the complementary case: a strategy that
|
|
27
27
|
# owns authorization itself (including permission tiers, which Layer-1 does
|
|
28
28
|
# not model) and needs to signal a 403 directly.
|
|
29
|
+
#
|
|
30
|
+
# DELIBERATELY has no `terminal` member (unlike AuthFailure): an
|
|
31
|
+
# authorization denial must not halt the strategy chain — a later
|
|
32
|
+
# strategy's success still wins (a different credential may well be
|
|
33
|
+
# permitted). When the chain DOES end in failure, a recorded denial
|
|
34
|
+
# already takes response precedence (403 over 401, even over a terminal
|
|
35
|
+
# halt), so there is nothing a terminal flag here would add.
|
|
29
36
|
AuthorizationFailure = Data.define(:failure_reason, :auth_method) do
|
|
30
37
|
# Authorization failures are not an authenticated request state. The
|
|
31
38
|
# request never reaches the handler, so handler-facing predicates report
|