otto 2.1.0 → 2.3.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 +2 -2
- data/.github/workflows/claude-code-review.yml +6 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +6 -2
- data/{CLAUDE.md → AGENTS.md} +2 -2
- data/CHANGELOG.rst +176 -0
- data/Gemfile.lock +1 -3
- data/docs/migrating/v2.3.0.md +241 -0
- data/lib/otto/core/configuration.rb +4 -0
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/helpers/validation.rb +3 -5
- data/lib/otto/logging_helpers.rb +2 -2
- data/lib/otto/mcp/auth/token.rb +12 -1
- data/lib/otto/mcp/registry.rb +3 -1
- data/lib/otto/mcp/server.rb +2 -1
- data/lib/otto/request.rb +64 -64
- data/lib/otto/route.rb +3 -43
- data/lib/otto/route_handlers/base.rb +3 -14
- data/lib/otto/security/authentication/auth_strategy.rb +29 -2
- data/lib/otto/security/authentication/authorization_failure.rb +56 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +20 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +13 -1
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -3
- data/lib/otto/security/authentication.rb +2 -0
- data/lib/otto/security/config.rb +285 -32
- data/lib/otto/security/configurator.rb +15 -0
- data/lib/otto/security/constant_resolver.rb +73 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +3 -2
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +36 -52
- data/lib/otto/security/middleware/validation_middleware.rb +6 -15
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +170 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -1
- data/otto.gemspec +8 -1
- metadata +5 -22
data/lib/otto/mcp/registry.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require_relative '../security/constant_resolver'
|
|
6
|
+
|
|
5
7
|
class Otto
|
|
6
8
|
module MCP
|
|
7
9
|
# Registry for managing MCP resources and tools
|
|
@@ -82,7 +84,7 @@ class Otto
|
|
|
82
84
|
klass_name = klass_method[0..-2].join('::')
|
|
83
85
|
method_name = klass_method.last
|
|
84
86
|
|
|
85
|
-
klass =
|
|
87
|
+
klass = Otto::Security::ConstantResolver.safe_const_get(klass_name)
|
|
86
88
|
result = klass.public_send(method_name, arguments, env)
|
|
87
89
|
else
|
|
88
90
|
raise "Invalid tool handler: #{handler}"
|
data/lib/otto/mcp/server.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative 'route_parser'
|
|
|
8
8
|
require_relative 'auth/token'
|
|
9
9
|
require_relative 'schema_validation'
|
|
10
10
|
require_relative 'rate_limiting'
|
|
11
|
+
require_relative '../security/constant_resolver'
|
|
11
12
|
|
|
12
13
|
class Otto
|
|
13
14
|
module MCP
|
|
@@ -124,7 +125,7 @@ class Otto
|
|
|
124
125
|
|
|
125
126
|
# Create resource handler
|
|
126
127
|
handler = lambda do
|
|
127
|
-
klass =
|
|
128
|
+
klass = Otto::Security::ConstantResolver.safe_const_get(klass_name)
|
|
128
129
|
method = klass.method(method_name)
|
|
129
130
|
if method.arity != 0
|
|
130
131
|
raise ArgumentError, "Handler #{klass_name}.#{method_name} must be a zero-arity method for resource #{uri}"
|
data/lib/otto/request.rb
CHANGED
|
@@ -24,13 +24,25 @@ class Otto
|
|
|
24
24
|
env['HTTP_USER_AGENT']
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
#
|
|
27
|
+
# Canonical client IP for the request.
|
|
28
28
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
29
|
+
# Prefers env['otto.client_ip'] — the value resolved once, early, by
|
|
30
|
+
# IPPrivacyMiddleware ("resolve once, read everywhere"): the masked IP
|
|
31
|
+
# when privacy is enabled, or the resolved real IP when privacy is
|
|
32
|
+
# disabled or the address is exempt. This means downstream code no longer
|
|
33
|
+
# depends on REMOTE_ADDR / X-Forwarded-For rewriting being load-bearing.
|
|
33
34
|
#
|
|
35
|
+
# Falls back to Rack's native resolution when the middleware has not run
|
|
36
|
+
# (e.g. standalone request use without the Otto middleware stack).
|
|
37
|
+
#
|
|
38
|
+
# @return [String, nil] Canonical (privacy-applied) client IP
|
|
39
|
+
def ip
|
|
40
|
+
canonical = env['otto.client_ip']
|
|
41
|
+
return canonical if canonical && !canonical.empty?
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
34
46
|
# If you need the masked IP explicitly, use:
|
|
35
47
|
# req.masked_ip # => '192.168.1.0' or nil if privacy disabled
|
|
36
48
|
#
|
|
@@ -51,7 +63,7 @@ class Otto
|
|
|
51
63
|
# fingerprint.masked_ip # => '192.168.1.0'
|
|
52
64
|
# fingerprint.country # => 'US'
|
|
53
65
|
def redacted_fingerprint
|
|
54
|
-
env['otto.
|
|
66
|
+
env['otto.privacy.fingerprint']
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
# Get the geo-location country code for the request
|
|
@@ -63,7 +75,7 @@ class Otto
|
|
|
63
75
|
# @example
|
|
64
76
|
# req.geo_country # => 'US'
|
|
65
77
|
def geo_country
|
|
66
|
-
redacted_fingerprint&.country || env['otto.geo_country']
|
|
78
|
+
redacted_fingerprint&.country || env['otto.privacy.geo_country']
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
# Get anonymized user agent string
|
|
@@ -91,7 +103,7 @@ class Otto
|
|
|
91
103
|
# @example
|
|
92
104
|
# req.masked_ip # => '192.168.1.0'
|
|
93
105
|
def masked_ip
|
|
94
|
-
env['otto.masked_ip'] || env['REMOTE_ADDR']
|
|
106
|
+
env['otto.privacy.masked_ip'] || env['REMOTE_ADDR']
|
|
95
107
|
end
|
|
96
108
|
|
|
97
109
|
# Get hashed IP for session correlation
|
|
@@ -104,30 +116,19 @@ class Otto
|
|
|
104
116
|
# @example
|
|
105
117
|
# req.hashed_ip # => 'a3f8b2c4d5e6f7...'
|
|
106
118
|
def hashed_ip
|
|
107
|
-
redacted_fingerprint&.hashed_ip || env['otto.hashed_ip']
|
|
119
|
+
redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
|
|
108
120
|
end
|
|
109
121
|
|
|
110
122
|
def client_ipaddress
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
env['HTTP_CLIENT_IP'],
|
|
121
|
-
].compact.map { |header| header.split(/,\s*/) }.flatten
|
|
122
|
-
|
|
123
|
-
# Return the first valid IP that's not a private/loopback address
|
|
124
|
-
forwarded_ips.each do |ip|
|
|
125
|
-
clean_ip = validate_ip_address(ip.strip)
|
|
126
|
-
return clean_ip if clean_ip && !private_ip?(clean_ip)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Fallback to remote address
|
|
130
|
-
validate_ip_address(remote_addr)
|
|
123
|
+
# Prefer the canonical client IP resolved once by IPPrivacyMiddleware
|
|
124
|
+
# ("resolve once, read everywhere"). Falls back to the shared resolver
|
|
125
|
+
# (Otto::Utils.resolve_client_ip) for standalone use without the
|
|
126
|
+
# middleware, so the with- and without-middleware paths agree on which
|
|
127
|
+
# forwarded headers to trust and how to walk a proxy chain.
|
|
128
|
+
canonical = env['otto.client_ip']
|
|
129
|
+
return canonical if canonical && !canonical.empty?
|
|
130
|
+
|
|
131
|
+
Otto::Utils.resolve_client_ip(env, otto_security_config)
|
|
131
132
|
end
|
|
132
133
|
|
|
133
134
|
def request_method
|
|
@@ -180,16 +181,31 @@ class Otto
|
|
|
180
181
|
# Check direct HTTPS connection
|
|
181
182
|
return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'
|
|
182
183
|
|
|
183
|
-
|
|
184
|
+
# Only trust forwarded proto headers when the request actually arrived via
|
|
185
|
+
# a trusted proxy.
|
|
186
|
+
return false unless forwarded_by_trusted_proxy?
|
|
184
187
|
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# X-FORWARDED-PROTO is set by elastic load balancer
|
|
189
|
-
return env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
|
|
190
|
-
end
|
|
188
|
+
# X-Scheme is set by nginx; X-Forwarded-Proto by elastic load balancer
|
|
189
|
+
env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
|
|
190
|
+
end
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
# Whether the request arrived through a trusted proxy.
|
|
193
|
+
#
|
|
194
|
+
# Prefers the canonical decision recorded once by IPPrivacyMiddleware in
|
|
195
|
+
# env['otto.via_trusted_proxy'] — evaluated against the original peer before
|
|
196
|
+
# REMOTE_ADDR is masked, so it stays correct even after masking. Falls back
|
|
197
|
+
# to evaluating the current REMOTE_ADDR when the middleware has not run
|
|
198
|
+
# (standalone request use).
|
|
199
|
+
#
|
|
200
|
+
# This is the trusted-proxy *identity* check only and is independent of
|
|
201
|
+
# count-based depth mode: depth resolves the client IP but never grants
|
|
202
|
+
# proxy trust for X-Forwarded-Proto.
|
|
203
|
+
#
|
|
204
|
+
# @return [Boolean]
|
|
205
|
+
def forwarded_by_trusted_proxy?
|
|
206
|
+
return env['otto.via_trusted_proxy'] if env.key?('otto.via_trusted_proxy')
|
|
207
|
+
|
|
208
|
+
otto_security_config ? trusted_proxy?(env['REMOTE_ADDR']) : false
|
|
193
209
|
end
|
|
194
210
|
|
|
195
211
|
# See: http://stackoverflow.com/questions/10013812/how-to-prevent-jquery-ajax-from-following-a-redirect-after-a-post
|
|
@@ -227,41 +243,25 @@ class Otto
|
|
|
227
243
|
end
|
|
228
244
|
|
|
229
245
|
def validate_ip_address(ip)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
# Remove any port number
|
|
233
|
-
clean_ip = ip.split(':').first
|
|
234
|
-
|
|
235
|
-
# Basic IP format validation
|
|
236
|
-
return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
|
237
|
-
|
|
238
|
-
# Validate each octet
|
|
239
|
-
octets = clean_ip.split('.')
|
|
240
|
-
return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
|
|
241
|
-
|
|
242
|
-
clean_ip
|
|
246
|
+
Otto::Utils.normalize_ip(ip)
|
|
243
247
|
end
|
|
244
248
|
|
|
249
|
+
# Whether the given address is non-public (private, loopback, link-local,
|
|
250
|
+
# multicast or unspecified). IPv4 and IPv6 aware via Otto::Utils.private_ip?
|
|
251
|
+
# — the previous implementation was an IPv4-only regex that silently
|
|
252
|
+
# treated every IPv6 address (including ::1 and ULA fc00::/7) as public.
|
|
253
|
+
#
|
|
254
|
+
# @param ip [String, IPAddr, nil] address to classify
|
|
255
|
+
# @return [Boolean]
|
|
245
256
|
def private_ip?(ip)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
# RFC 1918 private ranges and loopback
|
|
249
|
-
private_ranges = [
|
|
250
|
-
/\A10\./, # 10.0.0.0/8
|
|
251
|
-
/\A172\.(1[6-9]|2[0-9]|3[01])\./, # 172.16.0.0/12
|
|
252
|
-
/\A192\.168\./, # 192.168.0.0/16
|
|
253
|
-
/\A169\.254\./, # 169.254.0.0/16 (link-local)
|
|
254
|
-
/\A224\./, # 224.0.0.0/4 (multicast)
|
|
255
|
-
/\A0\./, # 0.0.0.0/8
|
|
256
|
-
]
|
|
257
|
-
|
|
258
|
-
private_ranges.any? { |range| ip.match?(range) }
|
|
257
|
+
Otto::Utils.private_ip?(ip)
|
|
259
258
|
end
|
|
260
259
|
|
|
261
260
|
def local_or_private_ip?(ip)
|
|
262
261
|
return false unless ip
|
|
263
262
|
|
|
264
|
-
#
|
|
263
|
+
# Fast path for the common localhost cases (avoids IPAddr allocation);
|
|
264
|
+
# private_ip? would also catch these via IPAddr#loopback?.
|
|
265
265
|
return true if ['127.0.0.1', '::1'].include?(ip)
|
|
266
266
|
|
|
267
267
|
# Check for private IP ranges
|
data/lib/otto/route.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require_relative 'security/constant_resolver'
|
|
6
|
+
|
|
5
7
|
class Otto
|
|
6
8
|
# Otto::Route
|
|
7
9
|
#
|
|
@@ -51,7 +53,7 @@ class Otto
|
|
|
51
53
|
@route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
|
|
52
54
|
|
|
53
55
|
# Resolve the class
|
|
54
|
-
@klass = safe_const_get(@route_definition.klass_name)
|
|
56
|
+
@klass = Otto::Security::ConstantResolver.safe_const_get(@route_definition.klass_name)
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
# Delegate common methods to route_definition for backward compatibility
|
|
@@ -170,48 +172,6 @@ class Otto
|
|
|
170
172
|
|
|
171
173
|
private
|
|
172
174
|
|
|
173
|
-
# Safely resolve a class name using Object.const_get with security validations
|
|
174
|
-
# This replaces the previous eval() usage to prevent code injection attacks.
|
|
175
|
-
#
|
|
176
|
-
# Security features:
|
|
177
|
-
# - Validates class name format (must start with capital letter)
|
|
178
|
-
# - Prevents access to dangerous system classes
|
|
179
|
-
# - Blocks relative class references (starting with ::)
|
|
180
|
-
# - Provides clear error messages for debugging
|
|
181
|
-
#
|
|
182
|
-
# @param class_name [String] The class name to resolve
|
|
183
|
-
# @return [Class] The resolved class
|
|
184
|
-
# @raise [ArgumentError] if class name is invalid, forbidden, or not found
|
|
185
|
-
def safe_const_get(class_name)
|
|
186
|
-
# Validate class name format
|
|
187
|
-
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
|
188
|
-
raise ArgumentError, "Invalid class name format: #{class_name}"
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Remove any leading :: then add exactly one
|
|
192
|
-
fq_class_name = "::#{class_name.sub(/^::+/, '')}"
|
|
193
|
-
|
|
194
|
-
# Prevent dangerous class names
|
|
195
|
-
forbidden_classes = %w[
|
|
196
|
-
Kernel Module Class Object BasicObject
|
|
197
|
-
File Dir IO Process System
|
|
198
|
-
Binding Proc Method UnboundMethod
|
|
199
|
-
Thread ThreadGroup Fiber
|
|
200
|
-
ObjectSpace GC
|
|
201
|
-
]
|
|
202
|
-
|
|
203
|
-
if forbidden_classes.include?(class_name) || class_name.start_with?('::')
|
|
204
|
-
raise ArgumentError, "Forbidden class name: #{class_name}"
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
begin
|
|
208
|
-
# Always guarantee exactly two leading colons
|
|
209
|
-
Object.const_get(fq_class_name)
|
|
210
|
-
rescue NameError => e
|
|
211
|
-
raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
|
|
215
175
|
def compile(path)
|
|
216
176
|
keys = []
|
|
217
177
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'securerandom'
|
|
7
7
|
|
|
8
|
+
require_relative '../security/constant_resolver'
|
|
9
|
+
|
|
8
10
|
class Otto
|
|
9
11
|
module RouteHandlers
|
|
10
12
|
# Base class for all route handlers
|
|
@@ -43,7 +45,7 @@ class Otto
|
|
|
43
45
|
# Get the target class, loading it safely
|
|
44
46
|
# @return [Class] The target class
|
|
45
47
|
def target_class
|
|
46
|
-
@target_class ||= safe_const_get(route_definition.klass_name)
|
|
48
|
+
@target_class ||= Otto::Security::ConstantResolver.safe_const_get(route_definition.klass_name)
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
# Template method for subclasses to implement their invocation logic
|
|
@@ -187,19 +189,6 @@ class Otto
|
|
|
187
189
|
|
|
188
190
|
handler_class.handle(result, response, context)
|
|
189
191
|
end
|
|
190
|
-
|
|
191
|
-
private
|
|
192
|
-
|
|
193
|
-
# Safely get a constant from a string name
|
|
194
|
-
# @param name [String] Class name
|
|
195
|
-
# @return [Class] The class
|
|
196
|
-
def safe_const_get(name)
|
|
197
|
-
name.split('::').inject(Object) do |scope, const_name|
|
|
198
|
-
scope.const_get(const_name)
|
|
199
|
-
end
|
|
200
|
-
rescue NameError => e
|
|
201
|
-
raise NameError, "Unknown class: #{name} (#{e})"
|
|
202
|
-
end
|
|
203
192
|
end
|
|
204
193
|
end
|
|
205
194
|
end
|
|
@@ -31,20 +31,47 @@ class Otto
|
|
|
31
31
|
Otto::Security::Authentication::StrategyResult.new(
|
|
32
32
|
session: session,
|
|
33
33
|
user: user,
|
|
34
|
-
auth_method: auth_method ||
|
|
34
|
+
auth_method: auth_method || strategy_auth_method,
|
|
35
35
|
metadata: metadata,
|
|
36
36
|
strategy_name: nil # Will be set by RouteAuthWrapper
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# Helper for authentication failure - return AuthFailure
|
|
41
|
+
#
|
|
42
|
+
# Use for a missing, invalid, or expired credential. RouteAuthWrapper maps
|
|
43
|
+
# this to 401 Unauthorized. For a VALID credential that is not permitted,
|
|
44
|
+
# use #authorization_failure instead (403 Forbidden).
|
|
41
45
|
def failure(reason = nil)
|
|
42
46
|
Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
|
|
43
47
|
Otto::Security::Authentication::AuthFailure.new(
|
|
44
48
|
failure_reason: reason || 'Authentication failed',
|
|
45
|
-
auth_method:
|
|
49
|
+
auth_method: strategy_auth_method
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Helper for authorization failure - return AuthorizationFailure
|
|
54
|
+
#
|
|
55
|
+
# Use when the credential is valid but the authenticated subject is not
|
|
56
|
+
# permitted (wrong role, missing permission). RouteAuthWrapper maps this to
|
|
57
|
+
# 403 Forbidden, letting clients distinguish "authenticate again" (401) from
|
|
58
|
+
# "you lack this permission" (403). See AuthorizationFailure.
|
|
59
|
+
def authorization_failure(reason = nil)
|
|
60
|
+
Otto.logger.debug "[#{self.class}] Authorization denied: #{reason}" if reason
|
|
61
|
+
Otto::Security::Authentication::AuthorizationFailure.new(
|
|
62
|
+
failure_reason: reason || 'Authorization denied',
|
|
63
|
+
auth_method: strategy_auth_method
|
|
46
64
|
)
|
|
47
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Short auth_method label from the strategy class name. Anonymous strategy
|
|
70
|
+
# classes (Class.new(...), common in tests) have a nil #name, so fall back
|
|
71
|
+
# to a generic label rather than raising on nil#split.
|
|
72
|
+
def strategy_auth_method
|
|
73
|
+
(self.class.name || 'AuthStrategy').split('::').last
|
|
74
|
+
end
|
|
48
75
|
end
|
|
49
76
|
end
|
|
50
77
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# lib/otto/security/authentication/authorization_failure.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
# Result for AUTHORIZATION failures (authenticated, but not permitted).
|
|
9
|
+
#
|
|
10
|
+
# This is distinct from AuthFailure, which represents an AUTHENTICATION
|
|
11
|
+
# failure (no/invalid/expired credential). A strategy that performs both
|
|
12
|
+
# authentication and authorization in one pass (e.g. a token strategy that
|
|
13
|
+
# also enforces a role/permission encoded in the route requirement) returns:
|
|
14
|
+
#
|
|
15
|
+
# * AuthFailure -> credential missing/invalid -> 401 Unauthorized
|
|
16
|
+
# * AuthorizationFailure -> credential valid, but denied -> 403 Forbidden
|
|
17
|
+
#
|
|
18
|
+
# Without this type a combined strategy could only return AuthFailure, and
|
|
19
|
+
# RouteAuthWrapper would collapse an authorization denial to 401 — leaving a
|
|
20
|
+
# client unable to distinguish "authenticate again" from "you lack this
|
|
21
|
+
# permission." The wrapper maps this type to ResponseBuilder#forbidden (403);
|
|
22
|
+
# see RouteAuthWrapper#handle_all_strategies_failed.
|
|
23
|
+
#
|
|
24
|
+
# NOTE: Otto's built-in Layer-1 role check (RoleAuthorization, driven by the
|
|
25
|
+
# `role=` route token) already yields 403 for role mismatches on a successful
|
|
26
|
+
# StrategyResult. This type covers the complementary case: a strategy that
|
|
27
|
+
# owns authorization itself (including permission tiers, which Layer-1 does
|
|
28
|
+
# not model) and needs to signal a 403 directly.
|
|
29
|
+
AuthorizationFailure = Data.define(:failure_reason, :auth_method) do
|
|
30
|
+
# Authorization failures are not an authenticated request state. The
|
|
31
|
+
# request never reaches the handler, so handler-facing predicates report
|
|
32
|
+
# the same "no user context" shape AuthFailure does.
|
|
33
|
+
#
|
|
34
|
+
# @return [Boolean] False
|
|
35
|
+
def authenticated?
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] True (no user context attached to a denial)
|
|
40
|
+
def anonymous?
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Hash] Empty hash
|
|
45
|
+
def user_context
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String] Debug representation
|
|
50
|
+
def inspect
|
|
51
|
+
"#<AuthorizationFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -106,11 +106,18 @@ class Otto
|
|
|
106
106
|
duration, total_start_time, failed_strategies)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
# Handle
|
|
110
|
-
next
|
|
109
|
+
# Handle a failure (authentication OR authorization) - record it and
|
|
110
|
+
# continue to the next strategy (OR logic; a later success still wins).
|
|
111
|
+
# AuthorizationFailure (valid credential, denied) is tagged so the
|
|
112
|
+
# final response is 403 instead of 401. See handle_all_strategies_failed.
|
|
113
|
+
next unless result.is_a?(AuthFailure) || result.is_a?(AuthorizationFailure)
|
|
111
114
|
|
|
112
115
|
log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
|
|
113
|
-
failed_strategies << {
|
|
116
|
+
failed_strategies << {
|
|
117
|
+
strategy: strategy_name,
|
|
118
|
+
reason: result.failure_reason,
|
|
119
|
+
authorization: result.is_a?(AuthorizationFailure),
|
|
120
|
+
}
|
|
114
121
|
end
|
|
115
122
|
|
|
116
123
|
# All strategies failed
|
|
@@ -156,6 +163,14 @@ class Otto
|
|
|
156
163
|
strategy_name: failure_strategy_name
|
|
157
164
|
)
|
|
158
165
|
|
|
166
|
+
# Authorization denial wins over authentication failure: if any strategy
|
|
167
|
+
# authenticated the subject but denied authorization (wrong role/missing
|
|
168
|
+
# permission), respond 403 Forbidden rather than 401 — the subject IS
|
|
169
|
+
# authenticated, they simply lack access. A bare 401 would (incorrectly)
|
|
170
|
+
# tell a logged-in client to re-authenticate.
|
|
171
|
+
authz_denial = failed_strategies.find { |f| f[:authorization] }
|
|
172
|
+
return @response_builder.forbidden(env, authz_denial[:reason]) if authz_denial
|
|
173
|
+
|
|
159
174
|
last_failure = if failed_strategies.any?
|
|
160
175
|
AuthFailure.new(
|
|
161
176
|
failure_reason: failed_strategies.last[:reason],
|
|
@@ -173,7 +188,7 @@ class Otto
|
|
|
173
188
|
|
|
174
189
|
# Build metadata for anonymous routes
|
|
175
190
|
def build_anonymous_metadata(env)
|
|
176
|
-
metadata = { ip: env['REMOTE_ADDR'] }
|
|
191
|
+
metadata = { ip: env['otto.client_ip'] || env['REMOTE_ADDR'] }
|
|
177
192
|
metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
|
|
178
193
|
metadata
|
|
179
194
|
end
|
|
@@ -181,7 +196,7 @@ class Otto
|
|
|
181
196
|
# Build metadata for failed authentication
|
|
182
197
|
def build_failure_metadata(env, failed_strategies)
|
|
183
198
|
metadata = {
|
|
184
|
-
ip: env['REMOTE_ADDR'],
|
|
199
|
+
ip: env['otto.client_ip'] || env['REMOTE_ADDR'],
|
|
185
200
|
auth_failure: 'All authentication strategies failed',
|
|
186
201
|
attempted_strategies: failed_strategies.map { |f| f[:strategy] },
|
|
187
202
|
failure_reasons: failed_strategies.map { |f| f[:reason] },
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative '../auth_strategy'
|
|
6
|
+
require 'rack/utils'
|
|
6
7
|
|
|
7
8
|
class Otto
|
|
8
9
|
module Security
|
|
@@ -27,7 +28,7 @@ class Otto
|
|
|
27
28
|
|
|
28
29
|
return failure('No API key provided') unless api_key
|
|
29
30
|
|
|
30
|
-
if @api_keys.empty? ||
|
|
31
|
+
if @api_keys.empty? || valid_api_key?(api_key)
|
|
31
32
|
# Create a simple user hash for API key authentication
|
|
32
33
|
user_data = { api_key: api_key }
|
|
33
34
|
success(user: user_data, api_key: api_key)
|
|
@@ -35,6 +36,17 @@ class Otto
|
|
|
35
36
|
failure('Invalid API key')
|
|
36
37
|
end
|
|
37
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Constant-time membership check over the configured API keys. Compares
|
|
43
|
+
# against every key without short-circuiting so match position/membership is
|
|
44
|
+
# not leaked via timing.
|
|
45
|
+
def valid_api_key?(api_key)
|
|
46
|
+
@api_keys.reduce(false) do |matched, key|
|
|
47
|
+
Rack::Utils.secure_compare(key, api_key) || matched
|
|
48
|
+
end
|
|
49
|
+
end
|
|
38
50
|
end
|
|
39
51
|
end
|
|
40
52
|
end
|
|
@@ -12,9 +12,11 @@ class Otto
|
|
|
12
12
|
# Public access strategy - always allows access
|
|
13
13
|
class NoAuthStrategy < AuthStrategy
|
|
14
14
|
def authenticate(env, _requirement)
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
# Canonical client IP ("resolve once, read everywhere"): masked by
|
|
16
|
+
# IPPrivacyMiddleware when privacy is on; REMOTE_ADDR fallback when
|
|
17
|
+
# the middleware has not run.
|
|
18
|
+
metadata = { ip: env['otto.client_ip'] || env['REMOTE_ADDR'] }
|
|
19
|
+
metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
|
|
18
20
|
|
|
19
21
|
Otto::Security::Authentication::StrategyResult.anonymous(metadata: metadata)
|
|
20
22
|
end
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
require_relative 'authentication/auth_strategy'
|
|
9
9
|
require_relative 'authentication/strategy_result'
|
|
10
10
|
require_relative 'authentication/auth_failure'
|
|
11
|
+
require_relative 'authentication/authorization_failure'
|
|
11
12
|
require_relative 'authentication/route_auth_wrapper'
|
|
12
13
|
|
|
13
14
|
# Load all strategies
|
|
@@ -31,4 +32,5 @@ class Otto
|
|
|
31
32
|
# Top-level backward compatibility aliases
|
|
32
33
|
StrategyResult = Security::Authentication::StrategyResult
|
|
33
34
|
AuthFailure = Security::Authentication::AuthFailure
|
|
35
|
+
AuthorizationFailure = Security::Authentication::AuthorizationFailure
|
|
34
36
|
end
|