otto 2.0.0.pre1 → 2.0.0.pre3
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 -3
- data/.github/workflows/claude-code-review.yml +30 -14
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.rst +54 -6
- data/CLAUDE.md +537 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +34 -26
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
- data/docs/.gitignore +2 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +338 -0
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +91 -41
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +103 -16
- data/lib/otto/core/router.rb +8 -7
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +118 -0
- data/lib/otto/helpers/base.rb +2 -21
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +25 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
- data/lib/otto/mcp/server.rb +26 -13
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/response_handlers/json.rb +6 -0
- data/lib/otto/route.rb +44 -48
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +24 -9
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +44 -0
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
- data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
- data/lib/otto/security/authentication/strategy_result.rb +129 -15
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/config.rb +51 -18
- data/lib/otto/security/configurator.rb +2 -15
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +183 -89
- data/otto.gemspec +5 -0
- metadata +83 -8
- data/changelog.d/20250911_235619_delano_next.rst +0 -28
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
- data/lib/otto/security/authentication/failure_result.rb +0 -36
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Otto
|
|
4
|
+
module Security
|
|
5
|
+
module Middleware
|
|
6
|
+
# IP Privacy Middleware
|
|
7
|
+
#
|
|
8
|
+
# Automatically masks IP addresses for privacy by default. Original IPs
|
|
9
|
+
# are never stored unless privacy is explicitly disabled.
|
|
10
|
+
#
|
|
11
|
+
# This middleware runs FIRST in the stack to ensure all downstream
|
|
12
|
+
# middleware and application code receives masked IPs by default.
|
|
13
|
+
#
|
|
14
|
+
# @example Default behavior (privacy enabled)
|
|
15
|
+
# # env['REMOTE_ADDR'] is masked to 192.168.1.0
|
|
16
|
+
# # env['otto.redacted_fingerprint'] contains full anonymized data
|
|
17
|
+
# # env['otto.original_ip'] is NOT set
|
|
18
|
+
#
|
|
19
|
+
# @example Privacy disabled
|
|
20
|
+
# otto.disable_ip_privacy!
|
|
21
|
+
# # env['REMOTE_ADDR'] contains real IP
|
|
22
|
+
# # env['otto.original_ip'] also contains real IP
|
|
23
|
+
#
|
|
24
|
+
class IPPrivacyMiddleware
|
|
25
|
+
# Initialize IP Privacy middleware
|
|
26
|
+
#
|
|
27
|
+
# @param app [#call] Rack application
|
|
28
|
+
# @param security_config [Otto::Security::Config] Security configuration
|
|
29
|
+
def initialize(app, security_config = nil)
|
|
30
|
+
@app = app
|
|
31
|
+
@security_config = security_config
|
|
32
|
+
@config = security_config&.ip_privacy_config || Otto::Privacy::Config.new
|
|
33
|
+
|
|
34
|
+
# Privacy is enabled by default unless explicitly disabled
|
|
35
|
+
@privacy_enabled = @config.enabled?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Process request with IP privacy
|
|
39
|
+
#
|
|
40
|
+
# @param env [Hash] Rack environment
|
|
41
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
42
|
+
def call(env)
|
|
43
|
+
if @privacy_enabled
|
|
44
|
+
apply_privacy(env)
|
|
45
|
+
else
|
|
46
|
+
apply_no_privacy(env)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@app.call(env)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Apply privacy settings to environment
|
|
55
|
+
#
|
|
56
|
+
# @param env [Hash] Rack environment
|
|
57
|
+
# Apply privacy settings to environment
|
|
58
|
+
#
|
|
59
|
+
# @param env [Hash] Rack environment
|
|
60
|
+
# Apply privacy settings to environment
|
|
61
|
+
#
|
|
62
|
+
# @param env [Hash] Rack environment
|
|
63
|
+
def apply_privacy(env)
|
|
64
|
+
# Resolve the actual client IP (handling proxies)
|
|
65
|
+
client_ip = resolve_client_ip(env)
|
|
66
|
+
|
|
67
|
+
Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
|
|
68
|
+
|
|
69
|
+
# Skip masking for private/localhost IPs unless explicitly configured to mask them
|
|
70
|
+
# This provides better DX for development while still protecting public IPs
|
|
71
|
+
unless @config.mask_private_ips
|
|
72
|
+
if Otto::Privacy::IPPrivacy.private_or_localhost?(client_ip)
|
|
73
|
+
# Update REMOTE_ADDR to the resolved client IP (even though it's not masked)
|
|
74
|
+
env['REMOTE_ADDR'] = client_ip
|
|
75
|
+
env['otto.original_ip'] = client_ip
|
|
76
|
+
# Don't mask forwarded headers for private IPs
|
|
77
|
+
Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Create privacy-safe fingerprint using the resolved client IP
|
|
83
|
+
# We temporarily set REMOTE_ADDR to the client IP for fingerprint creation
|
|
84
|
+
original_remote_addr = env['REMOTE_ADDR']
|
|
85
|
+
env['REMOTE_ADDR'] = client_ip
|
|
86
|
+
fingerprint = Otto::Privacy::RedactedFingerprint.new(env, @config)
|
|
87
|
+
env['REMOTE_ADDR'] = original_remote_addr
|
|
88
|
+
|
|
89
|
+
# Set privacy-safe values in environment
|
|
90
|
+
env['otto.redacted_fingerprint'] = fingerprint
|
|
91
|
+
env['otto.masked_ip'] = fingerprint.masked_ip
|
|
92
|
+
env['otto.hashed_ip'] = fingerprint.hashed_ip
|
|
93
|
+
env['otto.geo_country'] = fingerprint.country
|
|
94
|
+
|
|
95
|
+
# CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked IP
|
|
96
|
+
# This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
|
|
97
|
+
# automatically uses the masked IP without modification
|
|
98
|
+
env['REMOTE_ADDR'] = fingerprint.masked_ip
|
|
99
|
+
|
|
100
|
+
# Mask X-Forwarded-For headers to prevent leakage
|
|
101
|
+
# Replace with masked IP so proxy resolution logic finds the masked IP
|
|
102
|
+
mask_forwarded_headers(env, fingerprint.masked_ip)
|
|
103
|
+
|
|
104
|
+
Otto.logger.debug "[IPPrivacyMiddleware] Masked IP: #{fingerprint.masked_ip}" if Otto.debug
|
|
105
|
+
|
|
106
|
+
# NOTE: We deliberately DO NOT set env['otto.original_ip']
|
|
107
|
+
# This prevents accidental leakage of the real IP address
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Resolve the actual client IP address from the request
|
|
112
|
+
#
|
|
113
|
+
# This method handles proxy scenarios by checking X-Forwarded-For and
|
|
114
|
+
# other proxy headers from trusted proxies, similar to Rack's logic
|
|
115
|
+
# and Otto's client_ipaddress method.
|
|
116
|
+
#
|
|
117
|
+
# @param env [Hash] Rack environment
|
|
118
|
+
# @return [String] Resolved client IP address
|
|
119
|
+
def resolve_client_ip(env)
|
|
120
|
+
remote_addr = env['REMOTE_ADDR']
|
|
121
|
+
|
|
122
|
+
# If we don't have a security config, use direct connection
|
|
123
|
+
return remote_addr unless @security_config
|
|
124
|
+
|
|
125
|
+
# If REMOTE_ADDR is not from a trusted proxy, it's the client IP
|
|
126
|
+
return remote_addr unless trusted_proxy?(remote_addr)
|
|
127
|
+
|
|
128
|
+
# REMOTE_ADDR is from a trusted proxy, check forwarded headers
|
|
129
|
+
forwarded_ips = [
|
|
130
|
+
env['HTTP_X_FORWARDED_FOR'],
|
|
131
|
+
env['HTTP_X_REAL_IP'],
|
|
132
|
+
env['HTTP_X_CLIENT_IP'],
|
|
133
|
+
].compact.map { |header| header.split(/,\s*/) }.flatten
|
|
134
|
+
|
|
135
|
+
# Return the first valid public IP from forwarded headers
|
|
136
|
+
forwarded_ips.each do |ip|
|
|
137
|
+
clean_ip = validate_ip_address(ip.strip)
|
|
138
|
+
next unless clean_ip
|
|
139
|
+
|
|
140
|
+
# Return first IP that's not from a trusted proxy
|
|
141
|
+
return clean_ip unless trusted_proxy?(clean_ip)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Fallback to remote address if no valid forwarded IPs
|
|
145
|
+
remote_addr
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Mask X-Forwarded-For and related proxy headers
|
|
149
|
+
#
|
|
150
|
+
# Replaces forwarded IP headers with the masked IP to prevent leakage
|
|
151
|
+
# when downstream code (including Rack's request.ip) parses these headers.
|
|
152
|
+
#
|
|
153
|
+
# @param env [Hash] Rack environment
|
|
154
|
+
# @param masked_ip [String] The masked IP to use as replacement
|
|
155
|
+
def mask_forwarded_headers(env, masked_ip)
|
|
156
|
+
# Replace X-Forwarded-For with masked IP
|
|
157
|
+
# This prevents Rack::Request#ip from finding the real IP
|
|
158
|
+
env['HTTP_X_FORWARDED_FOR'] = masked_ip if env['HTTP_X_FORWARDED_FOR']
|
|
159
|
+
env['HTTP_X_REAL_IP'] = masked_ip if env['HTTP_X_REAL_IP']
|
|
160
|
+
env['HTTP_X_CLIENT_IP'] = masked_ip if env['HTTP_X_CLIENT_IP']
|
|
161
|
+
|
|
162
|
+
Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Check if an IP is from a trusted proxy
|
|
166
|
+
#
|
|
167
|
+
# @param ip [String] IP address to check
|
|
168
|
+
# @return [Boolean] true if IP is from a trusted proxy
|
|
169
|
+
def trusted_proxy?(ip)
|
|
170
|
+
return false unless @security_config
|
|
171
|
+
|
|
172
|
+
@security_config.trusted_proxy?(ip)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Validate and clean IP address
|
|
176
|
+
#
|
|
177
|
+
# @param ip [String, nil] IP address to validate
|
|
178
|
+
# @return [String, nil] Cleaned IP or nil if invalid
|
|
179
|
+
def validate_ip_address(ip)
|
|
180
|
+
return nil if ip.nil? || ip.empty?
|
|
181
|
+
|
|
182
|
+
# Remove any port number
|
|
183
|
+
clean_ip = ip.split(':').first
|
|
184
|
+
|
|
185
|
+
# Basic IPv4 format validation
|
|
186
|
+
return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
|
187
|
+
|
|
188
|
+
# Validate each octet
|
|
189
|
+
octets = clean_ip.split('.')
|
|
190
|
+
return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
|
|
191
|
+
|
|
192
|
+
clean_ip
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Apply no-privacy settings (privacy explicitly disabled)
|
|
196
|
+
#
|
|
197
|
+
# When privacy is disabled, original IP is available for
|
|
198
|
+
# backward compatibility with code that requires it.
|
|
199
|
+
#
|
|
200
|
+
# @param env [Hash] Rack environment
|
|
201
|
+
def apply_no_privacy(env)
|
|
202
|
+
# Store original IP for explicit access
|
|
203
|
+
env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
|
|
204
|
+
|
|
205
|
+
# env['REMOTE_ADDR'] remains unchanged (real IP)
|
|
206
|
+
# No fingerprint is created when privacy is disabled
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -7,6 +7,21 @@ class Otto
|
|
|
7
7
|
module Middleware
|
|
8
8
|
# Middleware for applying rate limiting to HTTP requests
|
|
9
9
|
class RateLimitMiddleware
|
|
10
|
+
# NOTE: This middleware is a CONFIGURATOR, not an enforcer.
|
|
11
|
+
#
|
|
12
|
+
# Actual rate limiting is performed by Rack::Attack globally via
|
|
13
|
+
# configure_rack_attack!. This middleware registers during initialization
|
|
14
|
+
# and then passes through all requests.
|
|
15
|
+
#
|
|
16
|
+
# To enforce rate limits, Rack::Attack must be added to the middleware
|
|
17
|
+
# stack BEFORE Otto's router (typically done by the hosting application).
|
|
18
|
+
#
|
|
19
|
+
# Example (config.ru):
|
|
20
|
+
# use Rack::Attack # Must come before Otto
|
|
21
|
+
# run otto
|
|
22
|
+
#
|
|
23
|
+
# The call method is a pass-through; rate limiting happens in Rack::Attack.
|
|
24
|
+
|
|
10
25
|
def initialize(app, security_config = nil)
|
|
11
26
|
@app = app
|
|
12
27
|
@security_config = security_config
|
|
@@ -19,10 +34,11 @@ class Otto
|
|
|
19
34
|
end
|
|
20
35
|
end
|
|
21
36
|
|
|
37
|
+
# Pass-through call - actual rate limiting handled by Rack::Attack
|
|
38
|
+
#
|
|
39
|
+
# This middleware does not enforce limits itself. It configures
|
|
40
|
+
# Rack::Attack during initialization, then delegates all requests.
|
|
22
41
|
def call(env)
|
|
23
|
-
return @app.call(env) unless @rate_limiter_available
|
|
24
|
-
|
|
25
|
-
# Let rack-attack handle the rate limiting
|
|
26
42
|
@app.call(env)
|
|
27
43
|
end
|
|
28
44
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# lib/otto/security.rb
|
|
2
|
+
|
|
3
|
+
require_relative 'security/authentication/strategy_result'
|
|
4
|
+
require_relative 'security/config'
|
|
5
|
+
require_relative 'security/configurator'
|
|
6
|
+
require_relative 'security/middleware/csrf_middleware'
|
|
7
|
+
require_relative 'security/middleware/validation_middleware'
|
|
8
|
+
require_relative 'security/middleware/rate_limit_middleware'
|
|
9
|
+
require_relative 'security/middleware/ip_privacy_middleware'
|
data/lib/otto/version.rb
CHANGED