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
|
@@ -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/utils.rb
CHANGED
|
@@ -320,5 +320,105 @@ class Otto
|
|
|
320
320
|
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
321
321
|
false
|
|
322
322
|
end
|
|
323
|
+
|
|
324
|
+
# Whether an address falls inside any of the given CIDR ranges.
|
|
325
|
+
#
|
|
326
|
+
# The general-purpose CIDR-set matcher (allowlists, denylists, network
|
|
327
|
+
# zones), sharing the semantics of the trusted-proxy matcher: the client
|
|
328
|
+
# address is normalized (port stripped, validated) and folded via
|
|
329
|
+
# IPAddr#native so an IPv4-mapped IPv6 peer (::ffff:203.0.113.7) matches
|
|
330
|
+
# an IPv4 range; ranges of the other address family are skipped rather
|
|
331
|
+
# than raising.
|
|
332
|
+
#
|
|
333
|
+
# Ranges are folded through #native too, so the fold is symmetric: a
|
|
334
|
+
# mapped-IPv6 CIDR (::ffff:10.0.0.0/104) matches a plain IPv4 client just
|
|
335
|
+
# as a mapped client matches a plain IPv4 range. Folding only one side
|
|
336
|
+
# made the family check reject the pair and silently drop the entry —
|
|
337
|
+
# wrong verdict, not a raise. #native returns self for ranges that are
|
|
338
|
+
# not IPv4-mapped/compatible, so ordinary IPv4 and IPv6 CIDRs are
|
|
339
|
+
# untouched, and it returns a new IPAddr rather than mutating, so
|
|
340
|
+
# pre-parsed entries in a caller's configuration array stay intact.
|
|
341
|
+
#
|
|
342
|
+
# The fold needs the prefix to cover the mapped marker — /96 or longer.
|
|
343
|
+
# ::ffff:10.0.0.0/104 folds to 10.0.0.0/8; ::ffff:10.0.0.0/64 does not
|
|
344
|
+
# fold at all, because masking zeroes the ffff marker, and so matches
|
|
345
|
+
# neither an IPv4 client nor a mapped one. Write mapped ranges at /96+,
|
|
346
|
+
# or just write the IPv4 CIDR. ::ffff:0:0/96 is the whole mapped space
|
|
347
|
+
# and therefore matches every IPv4 address. Deprecated IPv4-compatible
|
|
348
|
+
# notation (::a.b.c.d) folds on the same terms, on both sides.
|
|
349
|
+
#
|
|
350
|
+
# Asymmetric strictness, on purpose:
|
|
351
|
+
# - `ip` is runtime data — nil, blank, or malformed input returns false
|
|
352
|
+
# (fail-closed for allowlist callers).
|
|
353
|
+
# - `cidrs` entries are configuration — an invalid CIDR string raises
|
|
354
|
+
# IPAddr::InvalidAddressError, because silently skipping an entry
|
|
355
|
+
# narrows an allowlist or widens a denylist. Validate entries at
|
|
356
|
+
# write/boot time; pre-parsed IPAddr entries skip re-parsing here.
|
|
357
|
+
#
|
|
358
|
+
# @param ip [String, IPAddr, nil] address to test (runtime data)
|
|
359
|
+
# @param cidrs [Enumerable<String, IPAddr>, nil] CIDR ranges or host
|
|
360
|
+
# addresses (configuration)
|
|
361
|
+
# @return [Boolean] true when ip is inside at least one range
|
|
362
|
+
# @raise [IPAddr::InvalidAddressError] if a cidrs entry is not a valid
|
|
363
|
+
# IP or CIDR string
|
|
364
|
+
def ip_in_cidrs?(ip, cidrs)
|
|
365
|
+
return false if cidrs.nil?
|
|
366
|
+
|
|
367
|
+
client =
|
|
368
|
+
if ip.is_a?(IPAddr)
|
|
369
|
+
ip.native
|
|
370
|
+
else
|
|
371
|
+
candidate = normalize_ip(ip&.to_s)
|
|
372
|
+
return false unless candidate
|
|
373
|
+
|
|
374
|
+
IPAddr.new(candidate).native
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
cidrs.any? do |entry|
|
|
378
|
+
range = entry.is_a?(IPAddr) ? entry : IPAddr.new(entry.to_s)
|
|
379
|
+
# IPAddr#native builds its result with #clone, which carries frozen
|
|
380
|
+
# state over and then fails to mutate it. Callers who freeze their
|
|
381
|
+
# range configuration (or pass it through Ractor.make_shareable) would
|
|
382
|
+
# hit FrozenError, so hand #native an unfrozen receiver. Gating the dup
|
|
383
|
+
# on a foldable-range predicate would cost more than it saves:
|
|
384
|
+
# #ipv4_compat? is deprecated and warns under -w, and #native already
|
|
385
|
+
# short-circuits to self for anything that does not fold.
|
|
386
|
+
range = range.dup if range.frozen?
|
|
387
|
+
range = range.native
|
|
388
|
+
range.family == client.family && range.include?(client)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Whether an address is on the loopback interface.
|
|
393
|
+
#
|
|
394
|
+
# This is the RAW SOCKET PEER test used to authenticate a direct local call
|
|
395
|
+
# (Otto::CaddyTLS::LocalhostGuard) — and, because IPPrivacyMiddleware now
|
|
396
|
+
# runs outermost and rewrites REMOTE_ADDR, the same test IPPrivacyMiddleware
|
|
397
|
+
# applies to the original peer and records as the leak-free boolean
|
|
398
|
+
# env['otto.peer_loopback']. Shared here so the pre-masking record and the
|
|
399
|
+
# guard's own fallback cannot drift.
|
|
400
|
+
#
|
|
401
|
+
# Fails closed: a blank or unparseable value is non-loopback rather than
|
|
402
|
+
# raising on the hot path.
|
|
403
|
+
#
|
|
404
|
+
# #native folds IPv4-mapped IPv6 (::ffff:127.0.0.1, which dual-stack servers
|
|
405
|
+
# commonly present) so it is recognized as loopback; plain IPAddr#loopback?
|
|
406
|
+
# returns false for the mapped form.
|
|
407
|
+
#
|
|
408
|
+
# Deliberately does NOT strip a ':port' suffix (unlike #private_ip?): a
|
|
409
|
+
# conforming Rack server reports the peer port in REMOTE_PORT, so an
|
|
410
|
+
# unexpected format means something upstream is non-standard and denying is
|
|
411
|
+
# safer than coercing.
|
|
412
|
+
#
|
|
413
|
+
# @param address [String, nil] raw socket peer address
|
|
414
|
+
# @return [Boolean]
|
|
415
|
+
def loopback_address?(address)
|
|
416
|
+
addr = address.to_s.strip
|
|
417
|
+
return false if addr.empty?
|
|
418
|
+
|
|
419
|
+
IPAddr.new(addr).native.loopback?
|
|
420
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
421
|
+
false
|
|
422
|
+
end
|
|
323
423
|
end
|
|
324
424
|
end
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -226,11 +226,19 @@ class Otto
|
|
|
226
226
|
# before first request (before configuration freezing)
|
|
227
227
|
finalize_request_response_classes
|
|
228
228
|
|
|
229
|
-
#
|
|
230
|
-
#
|
|
229
|
+
# IP Privacy is the ENTRY POINT of the stack: the first middleware to touch
|
|
230
|
+
# a request, so everything else — Otto's own middleware, an :outermost pin,
|
|
231
|
+
# and anything the app adds via Otto#use — observes the masked REMOTE_ADDR
|
|
232
|
+
# and the canonical env['otto.client_ip'] (privacy by default for public
|
|
233
|
+
# IPs; private/localhost IPs are automatically exempted from masking).
|
|
234
|
+
#
|
|
235
|
+
# NOT position: :first, which is first-in-ARRAY and therefore INNERMOST —
|
|
236
|
+
# it put IP masking closest to the app and left every other middleware
|
|
237
|
+
# reading the raw peer address (issue #219). See
|
|
238
|
+
# MiddlewareStack#add_with_position for the full position vocabulary.
|
|
231
239
|
@middleware.add_with_position(
|
|
232
240
|
Otto::Security::Middleware::IPPrivacyMiddleware,
|
|
233
|
-
position: :
|
|
241
|
+
position: :entrypoint
|
|
234
242
|
)
|
|
235
243
|
end
|
|
236
244
|
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: concurrent-ruby
|
|
@@ -144,6 +143,9 @@ files:
|
|
|
144
143
|
- changelog.d/README.md
|
|
145
144
|
- changelog.d/scriv.ini
|
|
146
145
|
- docs/.gitignore
|
|
146
|
+
- docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
|
|
147
|
+
- docs/1108-STREAMING_SUPPORT_SUMMARY.md
|
|
148
|
+
- docs/geo-country.md
|
|
147
149
|
- docs/ipaddr-encoding-quirk.md
|
|
148
150
|
- docs/migrating/v2.0.0-pre1.md
|
|
149
151
|
- docs/migrating/v2.0.0-pre2.md
|
|
@@ -327,7 +329,6 @@ licenses:
|
|
|
327
329
|
- MIT
|
|
328
330
|
metadata:
|
|
329
331
|
rubygems_mfa_required: 'true'
|
|
330
|
-
post_install_message:
|
|
331
332
|
rdoc_options: []
|
|
332
333
|
require_paths:
|
|
333
334
|
- lib
|
|
@@ -345,8 +346,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
345
346
|
- !ruby/object:Gem::Version
|
|
346
347
|
version: '0'
|
|
347
348
|
requirements: []
|
|
348
|
-
rubygems_version: 3.
|
|
349
|
-
signing_key:
|
|
349
|
+
rubygems_version: 3.6.9
|
|
350
350
|
specification_version: 4
|
|
351
351
|
summary: Define your rack-apps in plaintext.
|
|
352
352
|
test_files: []
|