otto 2.9.0 → 2.10.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/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- data/docs/reverse-proxy-network-services.md +0 -371
|
@@ -36,6 +36,14 @@ class Otto
|
|
|
36
36
|
# # env['otto.original_ip'] also contains real IP
|
|
37
37
|
#
|
|
38
38
|
class IPPrivacyMiddleware
|
|
39
|
+
# Forwarding metadata Rack::Request reads without consulting Otto's
|
|
40
|
+
# proxy trust verdict: host (#host/#authority), scheme (#scheme/#ssl?),
|
|
41
|
+
# and port (#port), from both the X-Forwarded-* family and RFC 7239
|
|
42
|
+
# Forwarded (host=, proto=, and the port inside for=). Defined in
|
|
43
|
+
# Otto::Utils so Otto::Utils::RELAY_MARKER_HEADERS is built from the
|
|
44
|
+
# same list and cannot fall out of step with what is scrubbed here.
|
|
45
|
+
UNTRUSTED_FORWARDING_METADATA_HEADERS = Otto::Utils::FORWARDED_AUTHORITY_HEADERS
|
|
46
|
+
|
|
39
47
|
# Initialize IP Privacy middleware
|
|
40
48
|
#
|
|
41
49
|
# @param app [#call] Rack application
|
|
@@ -55,8 +63,13 @@ class Otto
|
|
|
55
63
|
# canonical client IP for this request, do not re-resolve or re-mask.
|
|
56
64
|
# This makes stacking two instances (e.g. an app-level mount plus
|
|
57
65
|
# Otto's built-in router mount) order-safe instead of double-masking.
|
|
66
|
+
#
|
|
67
|
+
# Client-IP resolution is idempotent, but proxy TRUST is not: the
|
|
68
|
+
# prior pass may have run under a different (or no) configuration,
|
|
69
|
+
# so this instance still enforces its own trust posture below.
|
|
58
70
|
if env.key?('otto.client_ip')
|
|
59
71
|
ensure_ip_match_present(env)
|
|
72
|
+
enforce_proxy_trust_after_prior_pass(env)
|
|
60
73
|
return @app.call(env)
|
|
61
74
|
end
|
|
62
75
|
|
|
@@ -65,7 +78,9 @@ class Otto
|
|
|
65
78
|
# REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
|
|
66
79
|
#
|
|
67
80
|
# TRI-STATE: the key is written ONLY when the operator configured
|
|
68
|
-
# proxy trust (CIDR matchers
|
|
81
|
+
# proxy trust (CIDR matchers, a depth, or the explicit trust-nobody
|
|
82
|
+
# assertion `trusted_proxies: :none`, which makes the value false for
|
|
83
|
+
# every peer, #259). Present, its value is
|
|
69
84
|
# authoritative in both directions — true means the peer matched a
|
|
70
85
|
# CIDR (filter mode) or depth mode is active (configuring a depth
|
|
71
86
|
# asserts the connecting peer IS the operator's proxy tier, #226);
|
|
@@ -77,9 +92,24 @@ class Otto
|
|
|
77
92
|
# forced consumers into grant-only reads (#228).
|
|
78
93
|
# respond_to?: like geo_headers_trusted?, a partial/duck-typed
|
|
79
94
|
# config (or nil) that cannot report trust state is "unconfigured".
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
proxy_trust_configured = @security_config.respond_to?(:proxy_trust_configured?) &&
|
|
96
|
+
@security_config.proxy_trust_configured?
|
|
97
|
+
via_trusted_proxy = proxy_trust_configured && trusted_proxy?(env['REMOTE_ADDR'])
|
|
98
|
+
env['otto.via_trusted_proxy'] = via_trusted_proxy if proxy_trust_configured
|
|
99
|
+
|
|
100
|
+
# Record whether the request was relayed BEFORE the scrub below can
|
|
101
|
+
# delete the relay markers. Downstream middleware that must
|
|
102
|
+
# authenticate a direct local call (Otto::CaddyTLS::LocalhostGuard)
|
|
103
|
+
# runs after this one and would otherwise read a request whose
|
|
104
|
+
# HTTP_FORWARDED was deleted here as "direct". Leak-free boolean.
|
|
105
|
+
env['otto.peer_relayed'] = Otto::Utils.relayed_request?(env)
|
|
106
|
+
|
|
107
|
+
# A peer that failed configured proxy trust cannot supply forwarded
|
|
108
|
+
# host, scheme, or port either. Unconfigured trust (absent tri-state
|
|
109
|
+
# key) is left alone: the operator has asserted nothing, and the
|
|
110
|
+
# contract reserves that state for downstream heuristics (#228), so
|
|
111
|
+
# Rack keeps its own defaults there.
|
|
112
|
+
scrub_untrusted_forwarding_metadata(env) if proxy_trust_configured && !via_trusted_proxy
|
|
83
113
|
|
|
84
114
|
# Same rationale, for loopback: this middleware runs outermost, so a
|
|
85
115
|
# downstream middleware that must authenticate a DIRECT LOCAL CALL
|
|
@@ -121,6 +151,58 @@ class Otto
|
|
|
121
151
|
@config.enabled?
|
|
122
152
|
end
|
|
123
153
|
|
|
154
|
+
# Enforce THIS instance's proxy trust posture when a prior
|
|
155
|
+
# IPPrivacyMiddleware pass already resolved the client IP.
|
|
156
|
+
#
|
|
157
|
+
# The early return in #call keeps client-IP resolution idempotent, but
|
|
158
|
+
# trust enforcement must not ride on it: an outer instance mounted
|
|
159
|
+
# without the application's security config (the "unconfigured
|
|
160
|
+
# defaults" case in docs/guides/privacy.md) writes otto.client_ip and
|
|
161
|
+
# nothing else, and returning here would let an inner
|
|
162
|
+
# `trusted_proxies: :none` instance pass X-Forwarded-Host through
|
|
163
|
+
# untouched — the exact bypass the assertion exists to close.
|
|
164
|
+
#
|
|
165
|
+
# What can still be decided after the prior pass:
|
|
166
|
+
# - trust-nobody: the verdict is false for every peer, so it needs no
|
|
167
|
+
# peer address. Always enforced, overriding a laxer prior verdict.
|
|
168
|
+
# - depth mode: the verdict is true by assertion. Recorded only when
|
|
169
|
+
# no configured pass has spoken (key absent).
|
|
170
|
+
# - CIDR mode: needs the connecting peer, which the prior pass has
|
|
171
|
+
# rewritten (REMOTE_ADDR is now the resolved, possibly masked, client
|
|
172
|
+
# IP — matching a masked address against a narrow CIDR produces false
|
|
173
|
+
# ALLOWs, see #ensure_ip_match_present). A verdict recorded by a prior
|
|
174
|
+
# CONFIGURED pass is kept (the same-config stacking case). With none,
|
|
175
|
+
# fail closed: deny, scrub, and warn so the misconfiguration is
|
|
176
|
+
# diagnosable rather than a silent grant.
|
|
177
|
+
#
|
|
178
|
+
# Every deny also scrubs the authority carriers, which is idempotent:
|
|
179
|
+
# deleting an already-deleted key is a no-op.
|
|
180
|
+
#
|
|
181
|
+
# @param env [Hash] Rack environment
|
|
182
|
+
def enforce_proxy_trust_after_prior_pass(env)
|
|
183
|
+
return unless @security_config.respond_to?(:proxy_trust_configured?) &&
|
|
184
|
+
@security_config.proxy_trust_configured?
|
|
185
|
+
|
|
186
|
+
if @security_config.trust_no_proxies?
|
|
187
|
+
env['otto.via_trusted_proxy'] = false
|
|
188
|
+
scrub_untrusted_forwarding_metadata(env)
|
|
189
|
+
elsif env.key?('otto.via_trusted_proxy')
|
|
190
|
+
nil # a configured pass already decided; honor it
|
|
191
|
+
elsif @security_config.trusted_proxy_depth_mode?
|
|
192
|
+
env['otto.via_trusted_proxy'] = true
|
|
193
|
+
else
|
|
194
|
+
Otto.logger.warn(
|
|
195
|
+
'[IPPrivacyMiddleware] otto.client_ip was resolved by a prior ' \
|
|
196
|
+
'pass with no proxy trust configured, so the connecting peer ' \
|
|
197
|
+
'can no longer be matched against trusted_proxies; treating ' \
|
|
198
|
+
'the peer as untrusted and stripping forwarded authority. ' \
|
|
199
|
+
'Pass the application security config to the outer instance.'
|
|
200
|
+
)
|
|
201
|
+
env['otto.via_trusted_proxy'] = false
|
|
202
|
+
scrub_untrusted_forwarding_metadata(env)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
124
206
|
# Guarantee env['otto.ip_match'] exists on the idempotent-return path.
|
|
125
207
|
#
|
|
126
208
|
# Every path in this middleware that sets otto.client_ip installs the
|
|
@@ -389,6 +471,25 @@ class Otto
|
|
|
389
471
|
env['otto.ip_match'] = ->(cidrs) { Otto::Utils.ip_in_cidrs?(client_ip, cidrs) }
|
|
390
472
|
end
|
|
391
473
|
|
|
474
|
+
# Remove forwarding metadata supplied by a peer that failed proxy trust.
|
|
475
|
+
#
|
|
476
|
+
# Rack honors these according only to the process-global forwarding
|
|
477
|
+
# family, so without this an untrusted client would control
|
|
478
|
+
# request.host, request.ssl?, and request.port for every mounted Rack
|
|
479
|
+
# app (Rack::Session's Secure-cookie gate reads ssl?). Delete the
|
|
480
|
+
# carriers rather than editing them: this path runs only in CIDR filter
|
|
481
|
+
# mode or under the trust-nobody assertion (`trusted_proxies: :none`,
|
|
482
|
+
# #259), neither of which reads the Forwarded header itself, and a
|
|
483
|
+
# hand-rolled RFC 7239 parser that disagrees with Rack's on quoting
|
|
484
|
+
# (e.g. `for=a"b;host=evil`) would let a host= survive the edit.
|
|
485
|
+
# X-Forwarded-For stays, masked or not, because Otto's own resolution
|
|
486
|
+
# already ignores it from an untrusted peer.
|
|
487
|
+
#
|
|
488
|
+
# @param env [Hash] Rack environment
|
|
489
|
+
def scrub_untrusted_forwarding_metadata(env)
|
|
490
|
+
UNTRUSTED_FORWARDING_METADATA_HEADERS.each { |key| env.delete(key) }
|
|
491
|
+
end
|
|
492
|
+
|
|
392
493
|
# Delete forwarded IP headers outright.
|
|
393
494
|
#
|
|
394
495
|
# Used on the no-resolvable-client-IP path, where there is no masked IP
|
|
@@ -27,13 +27,8 @@ class Otto
|
|
|
27
27
|
def initialize(app, security_config = nil)
|
|
28
28
|
@app = app
|
|
29
29
|
@security_config = security_config
|
|
30
|
-
@rate_limiter_available = defined?(Rack::Attack)
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
configure_rate_limiting
|
|
34
|
-
else
|
|
35
|
-
Otto.logger.warn '[Otto] rack-attack not available - rate limiting disabled'
|
|
36
|
-
end
|
|
31
|
+
configure_rate_limiting
|
|
37
32
|
end
|
|
38
33
|
|
|
39
34
|
# Pass-through call - actual rate limiting handled by Rack::Attack
|
|
@@ -4,18 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
require 'rack/attack'
|
|
9
|
-
rescue LoadError
|
|
10
|
-
# rack-attack is optional - graceful fallback
|
|
11
|
-
end
|
|
7
|
+
require_relative '../optional_dependency'
|
|
12
8
|
|
|
13
9
|
class Otto
|
|
14
10
|
module Security
|
|
15
11
|
# Rate limiting implementation using Rack::Attack
|
|
16
12
|
class RateLimiting
|
|
13
|
+
RACK_ATTACK_REQUIREMENT = '~> 6.7'
|
|
14
|
+
|
|
15
|
+
def self.ensure_available!
|
|
16
|
+
Otto::OptionalDependency.require!(
|
|
17
|
+
'rack-attack',
|
|
18
|
+
RACK_ATTACK_REQUIREMENT,
|
|
19
|
+
require_path: 'rack/attack',
|
|
20
|
+
feature: 'Rate limiting'
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
17
24
|
def self.configure_rack_attack!(config = {})
|
|
18
|
-
|
|
25
|
+
ensure_available!
|
|
19
26
|
|
|
20
27
|
# Use provided cache store or default
|
|
21
28
|
Rack::Attack.cache.store = config[:cache_store] if config[:cache_store]
|
|
@@ -23,9 +30,13 @@ class Otto
|
|
|
23
30
|
# Default rules
|
|
24
31
|
default_requests_per_minute = config.fetch(:requests_per_minute, 100)
|
|
25
32
|
|
|
26
|
-
# General request throttling
|
|
33
|
+
# General request throttling. Internal paths (/_mcp, /_status, ...)
|
|
34
|
+
# are skipped by default. PATH_INFO, not Rack::Request#path: #path
|
|
35
|
+
# prepends SCRIPT_NAME, so with Otto mounted under `map '/api'` the
|
|
36
|
+
# internal /_mcp request read as /api/_mcp and was counted here while
|
|
37
|
+
# the MCP throttle, comparing the same way, never saw it at all.
|
|
27
38
|
Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
|
|
28
|
-
request.ip unless request.
|
|
39
|
+
request.ip unless request.path_info.start_with?('/_')
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
# Apply custom rules if provided
|
|
@@ -45,52 +56,76 @@ class Otto
|
|
|
45
56
|
end
|
|
46
57
|
end
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
now = match_data[:epoch_time]
|
|
52
|
-
|
|
53
|
-
headers = {
|
|
54
|
-
'content-type' => 'application/json',
|
|
55
|
-
'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
|
|
56
|
-
}
|
|
59
|
+
configure_responses
|
|
60
|
+
configure_logging
|
|
61
|
+
end
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
error: 'Rate limit exceeded',
|
|
67
|
-
message: 'Too many requests',
|
|
68
|
-
retry_after: headers['retry-after'].to_i,
|
|
69
|
-
limit: match_data[:limit],
|
|
70
|
-
period: match_data[:period],
|
|
71
|
-
}
|
|
72
|
-
[429, headers, [JSON.generate(error_response)]]
|
|
73
|
-
else
|
|
74
|
-
body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
|
|
75
|
-
headers['content-type'] = 'text/plain'
|
|
76
|
-
[429, headers, [body]]
|
|
77
|
-
end
|
|
78
|
-
end
|
|
63
|
+
# Rack::Attack holds a single throttled_responder and each subscriber to
|
|
64
|
+
# 'rack.attack' fires on every throttle event. Subclasses override
|
|
65
|
+
# throttled_response and log_throttled_request instead of registering
|
|
66
|
+
# their own responder or subscriber after super, so the hosting app never
|
|
67
|
+
# gets a redundant responder assignment or doubled log lines.
|
|
68
|
+
def self.configure_responses
|
|
69
|
+
Rack::Attack.throttled_responder = ->(request) { throttled_response(request) }
|
|
70
|
+
end
|
|
79
71
|
|
|
72
|
+
def self.configure_logging
|
|
80
73
|
# Log blocked requests if ActiveSupport is available
|
|
81
74
|
return unless defined?(ActiveSupport::Notifications)
|
|
82
75
|
|
|
83
|
-
# Rack::Attack is mounted by the hosting app AHEAD of Otto, so this
|
|
84
|
-
# subscriber sees the raw peer regardless of where IPPrivacyMiddleware
|
|
85
|
-
# sits in Otto's own stack. Log a masked address, never req.ip: a
|
|
86
|
-
# deployment on the default :masked profile must not write raw client
|
|
87
|
-
# IPs to its logs every time a limit trips (issue #219).
|
|
88
76
|
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
log_throttled_request(payload)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.throttled_response(request)
|
|
82
|
+
match_data = request.env['rack.attack.match_data']
|
|
83
|
+
general_throttled_response(request, throttle_headers(match_data), match_data)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.throttle_headers(match_data)
|
|
87
|
+
now = match_data[:epoch_time]
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
'content-type' => 'application/json',
|
|
91
|
+
'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Custom response for rate limited requests
|
|
96
|
+
def self.general_throttled_response(request, headers, match_data)
|
|
97
|
+
# Content negotiation for rate limit response
|
|
98
|
+
# Route's response_type takes precedence over Accept header
|
|
99
|
+
route_def = request.env['otto.route_definition']
|
|
100
|
+
wants_json = (route_def&.response_type == 'json') ||
|
|
101
|
+
request.env['HTTP_ACCEPT'].to_s.include?('application/json')
|
|
102
|
+
|
|
103
|
+
if wants_json
|
|
104
|
+
error_response = {
|
|
105
|
+
error: 'Rate limit exceeded',
|
|
106
|
+
message: 'Too many requests',
|
|
107
|
+
retry_after: headers['retry-after'].to_i,
|
|
108
|
+
limit: match_data[:limit],
|
|
109
|
+
period: match_data[:period],
|
|
110
|
+
}
|
|
111
|
+
[429, headers, [JSON.generate(error_response)]]
|
|
112
|
+
else
|
|
113
|
+
body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
|
|
114
|
+
headers['content-type'] = 'text/plain'
|
|
115
|
+
[429, headers, [body]]
|
|
92
116
|
end
|
|
93
117
|
end
|
|
118
|
+
|
|
119
|
+
# Rack::Attack is mounted by the hosting app AHEAD of Otto, so this
|
|
120
|
+
# subscriber sees the raw peer regardless of where IPPrivacyMiddleware
|
|
121
|
+
# sits in Otto's own stack. Log a masked address, never req.ip: a
|
|
122
|
+
# deployment on the default :masked profile must not write raw client
|
|
123
|
+
# IPs to its logs every time a limit trips (issue #219).
|
|
124
|
+
def self.log_throttled_request(payload)
|
|
125
|
+
req = payload[:request]
|
|
126
|
+
ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
|
|
127
|
+
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
128
|
+
end
|
|
94
129
|
end
|
|
95
130
|
end
|
|
96
131
|
end
|
data/lib/otto/utils.rb
CHANGED
|
@@ -19,9 +19,47 @@ class Otto
|
|
|
19
19
|
HTTP_X_CLIENT_IP
|
|
20
20
|
].freeze
|
|
21
21
|
|
|
22
|
+
# Forwarding metadata Rack::Request reads without consulting Otto's proxy
|
|
23
|
+
# trust verdict: host (#host/#authority), scheme (#scheme/#ssl?), and port
|
|
24
|
+
# (#port), from both the X-Forwarded-* family and RFC 7239 Forwarded
|
|
25
|
+
# (host=, proto=, and the port inside for=). IPPrivacyMiddleware deletes
|
|
26
|
+
# every one of these for a peer that failed configured proxy trust.
|
|
27
|
+
FORWARDED_AUTHORITY_HEADERS = %w[
|
|
28
|
+
HTTP_FORWARDED
|
|
29
|
+
HTTP_X_FORWARDED_HOST
|
|
30
|
+
HTTP_X_FORWARDED_PROTO
|
|
31
|
+
HTTP_X_FORWARDED_SCHEME
|
|
32
|
+
HTTP_X_FORWARDED_SSL
|
|
33
|
+
HTTP_X_FORWARDED_PORT
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Headers whose presence means the request was RELAYED by a proxy rather
|
|
37
|
+
# than issued directly by the peer: every forwarding carrier Otto knows —
|
|
38
|
+
# the forwarded-for family, RFC 7239 Forwarded, and the authority (host /
|
|
39
|
+
# scheme / port) carriers. The set must cover everything IPPrivacyMiddleware
|
|
40
|
+
# may DELETE on the untrusted-peer path: a carrier that is scrubbed but not
|
|
41
|
+
# counted here would let a relayed request look direct afterwards. Shared
|
|
42
|
+
# by IPPrivacyMiddleware (which records the verdict as
|
|
43
|
+
# env['otto.peer_relayed'] BEFORE the scrub) and
|
|
44
|
+
# Otto::CaddyTLS::LocalhostGuard, so the record and the guard's own
|
|
45
|
+
# fallback scan cannot drift.
|
|
46
|
+
RELAY_MARKER_HEADERS = (FORWARDED_FOR_HEADERS + FORWARDED_AUTHORITY_HEADERS).uniq.freeze
|
|
47
|
+
|
|
22
48
|
# Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
|
|
23
49
|
# predicates do not cover but that should still be treated as non-public
|
|
24
50
|
# (e.g. when picking the real client out of a forwarded chain).
|
|
51
|
+
#
|
|
52
|
+
# The documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24,
|
|
53
|
+
# 2001:db8::/32 and the newer 3fff::/20) are deliberately NOT listed here.
|
|
54
|
+
# The predicate answers "could this be the real client", and non-public
|
|
55
|
+
# entries are skipped as proxy hops. Those ranges are not globally routable,
|
|
56
|
+
# but they are the universal convention for an example public client in tests
|
|
57
|
+
# and docs, including this repo's own specs and guides, so treating them as
|
|
58
|
+
# non-public would make the resolver skip the example client and silently
|
|
59
|
+
# invalidate those examples. There is no security gain either: an attacker who
|
|
60
|
+
# can inject into a chain injects a routable address, and a documentation
|
|
61
|
+
# address in a real chain means a misconfigured hop, which is better surfaced
|
|
62
|
+
# than skipped.
|
|
25
63
|
SPECIAL_USE_RANGES = [
|
|
26
64
|
IPAddr.new('0.0.0.0/8'), # "this" network / unspecified (IPv4)
|
|
27
65
|
IPAddr.new('224.0.0.0/4'), # IPv4 multicast
|
|
@@ -389,6 +427,18 @@ class Otto
|
|
|
389
427
|
end
|
|
390
428
|
end
|
|
391
429
|
|
|
430
|
+
# Whether any relay marker header is present on the request.
|
|
431
|
+
#
|
|
432
|
+
# Call this only from code that runs BEFORE forwarding carriers may be
|
|
433
|
+
# deleted (IPPrivacyMiddleware's untrusted-peer scrub); downstream code
|
|
434
|
+
# should prefer the recorded env['otto.peer_relayed'] boolean.
|
|
435
|
+
#
|
|
436
|
+
# @param env [Hash] Rack environment
|
|
437
|
+
# @return [Boolean]
|
|
438
|
+
def relayed_request?(env)
|
|
439
|
+
RELAY_MARKER_HEADERS.any? { |header| !env[header].to_s.strip.empty? }
|
|
440
|
+
end
|
|
441
|
+
|
|
392
442
|
# Whether an address is on the loopback interface.
|
|
393
443
|
#
|
|
394
444
|
# This is the RAW SOCKET PEER test used to authenticate a direct local call
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -76,7 +76,7 @@ class Otto
|
|
|
76
76
|
end
|
|
77
77
|
@logger = Logger.new($stdout, Logger::INFO)
|
|
78
78
|
|
|
79
|
-
attr_reader :routes, :routes_literal, :
|
|
79
|
+
attr_reader :routes, :routes_literal, :route_definitions,
|
|
80
80
|
:routes_by_definition, :option,
|
|
81
81
|
:static_route, :security_config, :locale_config, :auth_config,
|
|
82
82
|
:route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
|
|
@@ -84,6 +84,7 @@ class Otto
|
|
|
84
84
|
attr_accessor :not_found, :server_error
|
|
85
85
|
|
|
86
86
|
def initialize(path = nil, opts = {})
|
|
87
|
+
constructed = false
|
|
87
88
|
initialize_core_state
|
|
88
89
|
initialize_options(path, opts)
|
|
89
90
|
initialize_configurations(opts)
|
|
@@ -106,6 +107,13 @@ class Otto
|
|
|
106
107
|
# but before processing requests.
|
|
107
108
|
@freeze_mutex = Mutex.new
|
|
108
109
|
@configuration_frozen = false
|
|
110
|
+
constructed = true
|
|
111
|
+
ensure
|
|
112
|
+
# A config that committed to a forwarding family during configure_security
|
|
113
|
+
# is held process-wide; withdraw it if construction failed afterwards
|
|
114
|
+
# (e.g. a bad routes path), or the dead app would keep vetoing other
|
|
115
|
+
# families for the life of the process.
|
|
116
|
+
Otto::Security::Config.release_rack_forwarding_family!(@security_config) if @security_config && !constructed
|
|
109
117
|
end
|
|
110
118
|
alias options option
|
|
111
119
|
|
|
@@ -172,16 +180,6 @@ class Otto
|
|
|
172
180
|
private
|
|
173
181
|
|
|
174
182
|
def initialize_core_state
|
|
175
|
-
# The GET cache is a Concurrent::Map, not a plain Hash: lazy static-file
|
|
176
|
-
# discovery (Core::Router#handle_request, Core::FileSafety#add_static_path)
|
|
177
|
-
# writes into it at request time, after freeze_configuration! has already
|
|
178
|
-
# deep-frozen the rest of the routing state. Deep-freezing this cache too
|
|
179
|
-
# would turn every as-yet-uncached static file request into a 500
|
|
180
|
-
# (FrozenError) in production (issue #185), so it is intentionally excluded
|
|
181
|
-
# from deep_freeze_value in Configuration#freeze_configuration! and kept as
|
|
182
|
-
# a structure that is both mutable post-freeze and safe under concurrent
|
|
183
|
-
# request threads.
|
|
184
|
-
@routes_static = { GET: Concurrent::Map.new }
|
|
185
183
|
@routes = { GET: [] }
|
|
186
184
|
@routes_literal = { GET: {} }
|
|
187
185
|
@route_definitions = {}
|
data/otto.gemspec
CHANGED
|
@@ -34,8 +34,6 @@ Gem::Specification.new do |spec|
|
|
|
34
34
|
spec.add_dependency 'logger', '~> 1', '< 2.0'
|
|
35
35
|
|
|
36
36
|
spec.add_dependency 'rack', '~> 3.1', '< 4.0'
|
|
37
|
-
spec.add_dependency 'rack-parser', '~> 0.7'
|
|
38
|
-
spec.add_dependency 'rexml', '~> 3.4'
|
|
39
37
|
|
|
40
38
|
# Security dependencies
|
|
41
39
|
spec.add_dependency 'loofah', '~> 2.20'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -70,34 +70,6 @@ dependencies:
|
|
|
70
70
|
- - "<"
|
|
71
71
|
- !ruby/object:Gem::Version
|
|
72
72
|
version: '4.0'
|
|
73
|
-
- !ruby/object:Gem::Dependency
|
|
74
|
-
name: rack-parser
|
|
75
|
-
requirement: !ruby/object:Gem::Requirement
|
|
76
|
-
requirements:
|
|
77
|
-
- - "~>"
|
|
78
|
-
- !ruby/object:Gem::Version
|
|
79
|
-
version: '0.7'
|
|
80
|
-
type: :runtime
|
|
81
|
-
prerelease: false
|
|
82
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
83
|
-
requirements:
|
|
84
|
-
- - "~>"
|
|
85
|
-
- !ruby/object:Gem::Version
|
|
86
|
-
version: '0.7'
|
|
87
|
-
- !ruby/object:Gem::Dependency
|
|
88
|
-
name: rexml
|
|
89
|
-
requirement: !ruby/object:Gem::Requirement
|
|
90
|
-
requirements:
|
|
91
|
-
- - "~>"
|
|
92
|
-
- !ruby/object:Gem::Version
|
|
93
|
-
version: '3.4'
|
|
94
|
-
type: :runtime
|
|
95
|
-
prerelease: false
|
|
96
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
97
|
-
requirements:
|
|
98
|
-
- - "~>"
|
|
99
|
-
- !ruby/object:Gem::Version
|
|
100
|
-
version: '3.4'
|
|
101
73
|
- !ruby/object:Gem::Dependency
|
|
102
74
|
name: loofah
|
|
103
75
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -144,18 +116,32 @@ files:
|
|
|
144
116
|
- bin/rspec
|
|
145
117
|
- changelog.d/README.md
|
|
146
118
|
- changelog.d/scriv.ini
|
|
147
|
-
- docs
|
|
148
|
-
- docs/
|
|
149
|
-
- docs/
|
|
150
|
-
- docs/
|
|
151
|
-
- docs/
|
|
152
|
-
- docs/
|
|
153
|
-
- docs/
|
|
154
|
-
- docs/
|
|
119
|
+
- docs/README.md
|
|
120
|
+
- docs/adr/README.md
|
|
121
|
+
- docs/adr/adr-001-route-authentication-at-handler-boundary.md
|
|
122
|
+
- docs/adr/adr-002-multi-strategy-authentication-and-authorization.md
|
|
123
|
+
- docs/adr/adr-003-caddy-tls-route-based-integration.md
|
|
124
|
+
- docs/adr/adr-004-separate-compatibility-from-security-maintenance.md
|
|
125
|
+
- docs/guides/authentication.md
|
|
126
|
+
- docs/guides/caddy-tls.md
|
|
127
|
+
- docs/guides/configuration_freezing.md
|
|
128
|
+
- docs/guides/enrichment.md
|
|
129
|
+
- docs/guides/forwarded-authority.md
|
|
130
|
+
- docs/guides/geo-country.md
|
|
131
|
+
- docs/guides/ip_privacy.md
|
|
132
|
+
- docs/guides/ipaddr-encoding-quirk.md
|
|
133
|
+
- docs/guides/mcp.md
|
|
134
|
+
- docs/guides/privacy.md
|
|
135
|
+
- docs/guides/routing.md
|
|
136
|
+
- docs/guides/structured_logging.md
|
|
137
|
+
- docs/guides/testing-guide.md
|
|
138
|
+
- docs/maintainers/github-actions.md
|
|
139
|
+
- docs/maintainers/investigations/.gitignore
|
|
140
|
+
- docs/migrating/v2.0.0.md
|
|
155
141
|
- docs/migrating/v2.3.0.md
|
|
156
|
-
- docs/
|
|
157
|
-
- docs/
|
|
158
|
-
- docs/
|
|
142
|
+
- docs/reference/authentication.md
|
|
143
|
+
- docs/reference/route-syntax.md
|
|
144
|
+
- docs/reference/runtime-and-dependency-security.md
|
|
159
145
|
- examples/.gitignore
|
|
160
146
|
- examples/advanced_routes/README.md
|
|
161
147
|
- examples/advanced_routes/app.rb
|
|
@@ -251,12 +237,16 @@ files:
|
|
|
251
237
|
- lib/otto/mcp.rb
|
|
252
238
|
- lib/otto/mcp/auth/token.rb
|
|
253
239
|
- lib/otto/mcp/core.rb
|
|
240
|
+
- lib/otto/mcp/endpoint.rb
|
|
241
|
+
- lib/otto/mcp/errors.rb
|
|
242
|
+
- lib/otto/mcp/options.rb
|
|
254
243
|
- lib/otto/mcp/protocol.rb
|
|
255
244
|
- lib/otto/mcp/rate_limiting.rb
|
|
256
245
|
- lib/otto/mcp/registry.rb
|
|
257
246
|
- lib/otto/mcp/route_parser.rb
|
|
258
247
|
- lib/otto/mcp/schema_validation.rb
|
|
259
248
|
- lib/otto/mcp/server.rb
|
|
249
|
+
- lib/otto/optional_dependency.rb
|
|
260
250
|
- lib/otto/privacy.rb
|
|
261
251
|
- lib/otto/privacy/anonymizer_resolver.rb
|
|
262
252
|
- lib/otto/privacy/asn_resolver.rb
|
data/docs/.gitignore
DELETED