otto 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +283 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +14 -12
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +172 -0
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +43 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +281 -3
- data/lib/otto/privacy/core.rb +104 -8
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +4 -1
- data/lib/otto/request.rb +35 -1
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +123 -6
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/policy.rb +135 -3
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +37 -5
- metadata +13 -6
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# lib/otto/security/csrf_enforcement_wrapper.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'csrf_validation'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Security
|
|
9
|
+
# Per-route CSRF enforcement, applied at the handler layer.
|
|
10
|
+
#
|
|
11
|
+
# CSRF enforcement lives here rather than in the global +CSRFMiddleware+
|
|
12
|
+
# because +csrf=exempt+ is a per-route option: it is only known once a
|
|
13
|
+
# route has been matched (the middleware runs *ahead* of route matching and
|
|
14
|
+
# never sees route options, so a global block could not honor exemption —
|
|
15
|
+
# issue #186). This wrapper runs after matching, alongside +RouteAuthWrapper+,
|
|
16
|
+
# where +route_definition.csrf_exempt?+ is directly available. It is composed
|
|
17
|
+
# by +HandlerFactory+ only when CSRF protection is enabled, and wraps outside
|
|
18
|
+
# +RouteAuthWrapper+ so a forged unsafe request is rejected before any
|
|
19
|
+
# authentication work runs.
|
|
20
|
+
#
|
|
21
|
+
# The global +CSRFMiddleware+ retains only token *injection* into HTML
|
|
22
|
+
# responses (a response-shaping concern that is method/content-type based,
|
|
23
|
+
# not route based, so it stays global).
|
|
24
|
+
class CSRFEnforcementWrapper
|
|
25
|
+
include CSRFValidation
|
|
26
|
+
|
|
27
|
+
attr_reader :wrapped_handler, :route_definition, :config
|
|
28
|
+
|
|
29
|
+
# @param wrapped_handler [#call] the handler to guard
|
|
30
|
+
# @param route_definition [Otto::RouteDefinition] the matched route
|
|
31
|
+
# @param config [Otto::Security::Config] security config exposing CSRF settings
|
|
32
|
+
def initialize(wrapped_handler, route_definition, config)
|
|
33
|
+
@wrapped_handler = wrapped_handler
|
|
34
|
+
@route_definition = route_definition
|
|
35
|
+
@config = config
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param env [Hash] Rack environment
|
|
39
|
+
# @param extra_params [Hash] Additional parameters passed through to the handler
|
|
40
|
+
# @return [Array] Rack response tuple
|
|
41
|
+
def call(env, extra_params = {})
|
|
42
|
+
return wrapped_handler.call(env, extra_params) unless enforce?(env)
|
|
43
|
+
|
|
44
|
+
request = Otto::Request.new(env)
|
|
45
|
+
return wrapped_handler.call(env, extra_params) if valid_csrf_token?(request)
|
|
46
|
+
|
|
47
|
+
Otto.structured_log(:warn, 'CSRF validation failed',
|
|
48
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
49
|
+
handler: route_definition.definition,
|
|
50
|
+
referrer: request.referrer
|
|
51
|
+
))
|
|
52
|
+
csrf_error_response
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Whether this request must present a valid CSRF token. Only unsafe
|
|
58
|
+
# methods on a non-exempt route are enforced when protection is enabled.
|
|
59
|
+
def enforce?(env)
|
|
60
|
+
return false unless config&.csrf_enabled?
|
|
61
|
+
return false if safe_method?(env['REQUEST_METHOD'])
|
|
62
|
+
return false if route_definition.csrf_exempt?
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# lib/otto/security/csrf_validation.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Security
|
|
9
|
+
# Shared CSRF token mechanics.
|
|
10
|
+
#
|
|
11
|
+
# Both the global +CSRFMiddleware+ (which injects tokens into HTML
|
|
12
|
+
# responses) and the per-route +CSRFEnforcementWrapper+ (which enforces
|
|
13
|
+
# tokens on unsafe requests, honoring +csrf=exempt+) mix this in so the
|
|
14
|
+
# two cannot drift on what counts as a safe method, where a token may be
|
|
15
|
+
# carried, or how a rejection is shaped. The including object must expose a
|
|
16
|
+
# +@config+ (an +Otto::Security::Config+).
|
|
17
|
+
module CSRFValidation
|
|
18
|
+
# HTTP methods that never mutate state and so are exempt from token
|
|
19
|
+
# validation (RFC 7231 safe methods plus TRACE).
|
|
20
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
21
|
+
|
|
22
|
+
# Static 403 body. Frozen once so a rejected request does not re-serialize
|
|
23
|
+
# the same JSON on every call.
|
|
24
|
+
CSRF_ERROR_BODY = {
|
|
25
|
+
error: 'CSRF token validation failed',
|
|
26
|
+
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
|
27
|
+
}.to_json.freeze
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def safe_method?(method)
|
|
32
|
+
SAFE_METHODS.include?(method.to_s.upcase)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def valid_csrf_token?(request)
|
|
36
|
+
token = extract_csrf_token(request)
|
|
37
|
+
# Reject nil / blank / whitespace-only tokens up front, before creating
|
|
38
|
+
# a session or running HMAC verification — obviously-malformed input
|
|
39
|
+
# should not cause session churn (#186 review).
|
|
40
|
+
return false if token.nil? || token.strip.empty?
|
|
41
|
+
|
|
42
|
+
session_id = extract_session_id(request)
|
|
43
|
+
@config.verify_csrf_token(token, session_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def extract_csrf_token(request)
|
|
47
|
+
# Try form parameter first
|
|
48
|
+
token = request.params[@config.csrf_token_key]
|
|
49
|
+
|
|
50
|
+
# Try header if not in params
|
|
51
|
+
token ||= request.env[@config.csrf_header_key]
|
|
52
|
+
|
|
53
|
+
# Try alternative header format
|
|
54
|
+
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
|
55
|
+
|
|
56
|
+
token
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_session_id(request)
|
|
60
|
+
@config.get_or_create_session_id(request)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def csrf_error_response
|
|
64
|
+
[
|
|
65
|
+
403,
|
|
66
|
+
{
|
|
67
|
+
'content-type' => 'application/json',
|
|
68
|
+
'content-length' => CSRF_ERROR_BODY.bytesize.to_s,
|
|
69
|
+
},
|
|
70
|
+
[CSRF_ERROR_BODY],
|
|
71
|
+
]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -7,10 +7,18 @@ require_relative '../config'
|
|
|
7
7
|
class Otto
|
|
8
8
|
module Security
|
|
9
9
|
module Middleware
|
|
10
|
-
#
|
|
10
|
+
# Global middleware that injects CSRF tokens into HTML responses.
|
|
11
|
+
#
|
|
12
|
+
# Token *enforcement* deliberately does NOT live here. This middleware
|
|
13
|
+
# runs ahead of route matching, so it cannot see per-route options like
|
|
14
|
+
# +csrf=exempt+ (issue #186); enforcing globally would block routes an
|
|
15
|
+
# operator explicitly exempted. Enforcement is applied after matching by
|
|
16
|
+
# +Otto::Security::CSRFEnforcementWrapper+ at the handler layer, where the
|
|
17
|
+
# route definition is available. This middleware keeps only the
|
|
18
|
+
# response-shaping half — injecting a fresh token into HTML responses so
|
|
19
|
+
# forms and meta tags can carry it — which is method/content-type based
|
|
20
|
+
# and correctly stays global.
|
|
11
21
|
class CSRFMiddleware
|
|
12
|
-
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
13
|
-
|
|
14
22
|
def initialize(app, config = nil)
|
|
15
23
|
@app = app
|
|
16
24
|
@config = config || Otto::Security::Config.new
|
|
@@ -19,60 +27,14 @@ class Otto
|
|
|
19
27
|
def call(env)
|
|
20
28
|
return @app.call(env) unless @config.csrf_enabled?
|
|
21
29
|
|
|
22
|
-
request
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
response = @app.call(env)
|
|
27
|
-
response = inject_csrf_token(request, response) if html_response?(response)
|
|
28
|
-
return response
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Validate CSRF token for unsafe methods
|
|
32
|
-
unless valid_csrf_token?(request)
|
|
33
|
-
# Log CSRF validation failure
|
|
34
|
-
Otto.structured_log(:warn, "CSRF validation failed",
|
|
35
|
-
Otto::LoggingHelpers.request_context(env).merge(
|
|
36
|
-
referrer: request.referrer
|
|
37
|
-
)
|
|
38
|
-
)
|
|
39
|
-
return csrf_error_response
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
@app.call(env)
|
|
30
|
+
request = Otto::Request.new(env)
|
|
31
|
+
response = @app.call(env)
|
|
32
|
+
response = inject_csrf_token(request, response) if html_response?(response)
|
|
33
|
+
response
|
|
43
34
|
end
|
|
44
35
|
|
|
45
36
|
private
|
|
46
37
|
|
|
47
|
-
def safe_method?(method)
|
|
48
|
-
SAFE_METHODS.include?(method.upcase)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def valid_csrf_token?(request)
|
|
52
|
-
token = extract_csrf_token(request)
|
|
53
|
-
return false if token.nil? || token.empty?
|
|
54
|
-
|
|
55
|
-
session_id = @config.get_or_create_session_id(request)
|
|
56
|
-
@config.verify_csrf_token(token, session_id)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def extract_csrf_token(request)
|
|
60
|
-
# Try form parameter first
|
|
61
|
-
token = request.params[@config.csrf_token_key]
|
|
62
|
-
|
|
63
|
-
# Try header if not in params
|
|
64
|
-
token ||= request.env[@config.csrf_header_key]
|
|
65
|
-
|
|
66
|
-
# Try alternative header format
|
|
67
|
-
token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
|
|
68
|
-
|
|
69
|
-
token
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def extract_session_id(request)
|
|
73
|
-
@config.get_or_create_session_id(request)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
38
|
def inject_csrf_token(request, response)
|
|
77
39
|
return response unless response.is_a?(Array) && response.length >= 3
|
|
78
40
|
|
|
@@ -138,24 +100,6 @@ class Otto
|
|
|
138
100
|
content_type = headers.find { |k, _v| k.downcase == 'content-type' }&.last
|
|
139
101
|
content_type&.include?('text/html')
|
|
140
102
|
end
|
|
141
|
-
|
|
142
|
-
def csrf_error_response
|
|
143
|
-
[
|
|
144
|
-
403,
|
|
145
|
-
{
|
|
146
|
-
'content-type' => 'application/json',
|
|
147
|
-
'content-length' => csrf_error_body.bytesize.to_s,
|
|
148
|
-
},
|
|
149
|
-
[csrf_error_body],
|
|
150
|
-
]
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def csrf_error_body
|
|
154
|
-
{
|
|
155
|
-
error: 'CSRF token validation failed',
|
|
156
|
-
message: 'The request could not be authenticated. Please refresh the page and try again.',
|
|
157
|
-
}.to_json
|
|
158
|
-
end
|
|
159
103
|
end
|
|
160
104
|
end
|
|
161
105
|
end
|
|
@@ -10,8 +10,18 @@ class Otto
|
|
|
10
10
|
# Automatically masks IP addresses for privacy by default. Original IPs
|
|
11
11
|
# are never stored unless privacy is explicitly disabled.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Otto pins this middleware to the OUTERMOST position of the stack (the
|
|
14
|
+
# :entrypoint tier — see Otto::Core::MiddlewareStack#add_with_position),
|
|
15
|
+
# so it is the first middleware to touch a request and every other
|
|
16
|
+
# middleware, plus the application, reads masked IPs by default. Before
|
|
17
|
+
# #219 it was registered `position: :first`, which is first-in-array and
|
|
18
|
+
# therefore INNERMOST: only the wrapped app saw masked values while every
|
|
19
|
+
# other middleware still saw the raw peer address.
|
|
20
|
+
#
|
|
21
|
+
# Because it now runs ahead of everything, facts about the ORIGINAL peer
|
|
22
|
+
# that downstream code can no longer derive from the (masked) REMOTE_ADDR
|
|
23
|
+
# are recorded first, as leak-free booleans — never as addresses:
|
|
24
|
+
# env['otto.via_trusted_proxy'] and env['otto.peer_loopback'].
|
|
15
25
|
#
|
|
16
26
|
# @example Default behavior (privacy enabled)
|
|
17
27
|
# # env['REMOTE_ADDR'] is masked to 192.168.1.0
|
|
@@ -32,9 +42,6 @@ class Otto
|
|
|
32
42
|
@app = app
|
|
33
43
|
@security_config = security_config
|
|
34
44
|
@config = security_config&.ip_privacy_config || Otto::Privacy::Config.new
|
|
35
|
-
|
|
36
|
-
# Privacy is enabled by default unless explicitly disabled
|
|
37
|
-
@privacy_enabled = @config.enabled?
|
|
38
45
|
end
|
|
39
46
|
|
|
40
47
|
# Process request with IP privacy
|
|
@@ -46,7 +53,10 @@ class Otto
|
|
|
46
53
|
# canonical client IP for this request, do not re-resolve or re-mask.
|
|
47
54
|
# This makes stacking two instances (e.g. an app-level mount plus
|
|
48
55
|
# Otto's built-in router mount) order-safe instead of double-masking.
|
|
49
|
-
|
|
56
|
+
if env.key?('otto.client_ip')
|
|
57
|
+
ensure_ip_match_present(env)
|
|
58
|
+
return @app.call(env)
|
|
59
|
+
end
|
|
50
60
|
|
|
51
61
|
# Record the connecting peer's trust decision BEFORE any masking, so
|
|
52
62
|
# secure? can authorize X-Forwarded-Proto canonically even after
|
|
@@ -58,7 +68,20 @@ class Otto
|
|
|
58
68
|
# downstream OneTimeSecret behavior).
|
|
59
69
|
env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
|
|
60
70
|
|
|
61
|
-
|
|
71
|
+
# Same rationale, for loopback: this middleware runs outermost, so a
|
|
72
|
+
# downstream middleware that must authenticate a DIRECT LOCAL CALL
|
|
73
|
+
# (Otto::CaddyTLS::LocalhostGuard) can no longer read the true socket
|
|
74
|
+
# peer from REMOTE_ADDR. Record the verdict here, on the untouched
|
|
75
|
+
# peer, as a boolean — the address itself is never exposed.
|
|
76
|
+
#
|
|
77
|
+
# Deliberately the raw peer, NOT the resolved client IP: resolution
|
|
78
|
+
# honors forwarded headers from trusted proxies, and a co-located
|
|
79
|
+
# reverse proxy on loopback is itself a natural trusted proxy, so
|
|
80
|
+
# resolving first would let `X-Forwarded-For: 127.0.0.1` promote a
|
|
81
|
+
# remote caller to "localhost".
|
|
82
|
+
env['otto.peer_loopback'] = Otto::Utils.loopback_address?(env['REMOTE_ADDR'])
|
|
83
|
+
|
|
84
|
+
if privacy_enabled?
|
|
62
85
|
apply_privacy(env)
|
|
63
86
|
else
|
|
64
87
|
apply_no_privacy(env)
|
|
@@ -69,12 +92,56 @@ class Otto
|
|
|
69
92
|
|
|
70
93
|
private
|
|
71
94
|
|
|
72
|
-
#
|
|
95
|
+
# Whether IP privacy is on for this request.
|
|
73
96
|
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
97
|
+
# Read live from the config rather than cached at construction. Otto
|
|
98
|
+
# builds its middleware stack at the end of Otto.new, but
|
|
99
|
+
# configure_ip_privacy stays legal until the first request (the
|
|
100
|
+
# configuration freeze is deferred — see Otto#initialize). A flag
|
|
101
|
+
# captured in #initialize would therefore ignore a post-construction
|
|
102
|
+
# `configure_ip_privacy(profile: :audit)` and keep masking under a
|
|
103
|
+
# profile the operator explicitly turned off. One predicate call per
|
|
104
|
+
# request buys that correctness.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def privacy_enabled?
|
|
108
|
+
@config.enabled?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Guarantee env['otto.ip_match'] exists on the idempotent-return path.
|
|
112
|
+
#
|
|
113
|
+
# Every path in this middleware that sets otto.client_ip installs the
|
|
114
|
+
# capability first, so a second IPPrivacyMiddleware pass that reaches
|
|
115
|
+
# this guard finds both keys and leaves the precise closure in place.
|
|
116
|
+
# (The no-resolvable-IP path installs the capability but never sets
|
|
117
|
+
# otto.client_ip, so a second pass re-runs apply_privacy and reinstalls
|
|
118
|
+
# an equivalent fail-closed closure — idempotent, since there is nothing
|
|
119
|
+
# to double-mask.) The gap is out-of-contract writes: otto.client_ip is
|
|
120
|
+
# documented as "Set by: IPPrivacyMiddleware" (see Otto::EnvKeys), but
|
|
121
|
+
# an app or test harness that sets it directly trips the idempotency
|
|
122
|
+
# guard and leaves the advertised capability nil — downstream policy
|
|
123
|
+
# code then raises NoMethodError on nil.
|
|
124
|
+
#
|
|
125
|
+
# The repair installs a fail-closed check, NOT one derived from
|
|
126
|
+
# env['otto.client_ip']. That value may already be masked, and matching
|
|
127
|
+
# a masked address against a narrow CIDR produces false ALLOWs (masked
|
|
128
|
+
# 192.168.1.0 falls inside 192.168.1.0/28 when the real client was
|
|
129
|
+
# .200). A universal deny is the safe verdict; the warning below is
|
|
130
|
+
# what makes it diagnosable instead of a silent lockout.
|
|
76
131
|
#
|
|
77
132
|
# @param env [Hash] Rack environment
|
|
133
|
+
def ensure_ip_match_present(env)
|
|
134
|
+
return if env.key?('otto.ip_match')
|
|
135
|
+
|
|
136
|
+
Otto.logger.warn(
|
|
137
|
+
'[IPPrivacyMiddleware] otto.client_ip was set outside this ' \
|
|
138
|
+
'middleware, so otto.ip_match could not be built from the ' \
|
|
139
|
+
'unmasked address; installing a fail-closed check (every CIDR ' \
|
|
140
|
+
'test returns false). Let IPPrivacyMiddleware resolve the client IP.'
|
|
141
|
+
)
|
|
142
|
+
env['otto.ip_match'] = ->(_cidrs) { false }
|
|
143
|
+
end
|
|
144
|
+
|
|
78
145
|
# Apply privacy settings to environment
|
|
79
146
|
#
|
|
80
147
|
# @param env [Hash] Rack environment
|
|
@@ -83,7 +150,19 @@ class Otto
|
|
|
83
150
|
# canonical resolution step; masking below operates on this value.
|
|
84
151
|
client_ip = resolve_client_ip(env)
|
|
85
152
|
|
|
86
|
-
|
|
153
|
+
# Install the verdict-only precision capability while client_ip is
|
|
154
|
+
# still the real address — after this method returns, the raw
|
|
155
|
+
# material is gone (REMOTE_ADDR and forwarded headers rewritten).
|
|
156
|
+
install_ip_match(env, client_ip)
|
|
157
|
+
|
|
158
|
+
# There is deliberately no debug line for the resolution itself. It
|
|
159
|
+
# used to interpolate client_ip, which handed back through the log
|
|
160
|
+
# exactly what these profiles withhold from env — and logs travel
|
|
161
|
+
# further than a process does. Stripped of the address it carried no
|
|
162
|
+
# information worth a line: this method only runs on the masking
|
|
163
|
+
# profiles (:masked / :anonymous), and every branch below already logs
|
|
164
|
+
# its own outcome. To trace resolution here, log a derived value (the
|
|
165
|
+
# masked IP, the family, the trusted-proxy verdict) — never the address.
|
|
87
166
|
|
|
88
167
|
# No resolvable client IP (REMOTE_ADDR absent or blank, and no trusted
|
|
89
168
|
# forwarded value). There is nothing to mask, and masking would derive
|
|
@@ -100,9 +179,18 @@ class Otto
|
|
|
100
179
|
# original sensitive data. So still scrub those headers before
|
|
101
180
|
# bailing — a request with no resolvable IP must not leak an
|
|
102
181
|
# un-anonymized User-Agent or Referer.
|
|
182
|
+
#
|
|
183
|
+
# Likewise, forwarded headers may still carry raw client addresses
|
|
184
|
+
# (e.g. an X-Forwarded-For / Forwarded value with no usable REMOTE_ADDR
|
|
185
|
+
# to anchor resolution). There is no masked IP to rewrite them to, so
|
|
186
|
+
# DELETE them — leaving them would leak the raw address downstream.
|
|
103
187
|
if client_ip.to_s.empty?
|
|
104
188
|
Otto.logger.debug '[IPPrivacyMiddleware] No resolvable client IP; skipping IP masking' if Otto.debug
|
|
105
|
-
scrub_sensitive_headers(
|
|
189
|
+
scrub_sensitive_headers(
|
|
190
|
+
env,
|
|
191
|
+
Otto::Privacy::RedactedFingerprint.new(env, @config, geo_headers_trusted: geo_headers_trusted?(env))
|
|
192
|
+
)
|
|
193
|
+
scrub_forwarded_headers(env)
|
|
106
194
|
return
|
|
107
195
|
end
|
|
108
196
|
|
|
@@ -116,7 +204,22 @@ class Otto
|
|
|
116
204
|
# Canonical client IP downstream reads (exempt: not masked)
|
|
117
205
|
env['otto.client_ip'] = client_ip
|
|
118
206
|
# Don't mask forwarded headers for private IPs
|
|
119
|
-
|
|
207
|
+
#
|
|
208
|
+
# This early return also means NONE of the privacy fingerprint
|
|
209
|
+
# values are produced for exempt IPs — no otto.privacy.fingerprint,
|
|
210
|
+
# masked_ip, hashed_ip, geo_country, or correlation_hash. That is
|
|
211
|
+
# intentional and consistent: the correlation hash targets public
|
|
212
|
+
# audit-trail traffic, so req.ip_correlation_hash is nil for
|
|
213
|
+
# localhost / RFC-1918 addresses (the default dev path) even when a
|
|
214
|
+
# correlation_secret is configured. Set mask_private_ips to treat
|
|
215
|
+
# private IPs as public and run them through the full path below.
|
|
216
|
+
# No address interpolated (see the resolution note at the top of
|
|
217
|
+
# this method). Exempt IPs skip fingerprinting entirely, so there
|
|
218
|
+
# is no derived value to log either — the line records only that
|
|
219
|
+
# the exemption fired. The address is not lost to debugging: this
|
|
220
|
+
# path leaves REMOTE_ADDR unmasked and sets otto.client_ip to the
|
|
221
|
+
# same value, so downstream request logs still carry it.
|
|
222
|
+
Otto.logger.debug '[IPPrivacyMiddleware] Private/localhost IP exempted from masking' if Otto.debug
|
|
120
223
|
return
|
|
121
224
|
end
|
|
122
225
|
end
|
|
@@ -125,7 +228,9 @@ class Otto
|
|
|
125
228
|
# We temporarily set REMOTE_ADDR to the client IP for fingerprint creation
|
|
126
229
|
original_remote_addr = env['REMOTE_ADDR']
|
|
127
230
|
env['REMOTE_ADDR'] = client_ip
|
|
128
|
-
fingerprint = Otto::Privacy::RedactedFingerprint.new(
|
|
231
|
+
fingerprint = Otto::Privacy::RedactedFingerprint.new(
|
|
232
|
+
env, @config, geo_headers_trusted: geo_headers_trusted?(env)
|
|
233
|
+
)
|
|
129
234
|
env['REMOTE_ADDR'] = original_remote_addr
|
|
130
235
|
|
|
131
236
|
# Set privacy-safe values in environment
|
|
@@ -134,6 +239,14 @@ class Otto
|
|
|
134
239
|
env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
|
|
135
240
|
env['otto.privacy.geo_country'] = fingerprint.country
|
|
136
241
|
|
|
242
|
+
# Fingerprint the FULL client IP here — while client_ip is still the
|
|
243
|
+
# real address, before REMOTE_ADDR is masked below — so it identifies
|
|
244
|
+
# the visitor, not just their /24. Uses the caller's stable secret
|
|
245
|
+
# (unlike hashed_ip's daily key), so the same IP matches across days.
|
|
246
|
+
# nil when no secret is set. The real IP is never written to env; only
|
|
247
|
+
# this hash leaves the middleware.
|
|
248
|
+
env['otto.privacy.correlation_hash'] = correlation_hash(client_ip)
|
|
249
|
+
|
|
137
250
|
# CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked values
|
|
138
251
|
# This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
|
|
139
252
|
# automatically uses the masked values without modification
|
|
@@ -158,6 +271,23 @@ class Otto
|
|
|
158
271
|
# or env['otto.original_referer']. This prevents accidental leakage of the real values.
|
|
159
272
|
end
|
|
160
273
|
|
|
274
|
+
# Fingerprint of the full client IP, keyed with the caller's stable
|
|
275
|
+
# correlation secret (not hashed_ip's daily-rotating key). The same IP
|
|
276
|
+
# and secret always produce the same value, so it can match a visitor
|
|
277
|
+
# across days — which the daily hash can't.
|
|
278
|
+
#
|
|
279
|
+
# Returns nil when no secret is configured. An empty key is never used
|
|
280
|
+
# to hash (that would let anyone reverse it); we return nil rather than
|
|
281
|
+
# raise, since "no secret" just means the feature is off.
|
|
282
|
+
#
|
|
283
|
+
# @param client_ip [String] Resolved full client IP (pre-masking)
|
|
284
|
+
# @return [String, nil] Hex HMAC-SHA256 digest, or nil when unconfigured
|
|
285
|
+
def correlation_hash(client_ip)
|
|
286
|
+
secret = @config.correlation_secret
|
|
287
|
+
return nil if secret.nil? || secret.empty?
|
|
288
|
+
|
|
289
|
+
Otto::Privacy::IPPrivacy.hash_ip(client_ip, secret)
|
|
290
|
+
end
|
|
161
291
|
|
|
162
292
|
# Set or clear a Rack env header in a SPEC-compliant way.
|
|
163
293
|
#
|
|
@@ -211,6 +341,51 @@ class Otto
|
|
|
211
341
|
Otto::Utils.resolve_client_ip(env, @security_config)
|
|
212
342
|
end
|
|
213
343
|
|
|
344
|
+
# Install env['otto.ip_match']: a verdict-only CIDR membership check
|
|
345
|
+
# over the resolved, UNMASKED client IP.
|
|
346
|
+
#
|
|
347
|
+
# This is the precision axis of the privacy design, decoupled from the
|
|
348
|
+
# observability axis (the privacy profile): policy code downstream —
|
|
349
|
+
# e.g. a per-tenant IP allowlist — can ask "is this client inside
|
|
350
|
+
# these ranges?" at full /32-/128 precision under ANY profile,
|
|
351
|
+
# including full masking. The unmasked address itself never lands in
|
|
352
|
+
# env; only this closure does, and a closure serializes to nothing
|
|
353
|
+
# useful, so env dumps, loggers, and error reporters that walk env
|
|
354
|
+
# cannot leak the IP accidentally.
|
|
355
|
+
#
|
|
356
|
+
# Threat model: the capability is a membership oracle, so deliberate
|
|
357
|
+
# in-process code could reconstruct the address via adaptive queries —
|
|
358
|
+
# but in-process code is already trusted (it could monkeypatch this
|
|
359
|
+
# middleware). The invariant defended is accidental persistence and
|
|
360
|
+
# serialization, and a Proc preserves it where a raw string could not.
|
|
361
|
+
#
|
|
362
|
+
# The closure is installed on every path that resolves an IP (masked,
|
|
363
|
+
# private-exempt, and privacy-disabled). When the request has no
|
|
364
|
+
# resolvable client IP the check returns false — fail-closed for
|
|
365
|
+
# allowlist callers. Invalid CIDR entries raise (configuration error);
|
|
366
|
+
# see Otto::Utils.ip_in_cidrs?.
|
|
367
|
+
#
|
|
368
|
+
# @param env [Hash] Rack environment
|
|
369
|
+
# @param client_ip [String, nil] resolved, unmasked client IP
|
|
370
|
+
def install_ip_match(env, client_ip)
|
|
371
|
+
env['otto.ip_match'] = ->(cidrs) { Otto::Utils.ip_in_cidrs?(client_ip, cidrs) }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Delete forwarded IP headers outright.
|
|
375
|
+
#
|
|
376
|
+
# Used on the no-resolvable-client-IP path, where there is no masked IP
|
|
377
|
+
# to rewrite these to. Leaving them would leak a raw client address (in
|
|
378
|
+
# X-Forwarded-For / X-Real-IP / X-Client-IP / RFC 7239 Forwarded)
|
|
379
|
+
# downstream. Deleting is Rack-SPEC-safe: an absent CGI key is valid.
|
|
380
|
+
#
|
|
381
|
+
# @param env [Hash] Rack environment
|
|
382
|
+
def scrub_forwarded_headers(env)
|
|
383
|
+
env.delete('HTTP_X_FORWARDED_FOR')
|
|
384
|
+
env.delete('HTTP_X_REAL_IP')
|
|
385
|
+
env.delete('HTTP_X_CLIENT_IP')
|
|
386
|
+
env.delete('HTTP_FORWARDED')
|
|
387
|
+
end
|
|
388
|
+
|
|
214
389
|
# Mask X-Forwarded-For and related proxy headers
|
|
215
390
|
#
|
|
216
391
|
# Replaces forwarded IP headers with the masked IP to prevent leakage
|
|
@@ -233,6 +408,15 @@ class Otto
|
|
|
233
408
|
env['HTTP_X_REAL_IP'] = masked_ip if env['HTTP_X_REAL_IP']
|
|
234
409
|
env['HTTP_X_CLIENT_IP'] = masked_ip if env['HTTP_X_CLIENT_IP']
|
|
235
410
|
|
|
411
|
+
# RFC 7239 Forwarded carries the client IP in a structured `for=`
|
|
412
|
+
# token, and Otto reads it as an authoritative client-IP source in
|
|
413
|
+
# count-based depth mode (trusted_proxy_header 'Forwarded'/'Both').
|
|
414
|
+
# Left as-is it would leak the real IP to downstream code. Redact only
|
|
415
|
+
# the `for=` value(s) so proto=/host=/by= metadata survives.
|
|
416
|
+
if env['HTTP_FORWARDED']
|
|
417
|
+
env['HTTP_FORWARDED'] = Otto::Privacy::IPPrivacy.mask_forwarded_for(env['HTTP_FORWARDED'], masked_ip)
|
|
418
|
+
end
|
|
419
|
+
|
|
236
420
|
Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
|
|
237
421
|
end
|
|
238
422
|
|
|
@@ -246,6 +430,34 @@ class Otto
|
|
|
246
430
|
@security_config.trusted_proxy?(ip)
|
|
247
431
|
end
|
|
248
432
|
|
|
433
|
+
# Whether request geo headers may be trusted for this request.
|
|
434
|
+
#
|
|
435
|
+
# Geo headers (CF-IPCountry and friends, plus any app-configured header)
|
|
436
|
+
# are client-spoofable unless the request actually arrived through the
|
|
437
|
+
# CDN/proxy that sets them. So Otto trusts them ONLY when it can verify
|
|
438
|
+
# that origin: a request that arrived via a configured CIDR trusted
|
|
439
|
+
# proxy (identity checked against REMOTE_ADDR).
|
|
440
|
+
#
|
|
441
|
+
# Every other case is untrusted, and geo falls to the local database /
|
|
442
|
+
# custom resolver:
|
|
443
|
+
# - Count-based depth mode: the hop setting the header can't be verified
|
|
444
|
+
# as a geo-CDN (depth proxies are often plain load balancers), and
|
|
445
|
+
# depth configures no CIDR matchers, so trusted_proxies_configured? is
|
|
446
|
+
# false here too.
|
|
447
|
+
# - No trusted-proxy configuration: the header is client-supplied and
|
|
448
|
+
# unverifiable. Deployments behind a real CDN should configure
|
|
449
|
+
# trusted_proxies (or a local database) to get header-based geo.
|
|
450
|
+
#
|
|
451
|
+
# @param env [Hash] Rack environment
|
|
452
|
+
# @return [Boolean]
|
|
453
|
+
def geo_headers_trusted?(env)
|
|
454
|
+
sc = @security_config
|
|
455
|
+
return false unless sc.respond_to?(:trusted_proxies_configured?)
|
|
456
|
+
return false unless sc.trusted_proxies_configured?
|
|
457
|
+
|
|
458
|
+
env['otto.via_trusted_proxy'] == true
|
|
459
|
+
end
|
|
460
|
+
|
|
249
461
|
# Apply no-privacy settings (privacy explicitly disabled)
|
|
250
462
|
#
|
|
251
463
|
# When privacy is disabled, original IP is available for
|
|
@@ -256,7 +468,12 @@ class Otto
|
|
|
256
468
|
# Resolve the canonical client IP once, even with privacy disabled, so
|
|
257
469
|
# downstream code can read env['otto.client_ip'] instead of re-deriving
|
|
258
470
|
# it from REMOTE_ADDR / forwarded headers.
|
|
259
|
-
|
|
471
|
+
client_ip = resolve_client_ip(env)
|
|
472
|
+
env['otto.client_ip'] = client_ip
|
|
473
|
+
|
|
474
|
+
# Same precision capability as the privacy-enabled paths, so policy
|
|
475
|
+
# code has one interface regardless of profile.
|
|
476
|
+
install_ip_match(env, client_ip)
|
|
260
477
|
|
|
261
478
|
# Store original values for explicit access when privacy is disabled
|
|
262
479
|
if env['REMOTE_ADDR']
|
|
@@ -80,9 +80,15 @@ class Otto
|
|
|
80
80
|
# Log blocked requests if ActiveSupport is available
|
|
81
81
|
return unless defined?(ActiveSupport::Notifications)
|
|
82
82
|
|
|
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).
|
|
83
88
|
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
|
|
84
89
|
req = payload[:request]
|
|
85
|
-
Otto.
|
|
90
|
+
ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
|
|
91
|
+
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
86
92
|
end
|
|
87
93
|
end
|
|
88
94
|
end
|
data/lib/otto/security.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative 'security/authorization_error'
|
|
|
9
9
|
require_relative 'security/config'
|
|
10
10
|
require_relative 'security/configurator'
|
|
11
11
|
require_relative 'security/middleware/csrf_middleware'
|
|
12
|
+
require_relative 'security/csrf_enforcement_wrapper'
|
|
12
13
|
require_relative 'security/middleware/validation_middleware'
|
|
13
14
|
require_relative 'security/middleware/rate_limit_middleware'
|
|
14
15
|
require_relative 'security/middleware/ip_privacy_middleware'
|