otto 2.2.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 +1 -1
- data/CHANGELOG.rst +164 -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/route_auth_wrapper.rb +2 -2
- 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/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 +3 -21
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
|
|
@@ -188,7 +188,7 @@ class Otto
|
|
|
188
188
|
|
|
189
189
|
# Build metadata for anonymous routes
|
|
190
190
|
def build_anonymous_metadata(env)
|
|
191
|
-
metadata = { ip: env['REMOTE_ADDR'] }
|
|
191
|
+
metadata = { ip: env['otto.client_ip'] || env['REMOTE_ADDR'] }
|
|
192
192
|
metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
|
|
193
193
|
metadata
|
|
194
194
|
end
|
|
@@ -196,7 +196,7 @@ class Otto
|
|
|
196
196
|
# Build metadata for failed authentication
|
|
197
197
|
def build_failure_metadata(env, failed_strategies)
|
|
198
198
|
metadata = {
|
|
199
|
-
ip: env['REMOTE_ADDR'],
|
|
199
|
+
ip: env['otto.client_ip'] || env['REMOTE_ADDR'],
|
|
200
200
|
auth_failure: 'All authentication strategies failed',
|
|
201
201
|
attempted_strategies: failed_strategies.map { |f| f[:strategy] },
|
|
202
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
|