otto 2.9.0 → 2.11.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 +8 -5
- data/AGENTS.md +40 -1
- data/CHANGELOG.rst +235 -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 +157 -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 +293 -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 +40 -23
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +105 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +94 -18
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- 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/static.rb +38 -0
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +130 -13
- data/otto.gemspec +0 -2
- metadata +32 -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
data/lib/otto/security/core.rb
CHANGED
|
@@ -46,6 +46,7 @@ class Otto
|
|
|
46
46
|
ensure_not_frozen!
|
|
47
47
|
return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
|
|
48
48
|
|
|
49
|
+
Otto::Security::RateLimiting.ensure_available!
|
|
49
50
|
@security.configure_rate_limiting(options)
|
|
50
51
|
use Otto::Security::Middleware::RateLimitMiddleware
|
|
51
52
|
end
|
|
@@ -76,6 +77,23 @@ class Otto
|
|
|
76
77
|
@security_config.add_trusted_proxy(proxy)
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# Assert that NO proxy is trusted: every peer gets
|
|
81
|
+
# env['otto.via_trusted_proxy'] = false, forwarded client-IP chains are
|
|
82
|
+
# ignored, and forwarded host/scheme/port carriers are stripped so
|
|
83
|
+
# Rack::Request#host resolves only from the Host header.
|
|
84
|
+
#
|
|
85
|
+
# Equivalent to passing `trusted_proxies: :none` to Otto.new. Distinct
|
|
86
|
+
# from configuring nothing, which asserts nothing (tri-state, #228).
|
|
87
|
+
#
|
|
88
|
+
# @raise [ArgumentError] if trusted proxies or a depth >= 1 are configured
|
|
89
|
+
# @return [void]
|
|
90
|
+
# @example
|
|
91
|
+
# otto.trust_no_proxies!
|
|
92
|
+
def trust_no_proxies!
|
|
93
|
+
ensure_not_frozen!
|
|
94
|
+
@security_config.trust_no_proxies!
|
|
95
|
+
end
|
|
96
|
+
|
|
79
97
|
# Set custom security headers that will be added to all responses.
|
|
80
98
|
# These merge with the default security headers.
|
|
81
99
|
#
|
|
@@ -245,7 +263,7 @@ class Otto
|
|
|
245
263
|
# @param strategy [AuthStrategy] Strategy instance
|
|
246
264
|
# @example
|
|
247
265
|
# otto.add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
|
|
248
|
-
# otto.add_auth_strategy('api_key', APIKeyStrategy.new)
|
|
266
|
+
# otto.add_auth_strategy('api_key', APIKeyStrategy.new(api_keys: ENV.fetch('API_KEYS').split(',')))
|
|
249
267
|
# @raise [ArgumentError] if strategy name already registered
|
|
250
268
|
def add_auth_strategy(name, strategy)
|
|
251
269
|
ensure_not_frozen!
|
|
@@ -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/static.rb
CHANGED
|
@@ -15,6 +15,44 @@ class Otto
|
|
|
15
15
|
[404, security_headers.merge({ 'content-type' => 'text/plain' }), ['Not Found']]
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Return a per-request copy of a Rack triple so callers can never hand a
|
|
19
|
+
# shared object back to the Rack stack.
|
|
20
|
+
#
|
|
21
|
+
# Middleware above Otto (rack-session, Otto's own CSRF middleware, anything
|
|
22
|
+
# that calls +Rack::Utils.set_cookie_header!+) writes response headers in
|
|
23
|
+
# place. Returning a configured triple by reference lets those writes
|
|
24
|
+
# accumulate on the shared object for the life of the process, so every
|
|
25
|
+
# subsequent 404/500 replays every Set-Cookie any earlier one committed.
|
|
26
|
+
#
|
|
27
|
+
# The copy is intentionally shallow-plus-one: the headers container keeps
|
|
28
|
+
# its class (a +Rack::Headers+ stays case-insensitive), each Array-valued
|
|
29
|
+
# header (Rack 3's representation of a repeated header) is copied so an
|
|
30
|
+
# append cannot reach the shared Array, and an Array body is copied so a
|
|
31
|
+
# middleware appending chunks cannot grow the shared body. A frozen
|
|
32
|
+
# configured triple yields an unfrozen copy, so cookie middleware works
|
|
33
|
+
# after configuration freezing as well.
|
|
34
|
+
#
|
|
35
|
+
# @param response [Array] a Rack triple +[status, headers, body]+
|
|
36
|
+
# @return [Array] a new triple that shares no mutable container with +response+
|
|
37
|
+
def copy_response(response)
|
|
38
|
+
status, headers, body = response
|
|
39
|
+
[status, copy_headers(headers), body.is_a?(Array) ? body.dup : body]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Copy a Rack headers container, keeping its class and copying Array values.
|
|
43
|
+
#
|
|
44
|
+
# @param headers [Hash, Rack::Headers, nil] the headers to copy
|
|
45
|
+
# @return [Hash, Rack::Headers] a new container of the same class
|
|
46
|
+
def copy_headers(headers)
|
|
47
|
+
return {} if headers.nil?
|
|
48
|
+
|
|
49
|
+
copied = headers.dup
|
|
50
|
+
copied.each_pair do |key, value|
|
|
51
|
+
copied[key] = value.dup if value.is_a?(Array)
|
|
52
|
+
end
|
|
53
|
+
copied
|
|
54
|
+
end
|
|
55
|
+
|
|
18
56
|
def security_headers
|
|
19
57
|
{
|
|
20
58
|
'x-frame-options' => 'DENY',
|
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