otto 2.6.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 +218 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +12 -10
- 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/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/env_keys.rb +32 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/privacy/config.rb +245 -3
- data/lib/otto/privacy/core.rb +93 -14
- 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 +54 -2
- data/lib/otto/privacy.rb +3 -1
- data/lib/otto/request.rb +8 -1
- 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 +23 -1
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +201 -12
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +11 -3
- metadata +6 -6
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
require 'ipaddr'
|
|
6
|
-
|
|
7
5
|
require_relative '../utils'
|
|
8
6
|
|
|
9
7
|
class Otto
|
|
@@ -22,11 +20,15 @@ class Otto
|
|
|
22
20
|
#
|
|
23
21
|
# == Security: authenticate the RAW peer, not the resolved client IP
|
|
24
22
|
#
|
|
25
|
-
# The guard
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
23
|
+
# The guard authenticates the TCP socket peer as it arrived, before
|
|
24
|
+
# +IPPrivacyMiddleware+ rewrites +REMOTE_ADDR+ from forwarded headers.
|
|
25
|
+
# +IPPrivacyMiddleware+ is pinned OUTERMOST (issue #219), so it runs ahead
|
|
26
|
+
# of this guard and records its verdict on the untouched peer as
|
|
27
|
+
# +env['otto.peer_loopback']+ — a boolean, never an address. The guard reads
|
|
28
|
+
# that record when present and falls back to evaluating +REMOTE_ADDR+
|
|
29
|
+
# itself when it is not (no Otto privacy middleware in the stack, or the
|
|
30
|
+
# guard mounted outside Otto). Either way the decision is made on the raw
|
|
31
|
+
# peer.
|
|
30
32
|
#
|
|
31
33
|
# Reading Otto's resolved +otto.client_ip+ (or the rewritten +REMOTE_ADDR+)
|
|
32
34
|
# would be exploitable: a co-located reverse proxy on loopback is itself a
|
|
@@ -92,11 +94,28 @@ class Otto
|
|
|
92
94
|
# @param env [Hash] Rack environment
|
|
93
95
|
# @return [Boolean]
|
|
94
96
|
def direct_local_call?(env)
|
|
95
|
-
loopback_peer?(env
|
|
97
|
+
loopback_peer?(env) && !relayed?(env)
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
# Whether any forwarding header is present (request came via a proxy).
|
|
99
101
|
#
|
|
102
|
+
# Unlike the peer check, this reads header STATE, which IPPrivacyMiddleware
|
|
103
|
+
# has already touched by the time the guard runs. That is safe in both of
|
|
104
|
+
# its paths, but only for a reason worth writing down:
|
|
105
|
+
#
|
|
106
|
+
# - Masking REWRITES a forwarded header to the masked IP rather than
|
|
107
|
+
# removing it, so a relayed request still looks relayed. Correct — it was.
|
|
108
|
+
# - The no-resolvable-client-IP path DELETES them, which would make a
|
|
109
|
+
# relayed request look direct. That path is reached only when REMOTE_ADDR
|
|
110
|
+
# is absent or blank, which forces otto.peer_loopback to false, so
|
|
111
|
+
# #direct_local_call? denies on the peer check before this one matters.
|
|
112
|
+
#
|
|
113
|
+
# So header deletion upstream cannot turn a deny into an allow — but that
|
|
114
|
+
# rests on the peer check failing closed for a blank address. Anything that
|
|
115
|
+
# makes an unresolvable-IP request keep a loopback peer verdict would need
|
|
116
|
+
# to record the relay state pre-scrub too (an otto.peer_relayed sibling to
|
|
117
|
+
# otto.peer_loopback).
|
|
118
|
+
#
|
|
100
119
|
# @param env [Hash] Rack environment
|
|
101
120
|
# @return [Boolean]
|
|
102
121
|
def relayed?(env)
|
|
@@ -125,28 +144,27 @@ class Otto
|
|
|
125
144
|
Otto::Utils.normalize_path(path)
|
|
126
145
|
end
|
|
127
146
|
|
|
128
|
-
# Whether the connecting peer is a loopback address.
|
|
129
|
-
# blank or otherwise unparseable value is treated as non-loopback
|
|
130
|
-
# (denied) rather than raising on the hot path.
|
|
147
|
+
# Whether the connecting peer is a loopback address.
|
|
131
148
|
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
149
|
+
# Prefers +env['otto.peer_loopback']+ — IPPrivacyMiddleware's verdict on
|
|
150
|
+
# the ORIGINAL peer, recorded before it rewrites +REMOTE_ADDR+ (it runs
|
|
151
|
+
# outermost, so by the time this guard sees the env the address may
|
|
152
|
+
# already be the resolved-and-masked client IP). Only a real Boolean is
|
|
153
|
+
# honored; anything else falls through to evaluating +REMOTE_ADDR+, which
|
|
154
|
+
# is the correct source when no privacy middleware ran.
|
|
135
155
|
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
156
|
+
# Both paths share +Otto::Utils.loopback_address?+, so the recorded
|
|
157
|
+
# verdict and the fallback cannot disagree. It fails closed: a blank,
|
|
158
|
+
# ported, or unparseable value is treated as non-loopback (denied) rather
|
|
159
|
+
# than raising on the hot path.
|
|
140
160
|
#
|
|
141
|
-
# @param
|
|
161
|
+
# @param env [Hash] Rack environment
|
|
142
162
|
# @return [Boolean]
|
|
143
|
-
def loopback_peer?(
|
|
144
|
-
|
|
145
|
-
return
|
|
163
|
+
def loopback_peer?(env)
|
|
164
|
+
recorded = env['otto.peer_loopback']
|
|
165
|
+
return recorded if [true, false].include?(recorded)
|
|
146
166
|
|
|
147
|
-
|
|
148
|
-
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
149
|
-
false
|
|
167
|
+
Otto::Utils.loopback_address?(env['REMOTE_ADDR'])
|
|
150
168
|
end
|
|
151
169
|
|
|
152
170
|
# @return [Array] 401 Rack response tuple
|
|
@@ -13,12 +13,24 @@ class Otto
|
|
|
13
13
|
include Enumerable
|
|
14
14
|
include Otto::Core::Freezable
|
|
15
15
|
|
|
16
|
+
# Pin tiers honored by #ordered_stack, outward-ascending. #wrap folds the
|
|
17
|
+
# stack with reduce, so a LATER array position is a FURTHER OUT wrapper;
|
|
18
|
+
# sorting by tier therefore sorts by how early the middleware sees the
|
|
19
|
+
# request. Unpinned entries are tier 0 and keep their insertion order.
|
|
20
|
+
#
|
|
21
|
+
# A tier is recorded on the ENTRY (as entry[:pin_tier]), not on the
|
|
22
|
+
# middleware class. Entries are identified by (class, args, options), so
|
|
23
|
+
# the same class can legitimately be registered more than once with
|
|
24
|
+
# different configuration; a class-wide pin would drag those other
|
|
25
|
+
# registrations into the pinned tier along with it.
|
|
26
|
+
PIN_TIERS = {
|
|
27
|
+
outermost: 1,
|
|
28
|
+
entrypoint: 2,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
16
31
|
def initialize
|
|
17
32
|
@stack = []
|
|
18
33
|
@middleware_set = Set.new
|
|
19
|
-
# Classes pinned to run OUTERMOST regardless of insertion order (see
|
|
20
|
-
# the :outermost position in #add_with_position and #ordered_stack).
|
|
21
|
-
@outermost = Set.new
|
|
22
34
|
@on_change_callback = nil
|
|
23
35
|
end
|
|
24
36
|
|
|
@@ -52,8 +64,15 @@ class Otto
|
|
|
52
64
|
|
|
53
65
|
# Add middleware with position hint for optimal ordering
|
|
54
66
|
#
|
|
55
|
-
# Positions
|
|
56
|
-
#
|
|
67
|
+
# Positions name a place in the ARRAY; #wrap folds the array with reduce,
|
|
68
|
+
# so array order is the REVERSE of execution order — the last entry is the
|
|
69
|
+
# outermost wrapper and therefore the first to see a request.
|
|
70
|
+
#
|
|
71
|
+
# - :first/:innermost — innermost: the LAST middleware to see the request,
|
|
72
|
+
# closest to the app. Note the trap in the older `:first`
|
|
73
|
+
# spelling: it is first-in-array, hence last-to-execute.
|
|
74
|
+
# `:innermost` says the same thing in execution terms and is
|
|
75
|
+
# the preferred spelling.
|
|
57
76
|
# - :last/nil — append (outermost among currently-registered middleware,
|
|
58
77
|
# but a later append displaces it)
|
|
59
78
|
# - :outermost — pin to run OUTERMOST (first to see the request) and STAY
|
|
@@ -62,10 +81,18 @@ class Otto
|
|
|
62
81
|
# at build time. Use for middleware that must short-circuit
|
|
63
82
|
# ahead of everything else (e.g. the CSP report receiver,
|
|
64
83
|
# which must intercept before CSRF).
|
|
84
|
+
# - :entrypoint — pin OUTSIDE even the :outermost tier: the very first
|
|
85
|
+
# middleware to touch a request. Reserved for middleware
|
|
86
|
+
# that must normalize the request before anything else can
|
|
87
|
+
# observe it. Otto pins IPPrivacyMiddleware here so every
|
|
88
|
+
# other middleware — its own, an :outermost pin, and
|
|
89
|
+
# anything the app adds via Otto#use — reads a masked
|
|
90
|
+
# REMOTE_ADDR and the canonical env['otto.client_ip'].
|
|
65
91
|
#
|
|
66
92
|
# @param middleware_class [Class] Middleware class
|
|
67
93
|
# @param args [Array] Middleware arguments
|
|
68
|
-
# @param position [Symbol, nil] Position hint (:first, :
|
|
94
|
+
# @param position [Symbol, nil] Position hint (:first, :innermost, :last,
|
|
95
|
+
# :outermost, :entrypoint, or nil)
|
|
69
96
|
def add_with_position(middleware_class, *args, position: nil, **options)
|
|
70
97
|
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
|
71
98
|
|
|
@@ -81,11 +108,10 @@ class Otto
|
|
|
81
108
|
entry = { middleware: middleware_class, args: args, options: options }
|
|
82
109
|
|
|
83
110
|
case position
|
|
84
|
-
when :first
|
|
111
|
+
when :first, :innermost
|
|
85
112
|
@stack.unshift(entry)
|
|
86
|
-
when
|
|
87
|
-
@stack << entry
|
|
88
|
-
@outermost.add(middleware_class)
|
|
113
|
+
when *PIN_TIERS.keys
|
|
114
|
+
@stack << entry.merge(pin_tier: PIN_TIERS.fetch(position))
|
|
89
115
|
else
|
|
90
116
|
@stack << entry # :last / nil — default append
|
|
91
117
|
end
|
|
@@ -160,9 +186,9 @@ class Otto
|
|
|
160
186
|
# Update middleware set if any matching entries were found
|
|
161
187
|
return unless matches
|
|
162
188
|
|
|
163
|
-
# Rebuild the set of unique middleware classes
|
|
189
|
+
# Rebuild the set of unique middleware classes. Pins need no cleanup:
|
|
190
|
+
# each removed entry took its own tier with it.
|
|
164
191
|
@middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
|
|
165
|
-
@outermost.delete(middleware_class)
|
|
166
192
|
# Notify of change
|
|
167
193
|
@on_change_callback&.call
|
|
168
194
|
end
|
|
@@ -179,7 +205,6 @@ class Otto
|
|
|
179
205
|
|
|
180
206
|
@stack.clear
|
|
181
207
|
@middleware_set.clear
|
|
182
|
-
@outermost.clear
|
|
183
208
|
# Notify of change
|
|
184
209
|
@on_change_callback&.call
|
|
185
210
|
end
|
|
@@ -192,9 +217,15 @@ class Otto
|
|
|
192
217
|
# Build Rack application with middleware chain
|
|
193
218
|
#
|
|
194
219
|
# The stack folds via reduce, so the LAST entry becomes the OUTERMOST
|
|
195
|
-
# wrapper (first to see the request). #ordered_stack moves any
|
|
196
|
-
#
|
|
197
|
-
# order middleware was registered in.
|
|
220
|
+
# wrapper (first to see the request). #ordered_stack moves any pinned
|
|
221
|
+
# middleware (:outermost, :entrypoint) to the end so it stays outermost
|
|
222
|
+
# regardless of the order middleware was registered in.
|
|
223
|
+
#
|
|
224
|
+
# NOT a request-path method. Its only caller is Otto's build_app!, which
|
|
225
|
+
# runs at construction and again whenever the stack changes; requests are
|
|
226
|
+
# served by the chain it returns. So the ordering work here (and in
|
|
227
|
+
# #ordered_stack) is per-BUILD, not per-request — don't add caching
|
|
228
|
+
# machinery on the assumption that it is hot.
|
|
198
229
|
def wrap(base_app, security_config = nil)
|
|
199
230
|
ordered_stack.reduce(base_app) do |app, entry|
|
|
200
231
|
middleware = entry[:middleware]
|
|
@@ -215,11 +246,23 @@ class Otto
|
|
|
215
246
|
end
|
|
216
247
|
end
|
|
217
248
|
|
|
218
|
-
# Returns list of middleware classes in order
|
|
249
|
+
# Returns list of middleware classes in REGISTRATION order — the order
|
|
250
|
+
# they were added, which is the reverse of execution order and ignores pin
|
|
251
|
+
# tiers. Use #execution_order to see what actually runs first.
|
|
219
252
|
def middleware_list
|
|
220
253
|
@stack.map { |entry| entry[:middleware] }
|
|
221
254
|
end
|
|
222
255
|
|
|
256
|
+
# Returns middleware classes in EXECUTION order: the first entry is the
|
|
257
|
+
# outermost wrapper #wrap builds, i.e. the first to see a request. This is
|
|
258
|
+
# #middleware_list resolved through the pin tiers and reversed, so it
|
|
259
|
+
# answers "what does this stack actually do?" without building the app.
|
|
260
|
+
#
|
|
261
|
+
# @return [Array<Class>] outermost (first to execute) first
|
|
262
|
+
def execution_order
|
|
263
|
+
ordered_stack.reverse.map { |entry| entry[:middleware] }
|
|
264
|
+
end
|
|
265
|
+
|
|
223
266
|
# Detailed introspection
|
|
224
267
|
def middleware_details
|
|
225
268
|
@stack.map do |entry|
|
|
@@ -254,16 +297,20 @@ class Otto
|
|
|
254
297
|
|
|
255
298
|
private
|
|
256
299
|
|
|
257
|
-
# The stack ordered for #wrap: identical to @stack unless some
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
#
|
|
300
|
+
# The stack ordered for #wrap: identical to @stack unless some entry is
|
|
301
|
+
# pinned, in which case entries are sorted by pin tier (PIN_TIERS,
|
|
302
|
+
# outward-ascending) so pinned entries move to the end (outermost) while
|
|
303
|
+
# the relative order within every tier is preserved. Ruby's sort_by is not
|
|
304
|
+
# stable, hence the explicit index tiebreak. Returns @stack itself (no
|
|
305
|
+
# copy) in the common no-pin case, so ordinary apps are unaffected.
|
|
306
|
+
#
|
|
307
|
+
# Runs per build, not per request — see #wrap.
|
|
262
308
|
def ordered_stack
|
|
263
|
-
return @stack if @
|
|
309
|
+
return @stack if @stack.none? { |entry| entry[:pin_tier] }
|
|
264
310
|
|
|
265
|
-
|
|
266
|
-
|
|
311
|
+
@stack.each_with_index
|
|
312
|
+
.sort_by { |entry, index| [entry[:pin_tier] || 0, index] }
|
|
313
|
+
.map(&:first)
|
|
267
314
|
end
|
|
268
315
|
|
|
269
316
|
def middleware_needs_config?(middleware_class)
|
data/lib/otto/env_keys.rb
CHANGED
|
@@ -82,6 +82,17 @@ class Otto
|
|
|
82
82
|
# without depending on the (masked) REMOTE_ADDR
|
|
83
83
|
VIA_TRUSTED_PROXY = 'otto.via_trusted_proxy'
|
|
84
84
|
|
|
85
|
+
# Whether the connecting peer was the loopback interface.
|
|
86
|
+
# Type: Boolean
|
|
87
|
+
# Set by: IPPrivacyMiddleware (every request, evaluated on the ORIGINAL
|
|
88
|
+
# socket peer BEFORE REMOTE_ADDR is masked/rewritten). A boolean, never
|
|
89
|
+
# an address, so it carries no identifying data.
|
|
90
|
+
# Used by: Otto::CaddyTLS::LocalhostGuard to authenticate a direct local
|
|
91
|
+
# call now that IPPrivacyMiddleware runs outermost. Deliberately the raw
|
|
92
|
+
# peer, not the resolved client IP: forwarded headers must play no part
|
|
93
|
+
# in a localhost trust decision.
|
|
94
|
+
PEER_LOOPBACK = 'otto.peer_loopback'
|
|
95
|
+
|
|
85
96
|
# =========================================================================
|
|
86
97
|
# LOCALIZATION (I18N)
|
|
87
98
|
# =========================================================================
|
|
@@ -134,6 +145,27 @@ class Otto
|
|
|
134
145
|
# Note: presence also acts as the idempotency guard for the middleware
|
|
135
146
|
CLIENT_IP = 'otto.client_ip'
|
|
136
147
|
|
|
148
|
+
# Verdict-only CIDR membership check over the resolved, UNMASKED client IP
|
|
149
|
+
# Type: Proc — call with an Enumerable of CIDR strings or IPAddr objects,
|
|
150
|
+
# returns true/false
|
|
151
|
+
# Set by: IPPrivacyMiddleware (every path that resolves an IP, all
|
|
152
|
+
# privacy profiles)
|
|
153
|
+
# Used by: Downstream IP policy code (allowlists, denylists, network
|
|
154
|
+
# zones) that needs full /32-/128 precision without changing the
|
|
155
|
+
# observability posture — CLIENT_IP is masked under the default
|
|
156
|
+
# profile, so it cannot express a single host
|
|
157
|
+
# Note: the unmasked IP never lands in env; only this closure does, and a
|
|
158
|
+
# Proc serializes to nothing useful, so env dumps and loggers cannot
|
|
159
|
+
# leak the address accidentally. Returns false when the request had
|
|
160
|
+
# no resolvable client IP (fail-closed for allowlist callers);
|
|
161
|
+
# raises IPAddr::InvalidAddressError for invalid CIDR entries
|
|
162
|
+
# (configuration error). See Otto::Utils.ip_in_cidrs?.
|
|
163
|
+
# Note: setting CLIENT_IP yourself is out of contract — it trips the
|
|
164
|
+
# middleware's idempotency guard, so the unmasked address is never
|
|
165
|
+
# captured and this capability degrades to a logged fail-closed
|
|
166
|
+
# check that denies every range.
|
|
167
|
+
IP_MATCH = 'otto.ip_match'
|
|
168
|
+
|
|
137
169
|
# Privacy-safe masked IP address
|
|
138
170
|
# Type: String (e.g., '192.168.1.0')
|
|
139
171
|
# Set by: IPPrivacyMiddleware
|
data/lib/otto/logging_helpers.rb
CHANGED
|
@@ -76,12 +76,61 @@ class Otto
|
|
|
76
76
|
{
|
|
77
77
|
method: env['REQUEST_METHOD'],
|
|
78
78
|
path: env['PATH_INFO'],
|
|
79
|
-
ip: env
|
|
79
|
+
ip: privacy_safe_ip(env), # Canonical client IP (masked when privacy on)
|
|
80
80
|
country: env['otto.privacy.geo_country'],
|
|
81
81
|
user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
|
|
82
82
|
}.compact
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# A client IP that is safe to write to a log.
|
|
86
|
+
#
|
|
87
|
+
# Prefers the canonical env['otto.client_ip'] that IPPrivacyMiddleware
|
|
88
|
+
# resolves once per request: masked when privacy is on, and deliberately
|
|
89
|
+
# the real address when the app has disabled privacy. That key is present
|
|
90
|
+
# for anything running inside Otto, since the middleware is pinned
|
|
91
|
+
# outermost.
|
|
92
|
+
#
|
|
93
|
+
# It is ABSENT for code that runs outside Otto's stack — notably
|
|
94
|
+
# Rack::Attack, which the hosting app mounts ahead of Otto (see
|
|
95
|
+
# Otto::Security::Middleware::RateLimitMiddleware), so its 'rack.attack'
|
|
96
|
+
# subscriber observes the raw peer no matter how Otto orders its own
|
|
97
|
+
# middleware. There we mask the address ourselves rather than log it whole:
|
|
98
|
+
# a public IP is reduced with the default precision (privacy config is not
|
|
99
|
+
# reachable from a global subscriber), and private/localhost addresses pass
|
|
100
|
+
# through unmasked, matching the middleware's own exemption so development
|
|
101
|
+
# logs stay readable.
|
|
102
|
+
#
|
|
103
|
+
# Never raises and never returns a raw public address: an unparseable value
|
|
104
|
+
# (a ported REMOTE_ADDR, a malformed forwarded entry) becomes '[redacted]'.
|
|
105
|
+
#
|
|
106
|
+
# @param env [Hash] Rack environment hash
|
|
107
|
+
# @param fallback_ip [String, nil] address to fall back to when the env
|
|
108
|
+
# carries no canonical client IP — e.g. Rack::Request#ip, which factors in
|
|
109
|
+
# forwarded headers. Defaults to REMOTE_ADDR.
|
|
110
|
+
# @return [String, nil] log-safe address, or nil when there is none
|
|
111
|
+
def self.privacy_safe_ip(env, fallback_ip = nil)
|
|
112
|
+
canonical = env['otto.client_ip']
|
|
113
|
+
return canonical unless canonical.to_s.empty?
|
|
114
|
+
|
|
115
|
+
candidate = fallback_ip.to_s.empty? ? env['REMOTE_ADDR'] : fallback_ip
|
|
116
|
+
redact_ip(candidate)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reduce a raw address to a log-safe form. See .privacy_safe_ip.
|
|
120
|
+
#
|
|
121
|
+
# @param ip [String, nil] raw address
|
|
122
|
+
# @return [String, nil] masked address, the address itself when
|
|
123
|
+
# private/localhost, '[redacted]' when unparseable, nil when blank
|
|
124
|
+
def self.redact_ip(ip)
|
|
125
|
+
address = ip.to_s.strip
|
|
126
|
+
return nil if address.empty?
|
|
127
|
+
return address if Otto::Privacy::IPPrivacy.private_or_localhost?(address)
|
|
128
|
+
|
|
129
|
+
Otto::Privacy::IPPrivacy.mask_ip(address) || '[redacted]'
|
|
130
|
+
rescue ArgumentError
|
|
131
|
+
'[redacted]'
|
|
132
|
+
end
|
|
133
|
+
|
|
85
134
|
# Log a timed operation with consistent timing and error handling
|
|
86
135
|
#
|
|
87
136
|
# @param level [Symbol] The log level (:debug, :info, :warn, :error)
|
|
@@ -113,14 +113,17 @@ class Otto
|
|
|
113
113
|
def self.configure_mcp_logging
|
|
114
114
|
return unless defined?(ActiveSupport::Notifications)
|
|
115
115
|
|
|
116
|
+
# Masked address only — see the note on the sibling subscriber in
|
|
117
|
+
# Otto::Security::RateLimiting.configure_rack_attack! (issue #219).
|
|
116
118
|
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
|
|
117
119
|
req = payload[:request]
|
|
118
120
|
endpoint = req.env['otto.mcp_http_endpoint'] || '/_mcp'
|
|
121
|
+
ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
|
|
119
122
|
|
|
120
123
|
if req.path.start_with?(endpoint)
|
|
121
|
-
Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{
|
|
124
|
+
Otto.logger.warn "[MCP] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
122
125
|
else
|
|
123
|
-
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{
|
|
126
|
+
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
124
127
|
end
|
|
125
128
|
end
|
|
126
129
|
end
|