otto 2.0.0.pre2 → 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 +0 -2
- data/.github/workflows/claude-code-review.yml +29 -13
- data/CLAUDE.md +537 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +17 -10
- 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 +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +89 -39
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +24 -17
- data/lib/otto/core/router.rb +1 -1
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +8 -4
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +3 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- 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/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +16 -14
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +3 -3
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +137 -26
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -1
- data/lib/otto/security/authentication.rb +3 -4
- data/lib/otto/security/config.rb +51 -7
- data/lib/otto/security/configurator.rb +0 -13
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +181 -86
- data/otto.gemspec +3 -0
- metadata +58 -3
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
|
@@ -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
|
|
@@ -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
data/lib/otto.rb
CHANGED
|
@@ -11,29 +11,19 @@ require 'rack/request'
|
|
|
11
11
|
require 'rack/response'
|
|
12
12
|
require 'rack/utils'
|
|
13
13
|
|
|
14
|
-
require_relative 'otto/security/authentication/strategy_result'
|
|
15
14
|
require_relative 'otto/route_definition'
|
|
16
15
|
require_relative 'otto/route'
|
|
17
16
|
require_relative 'otto/static'
|
|
18
|
-
require_relative 'otto/helpers
|
|
19
|
-
require_relative 'otto/helpers/response'
|
|
17
|
+
require_relative 'otto/helpers'
|
|
20
18
|
require_relative 'otto/response_handlers'
|
|
21
19
|
require_relative 'otto/route_handlers'
|
|
22
|
-
require_relative 'otto/
|
|
23
|
-
require_relative 'otto/
|
|
24
|
-
require_relative 'otto/
|
|
25
|
-
require_relative 'otto/
|
|
26
|
-
require_relative 'otto/security
|
|
27
|
-
require_relative 'otto/security/middleware/rate_limit_middleware'
|
|
28
|
-
require_relative 'otto/mcp/server'
|
|
29
|
-
require_relative 'otto/core/router'
|
|
30
|
-
require_relative 'otto/core/file_safety'
|
|
31
|
-
require_relative 'otto/core/configuration'
|
|
32
|
-
require_relative 'otto/core/error_handler'
|
|
33
|
-
require_relative 'otto/core/uri_generator'
|
|
34
|
-
require_relative 'otto/core/middleware_stack'
|
|
35
|
-
require_relative 'otto/security/configurator'
|
|
20
|
+
require_relative 'otto/locale/config'
|
|
21
|
+
require_relative 'otto/mcp'
|
|
22
|
+
require_relative 'otto/core'
|
|
23
|
+
require_relative 'otto/privacy'
|
|
24
|
+
require_relative 'otto/security'
|
|
36
25
|
require_relative 'otto/utils'
|
|
26
|
+
require_relative 'otto/version'
|
|
37
27
|
|
|
38
28
|
# Otto is a simple Rack router that allows you to define routes in a file
|
|
39
29
|
# with built-in security features including CSRF protection, input validation,
|
|
@@ -56,37 +46,6 @@ require_relative 'otto/utils'
|
|
|
56
46
|
# otto.enable_csp!
|
|
57
47
|
# otto.enable_frame_protection!
|
|
58
48
|
#
|
|
59
|
-
# Configuration Data class to replace OpenStruct
|
|
60
|
-
# Configuration class to replace OpenStruct
|
|
61
|
-
class ConfigData
|
|
62
|
-
def initialize(**kwargs)
|
|
63
|
-
@data = kwargs
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Dynamic attribute accessors
|
|
67
|
-
def method_missing(method_name, *args)
|
|
68
|
-
if method_name.to_s.end_with?('=')
|
|
69
|
-
# Setter
|
|
70
|
-
attr_name = method_name.to_s.chomp('=').to_sym
|
|
71
|
-
@data[attr_name] = args.first
|
|
72
|
-
elsif @data.key?(method_name)
|
|
73
|
-
# Getter
|
|
74
|
-
@data[method_name]
|
|
75
|
-
else
|
|
76
|
-
super
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def respond_to_missing?(method_name, include_private = false)
|
|
81
|
-
method_name.to_s.end_with?('=') || @data.key?(method_name) || super
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Convert to hash for compatibility
|
|
85
|
-
def to_h
|
|
86
|
-
@data.dup
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
49
|
class Otto
|
|
91
50
|
include Otto::Core::Router
|
|
92
51
|
include Otto::Core::FileSafety
|
|
@@ -102,25 +61,11 @@ class Otto
|
|
|
102
61
|
else
|
|
103
62
|
defined?(Otto::Utils) ? Otto::Utils.yes?(ENV.fetch('OTTO_DEBUG', nil)) : false
|
|
104
63
|
end
|
|
105
|
-
@logger
|
|
106
|
-
@global_config = nil
|
|
107
|
-
|
|
108
|
-
# Global configuration for all Otto instances (Ruby 3.2+ pattern matching)
|
|
109
|
-
def self.configure
|
|
110
|
-
config = case @global_config
|
|
111
|
-
in Hash => h
|
|
112
|
-
# Transform string keys to symbol keys for ConfigData compatibility
|
|
113
|
-
symbol_hash = h.transform_keys(&:to_sym)
|
|
114
|
-
ConfigData.new(**symbol_hash)
|
|
115
|
-
else
|
|
116
|
-
ConfigData.new
|
|
117
|
-
end
|
|
118
|
-
yield config
|
|
119
|
-
@global_config = config.to_h
|
|
120
|
-
end
|
|
64
|
+
@logger = Logger.new($stdout, Logger::INFO)
|
|
121
65
|
|
|
122
|
-
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
|
|
123
|
-
:security_config, :locale_config, :auth_config,
|
|
66
|
+
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
|
|
67
|
+
:static_route, :security_config, :locale_config, :auth_config,
|
|
68
|
+
:route_handler_factory, :mcp_server, :security, :middleware
|
|
124
69
|
attr_accessor :not_found, :server_error
|
|
125
70
|
|
|
126
71
|
def initialize(path = nil, opts = {})
|
|
@@ -131,27 +76,76 @@ class Otto
|
|
|
131
76
|
Otto.logger.debug "new Otto: #{opts}" if Otto.debug
|
|
132
77
|
load(path) unless path.nil?
|
|
133
78
|
super()
|
|
79
|
+
|
|
80
|
+
# Build the middleware app once after all initialization is complete
|
|
81
|
+
build_app!
|
|
82
|
+
|
|
83
|
+
# Configuration freezing is deferred until first request to support
|
|
84
|
+
# multi-step initialization (e.g., multi-app architectures).
|
|
85
|
+
# This allows adding auth strategies, middleware, etc. after Otto.new
|
|
86
|
+
# but before processing requests.
|
|
87
|
+
@freeze_mutex = Mutex.new
|
|
88
|
+
@configuration_frozen = false
|
|
134
89
|
end
|
|
135
90
|
alias options option
|
|
136
91
|
|
|
137
92
|
# Main Rack application interface
|
|
138
93
|
def call(env)
|
|
139
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
94
|
+
# Freeze configuration on first request (thread-safe)
|
|
95
|
+
# Skip in test environment to allow test flexibility
|
|
96
|
+
unless defined?(RSpec) || @configuration_frozen
|
|
97
|
+
Otto.logger.debug '[Otto] Lazy freezing check: configuration not yet frozen' if Otto.debug
|
|
98
|
+
|
|
99
|
+
@freeze_mutex.synchronize do
|
|
100
|
+
unless @configuration_frozen
|
|
101
|
+
Otto.logger.info '[Otto] Freezing configuration on first request (lazy freeze)'
|
|
102
|
+
freeze_configuration!
|
|
103
|
+
@configuration_frozen = true
|
|
104
|
+
Otto.logger.debug '[Otto] Configuration frozen successfully' if Otto.debug
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
144
108
|
|
|
145
109
|
begin
|
|
146
|
-
app
|
|
110
|
+
# Use pre-built middleware app (built once at initialization)
|
|
111
|
+
@app.call(env)
|
|
147
112
|
rescue StandardError => e
|
|
148
113
|
handle_error(e, env)
|
|
149
114
|
end
|
|
150
115
|
end
|
|
151
116
|
|
|
117
|
+
# Builds the middleware application chain
|
|
118
|
+
# Called once at initialization and whenever middleware stack changes
|
|
119
|
+
#
|
|
120
|
+
# IMPORTANT: If you have routes with auth requirements, you MUST add session
|
|
121
|
+
# middleware to your middleware stack BEFORE Otto processes requests.
|
|
122
|
+
#
|
|
123
|
+
# Session middleware is required for RouteAuthWrapper to correctly persist
|
|
124
|
+
# session changes during authentication. Common options include:
|
|
125
|
+
# - Rack::Session::Cookie (requires rack-session gem)
|
|
126
|
+
# - Rack::Session::Pool
|
|
127
|
+
# - Rack::Session::Memcache
|
|
128
|
+
# - Any Rack-compatible session middleware
|
|
129
|
+
#
|
|
130
|
+
# Example:
|
|
131
|
+
# use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
|
|
132
|
+
# otto = Otto.new('routes.txt')
|
|
133
|
+
#
|
|
134
|
+
def build_app!
|
|
135
|
+
base_app = method(:handle_request)
|
|
136
|
+
@app = @middleware.wrap(base_app, @security_config)
|
|
137
|
+
end
|
|
138
|
+
|
|
152
139
|
# Middleware Management
|
|
153
140
|
def use(middleware, ...)
|
|
141
|
+
ensure_not_frozen!
|
|
154
142
|
@middleware.add(middleware, ...)
|
|
143
|
+
|
|
144
|
+
# NOTE: If build_app! is triggered during a request (via use() or
|
|
145
|
+
# middleware_stack=), the @app instance variable could be swapped
|
|
146
|
+
# mid-request in a multi-threaded environment.
|
|
147
|
+
|
|
148
|
+
build_app! if @app # Rebuild app if already initialized
|
|
155
149
|
end
|
|
156
150
|
|
|
157
151
|
# Compatibility method for existing tests
|
|
@@ -163,6 +157,7 @@ class Otto
|
|
|
163
157
|
def middleware_stack=(stack)
|
|
164
158
|
@middleware.clear!
|
|
165
159
|
Array(stack).each { |middleware| @middleware.add(middleware) }
|
|
160
|
+
build_app! if @app # Rebuild app if already initialized
|
|
166
161
|
end
|
|
167
162
|
|
|
168
163
|
# Compatibility method for middleware detection
|
|
@@ -179,6 +174,7 @@ class Otto
|
|
|
179
174
|
# @example
|
|
180
175
|
# otto.enable_csrf_protection!
|
|
181
176
|
def enable_csrf_protection!
|
|
177
|
+
ensure_not_frozen!
|
|
182
178
|
return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
|
|
183
179
|
|
|
184
180
|
@security_config.enable_csrf_protection!
|
|
@@ -191,6 +187,7 @@ class Otto
|
|
|
191
187
|
# @example
|
|
192
188
|
# otto.enable_request_validation!
|
|
193
189
|
def enable_request_validation!
|
|
190
|
+
ensure_not_frozen!
|
|
194
191
|
return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
|
|
195
192
|
|
|
196
193
|
@security_config.input_validation = true
|
|
@@ -206,6 +203,7 @@ class Otto
|
|
|
206
203
|
# @example
|
|
207
204
|
# otto.enable_rate_limiting!(requests_per_minute: 50)
|
|
208
205
|
def enable_rate_limiting!(options = {})
|
|
206
|
+
ensure_not_frozen!
|
|
209
207
|
return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
|
|
210
208
|
|
|
211
209
|
@security.configure_rate_limiting(options)
|
|
@@ -222,7 +220,7 @@ class Otto
|
|
|
222
220
|
# @example
|
|
223
221
|
# otto.add_rate_limit_rule('uploads', limit: 5, period: 300, condition: ->(req) { req.post? && req.path.include?('upload') })
|
|
224
222
|
def add_rate_limit_rule(name, options)
|
|
225
|
-
|
|
223
|
+
ensure_not_frozen!
|
|
226
224
|
@security_config.rate_limiting_config[:custom_rules][name.to_s] = options
|
|
227
225
|
end
|
|
228
226
|
|
|
@@ -234,6 +232,7 @@ class Otto
|
|
|
234
232
|
# otto.add_trusted_proxy('10.0.0.0/8')
|
|
235
233
|
# otto.add_trusted_proxy(/^172\.16\./)
|
|
236
234
|
def add_trusted_proxy(proxy)
|
|
235
|
+
ensure_not_frozen!
|
|
237
236
|
@security_config.add_trusted_proxy(proxy)
|
|
238
237
|
end
|
|
239
238
|
|
|
@@ -247,6 +246,7 @@ class Otto
|
|
|
247
246
|
# 'strict-transport-security' => 'max-age=31536000'
|
|
248
247
|
# })
|
|
249
248
|
def set_security_headers(headers)
|
|
249
|
+
ensure_not_frozen!
|
|
250
250
|
@security_config.security_headers.merge!(headers)
|
|
251
251
|
end
|
|
252
252
|
|
|
@@ -259,6 +259,7 @@ class Otto
|
|
|
259
259
|
# @example
|
|
260
260
|
# otto.enable_hsts!(max_age: 86400, include_subdomains: false)
|
|
261
261
|
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
|
262
|
+
ensure_not_frozen!
|
|
262
263
|
@security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
|
|
263
264
|
end
|
|
264
265
|
|
|
@@ -269,6 +270,7 @@ class Otto
|
|
|
269
270
|
# @example
|
|
270
271
|
# otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
|
271
272
|
def enable_csp!(policy = "default-src 'self'")
|
|
273
|
+
ensure_not_frozen!
|
|
272
274
|
@security_config.enable_csp!(policy)
|
|
273
275
|
end
|
|
274
276
|
|
|
@@ -278,6 +280,7 @@ class Otto
|
|
|
278
280
|
# @example
|
|
279
281
|
# otto.enable_frame_protection!('DENY')
|
|
280
282
|
def enable_frame_protection!(option = 'SAMEORIGIN')
|
|
283
|
+
ensure_not_frozen!
|
|
281
284
|
@security_config.enable_frame_protection!(option)
|
|
282
285
|
end
|
|
283
286
|
|
|
@@ -288,20 +291,10 @@ class Otto
|
|
|
288
291
|
# @example
|
|
289
292
|
# otto.enable_csp_with_nonce!(debug: true)
|
|
290
293
|
def enable_csp_with_nonce!(debug: false)
|
|
294
|
+
ensure_not_frozen!
|
|
291
295
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
292
296
|
end
|
|
293
297
|
|
|
294
|
-
# Enable authentication middleware for route-level access control.
|
|
295
|
-
# This will automatically check route auth parameters and enforce authentication.
|
|
296
|
-
#
|
|
297
|
-
# @example
|
|
298
|
-
# otto.enable_authentication!
|
|
299
|
-
def enable_authentication!
|
|
300
|
-
return if @middleware.includes?(Otto::Security::Authentication::AuthenticationMiddleware)
|
|
301
|
-
|
|
302
|
-
use Otto::Security::Authentication::AuthenticationMiddleware, @auth_config
|
|
303
|
-
end
|
|
304
|
-
|
|
305
298
|
# Add a single authentication strategy
|
|
306
299
|
#
|
|
307
300
|
# @param name [String] Strategy name
|
|
@@ -309,12 +302,83 @@ class Otto
|
|
|
309
302
|
# @example
|
|
310
303
|
# otto.add_auth_strategy('custom', MyCustomStrategy.new)
|
|
311
304
|
def add_auth_strategy(name, strategy)
|
|
305
|
+
ensure_not_frozen!
|
|
312
306
|
# Ensure auth_config is initialized (handles edge case where it might be nil)
|
|
313
307
|
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' } if @auth_config.nil?
|
|
314
308
|
|
|
315
309
|
@auth_config[:auth_strategies][name] = strategy
|
|
310
|
+
end
|
|
316
311
|
|
|
317
|
-
|
|
312
|
+
# Disable IP privacy to access original IP addresses
|
|
313
|
+
#
|
|
314
|
+
# IMPORTANT: By default, Otto masks public IP addresses for privacy.
|
|
315
|
+
# Private/localhost IPs (127.0.0.0/8, 10.0.0.0/8, etc.) are never masked.
|
|
316
|
+
# Only disable this if you need access to original public IPs.
|
|
317
|
+
#
|
|
318
|
+
# When disabled:
|
|
319
|
+
# - env['REMOTE_ADDR'] contains the real IP address
|
|
320
|
+
# - env['otto.original_ip'] also contains the real IP
|
|
321
|
+
# - No PrivateFingerprint is created
|
|
322
|
+
#
|
|
323
|
+
# @example
|
|
324
|
+
# otto.disable_ip_privacy!
|
|
325
|
+
def disable_ip_privacy!
|
|
326
|
+
ensure_not_frozen!
|
|
327
|
+
@security_config.ip_privacy_config.disable!
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# Enable full IP privacy (mask ALL IPs including private/localhost)
|
|
332
|
+
#
|
|
333
|
+
# By default, Otto exempts private and localhost IPs from masking for
|
|
334
|
+
# better development experience. Call this method to mask ALL IPs
|
|
335
|
+
# regardless of type.
|
|
336
|
+
#
|
|
337
|
+
# @example Enable full privacy (mask all IPs)
|
|
338
|
+
# otto = Otto.new(routes_file)
|
|
339
|
+
# otto.enable_full_ip_privacy!
|
|
340
|
+
# # Now 127.0.0.1 → 127.0.0.0, 192.168.1.100 → 192.168.1.0
|
|
341
|
+
#
|
|
342
|
+
# @return [void]
|
|
343
|
+
# @raise [FrozenError] if called after configuration is frozen
|
|
344
|
+
def enable_full_ip_privacy!
|
|
345
|
+
ensure_not_frozen!
|
|
346
|
+
@security_config.ip_privacy_config.mask_private_ips = true
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Configure IP privacy settings
|
|
350
|
+
#
|
|
351
|
+
# Privacy is enabled by default. Use this method to customize privacy
|
|
352
|
+
# behavior without disabling it entirely.
|
|
353
|
+
#
|
|
354
|
+
# @param octet_precision [Integer] Number of octets to mask (1 or 2, default: 1)
|
|
355
|
+
# @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
|
|
356
|
+
# @param geo [Boolean] Enable geo-location resolution (default: true)
|
|
357
|
+
# @param redis [Redis] Redis connection for multi-server atomic key generation
|
|
358
|
+
#
|
|
359
|
+
# @example Mask 2 octets instead of 1
|
|
360
|
+
# otto.configure_ip_privacy(octet_precision: 2)
|
|
361
|
+
#
|
|
362
|
+
# @example Disable geo-location
|
|
363
|
+
# otto.configure_ip_privacy(geo: false)
|
|
364
|
+
#
|
|
365
|
+
# @example Custom hash rotation
|
|
366
|
+
# otto.configure_ip_privacy(hash_rotation: 24.hours)
|
|
367
|
+
#
|
|
368
|
+
# @example Multi-server with Redis
|
|
369
|
+
# redis = Redis.new(url: ENV['REDIS_URL'])
|
|
370
|
+
# otto.configure_ip_privacy(redis: redis)
|
|
371
|
+
def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil)
|
|
372
|
+
ensure_not_frozen!
|
|
373
|
+
config = @security_config.ip_privacy_config
|
|
374
|
+
|
|
375
|
+
config.octet_precision = octet_precision if octet_precision
|
|
376
|
+
config.hash_rotation_period = hash_rotation if hash_rotation
|
|
377
|
+
config.geo_enabled = geo unless geo.nil?
|
|
378
|
+
config.instance_variable_set(:@redis, redis) if redis
|
|
379
|
+
|
|
380
|
+
# Validate configuration
|
|
381
|
+
config.validate!
|
|
318
382
|
end
|
|
319
383
|
|
|
320
384
|
# Enable MCP (Model Context Protocol) server support
|
|
@@ -326,6 +390,7 @@ class Otto
|
|
|
326
390
|
# @example
|
|
327
391
|
# otto.enable_mcp!(http: true, endpoint: '/api/mcp')
|
|
328
392
|
def enable_mcp!(options = {})
|
|
393
|
+
ensure_not_frozen!
|
|
329
394
|
@mcp_server ||= Otto::MCP::Server.new(self)
|
|
330
395
|
|
|
331
396
|
@mcp_server.enable!(options)
|
|
@@ -350,6 +415,14 @@ class Otto
|
|
|
350
415
|
# Initialize @auth_config first so it can be shared with the configurator
|
|
351
416
|
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' }
|
|
352
417
|
@security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
|
|
418
|
+
@app = nil # Pre-built middleware app (built after initialization)
|
|
419
|
+
|
|
420
|
+
# Add IP Privacy middleware first in stack (privacy by default for public IPs)
|
|
421
|
+
# Private/localhost IPs are automatically exempted from masking
|
|
422
|
+
@middleware.add_with_position(
|
|
423
|
+
Otto::Security::Middleware::IPPrivacyMiddleware,
|
|
424
|
+
position: :first
|
|
425
|
+
)
|
|
353
426
|
end
|
|
354
427
|
|
|
355
428
|
def initialize_options(_path, opts)
|
|
@@ -375,7 +448,7 @@ class Otto
|
|
|
375
448
|
end
|
|
376
449
|
|
|
377
450
|
class << self
|
|
378
|
-
attr_accessor :debug, :logger
|
|
451
|
+
attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
|
379
452
|
end
|
|
380
453
|
|
|
381
454
|
# Class methods for Otto framework providing singleton access and configuration
|
|
@@ -400,6 +473,28 @@ class Otto
|
|
|
400
473
|
def env? *guesses
|
|
401
474
|
!guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
|
|
402
475
|
end
|
|
476
|
+
|
|
477
|
+
# Test-only method to unfreeze Otto configuration
|
|
478
|
+
#
|
|
479
|
+
# This method resets the @configuration_frozen flag, allowing tests
|
|
480
|
+
# to bypass the ensure_not_frozen! check. It does NOT actually unfreeze
|
|
481
|
+
# Ruby objects (which is impossible once frozen).
|
|
482
|
+
#
|
|
483
|
+
# IMPORTANT: Only works when RSpec is defined. Raises an error otherwise
|
|
484
|
+
# to prevent accidental use in production.
|
|
485
|
+
#
|
|
486
|
+
# @param otto [Otto] The Otto instance to unfreeze
|
|
487
|
+
# @return [Otto] The unfrozen Otto instance
|
|
488
|
+
# @raise [RuntimeError] if RSpec is not defined (not in test environment)
|
|
489
|
+
# @api private
|
|
490
|
+
def unfreeze_for_testing(otto)
|
|
491
|
+
unless defined?(RSpec)
|
|
492
|
+
raise 'Otto.unfreeze_for_testing is only available in RSpec test environment'
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
otto.instance_variable_set(:@configuration_frozen, false)
|
|
496
|
+
otto
|
|
497
|
+
end
|
|
403
498
|
end
|
|
404
499
|
extend ClassMethods
|
|
405
500
|
end
|
data/otto.gemspec
CHANGED
|
@@ -16,6 +16,9 @@ Gem::Specification.new do |spec|
|
|
|
16
16
|
|
|
17
17
|
spec.required_ruby_version = ['>= 3.2', '< 4.0']
|
|
18
18
|
|
|
19
|
+
spec.add_dependency 'ipaddr', '~> 1', '< 2.0'
|
|
20
|
+
spec.add_dependency 'concurrent-ruby', '~> 1.3', '< 2.0'
|
|
21
|
+
|
|
19
22
|
# Logger is not part of the default gems as of Ruby 3.5.0
|
|
20
23
|
spec.add_dependency 'logger', '~> 1', '< 2.0'
|
|
21
24
|
|