otto 2.2.0 → 2.3.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 +2 -2
- data/.github/workflows/claude-code-review.yml +6 -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/CHANGELOG.rst +164 -0
- data/Gemfile.lock +1 -3
- data/docs/migrating/v2.3.0.md +241 -0
- data/lib/otto/core/configuration.rb +4 -0
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/helpers/validation.rb +3 -5
- data/lib/otto/logging_helpers.rb +2 -2
- data/lib/otto/mcp/auth/token.rb +12 -1
- data/lib/otto/mcp/registry.rb +3 -1
- data/lib/otto/mcp/server.rb +2 -1
- data/lib/otto/request.rb +64 -64
- data/lib/otto/route.rb +3 -43
- data/lib/otto/route_handlers/base.rb +3 -14
- data/lib/otto/security/authentication/route_auth_wrapper.rb +2 -2
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +13 -1
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -3
- data/lib/otto/security/config.rb +285 -32
- data/lib/otto/security/configurator.rb +15 -0
- data/lib/otto/security/constant_resolver.rb +73 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +3 -2
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +36 -52
- data/lib/otto/security/middleware/validation_middleware.rb +6 -15
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +170 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -1
- data/otto.gemspec +8 -1
- metadata +3 -21
|
@@ -15,7 +15,7 @@ class Otto
|
|
|
15
15
|
#
|
|
16
16
|
# @example Default behavior (privacy enabled)
|
|
17
17
|
# # env['REMOTE_ADDR'] is masked to 192.168.1.0
|
|
18
|
-
# # env['otto.
|
|
18
|
+
# # env['otto.privacy.fingerprint'] contains full anonymized data
|
|
19
19
|
# # env['otto.original_ip'] is NOT set
|
|
20
20
|
#
|
|
21
21
|
# @example Privacy disabled
|
|
@@ -42,6 +42,22 @@ class Otto
|
|
|
42
42
|
# @param env [Hash] Rack environment
|
|
43
43
|
# @return [Array] Rack response tuple [status, headers, body]
|
|
44
44
|
def call(env)
|
|
45
|
+
# Idempotency: if a prior IPPrivacyMiddleware pass already resolved the
|
|
46
|
+
# canonical client IP for this request, do not re-resolve or re-mask.
|
|
47
|
+
# This makes stacking two instances (e.g. an app-level mount plus
|
|
48
|
+
# Otto's built-in router mount) order-safe instead of double-masking.
|
|
49
|
+
return @app.call(env) if env.key?('otto.client_ip')
|
|
50
|
+
|
|
51
|
+
# Record the connecting peer's trust decision BEFORE any masking, so
|
|
52
|
+
# secure? can authorize X-Forwarded-Proto canonically even after
|
|
53
|
+
# REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
|
|
54
|
+
#
|
|
55
|
+
# This is the trusted-proxy *identity* check only — it is deliberately
|
|
56
|
+
# independent of count-based depth mode. Depth resolves the client IP;
|
|
57
|
+
# it never grants proxy trust for X-Forwarded-Proto (matching the
|
|
58
|
+
# downstream OneTimeSecret behavior).
|
|
59
|
+
env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
|
|
60
|
+
|
|
45
61
|
if @privacy_enabled
|
|
46
62
|
apply_privacy(env)
|
|
47
63
|
else
|
|
@@ -63,7 +79,8 @@ class Otto
|
|
|
63
79
|
#
|
|
64
80
|
# @param env [Hash] Rack environment
|
|
65
81
|
def apply_privacy(env)
|
|
66
|
-
# Resolve the actual client IP (handling proxies)
|
|
82
|
+
# Resolve the actual client IP once (handling proxies). This is the
|
|
83
|
+
# canonical resolution step; masking below operates on this value.
|
|
67
84
|
client_ip = resolve_client_ip(env)
|
|
68
85
|
|
|
69
86
|
Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
|
|
@@ -75,6 +92,8 @@ class Otto
|
|
|
75
92
|
# Update REMOTE_ADDR to the resolved client IP (even though it's not masked)
|
|
76
93
|
env['REMOTE_ADDR'] = client_ip
|
|
77
94
|
env['otto.original_ip'] = client_ip
|
|
95
|
+
# Canonical client IP downstream reads (exempt: not masked)
|
|
96
|
+
env['otto.client_ip'] = client_ip
|
|
78
97
|
# Don't mask forwarded headers for private IPs
|
|
79
98
|
Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
|
|
80
99
|
return
|
|
@@ -99,6 +118,10 @@ class Otto
|
|
|
99
118
|
# automatically uses the masked values without modification
|
|
100
119
|
env['REMOTE_ADDR'] = fingerprint.masked_ip
|
|
101
120
|
|
|
121
|
+
# Canonical client IP downstream reads ("resolve once, read everywhere").
|
|
122
|
+
# Privacy-safe: holds the masked value, never the original public IP.
|
|
123
|
+
env['otto.client_ip'] = fingerprint.masked_ip
|
|
124
|
+
|
|
102
125
|
# Replace User-Agent with anonymized version (consistent with IP masking)
|
|
103
126
|
# CRITICAL: Always replace, even if nil, to clear original sensitive data
|
|
104
127
|
env['HTTP_USER_AGENT'] = fingerprint.anonymized_ua
|
|
@@ -118,41 +141,17 @@ class Otto
|
|
|
118
141
|
end
|
|
119
142
|
|
|
120
143
|
|
|
121
|
-
# Resolve the actual client IP address from the request
|
|
144
|
+
# Resolve the actual client IP address from the request.
|
|
122
145
|
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
146
|
+
# Delegates to the shared Otto::Utils.resolve_client_ip so the
|
|
147
|
+
# middleware ("resolve once") and Otto::Request#client_ipaddress (its
|
|
148
|
+
# no-middleware fallback) use one canonical proxy-chain resolver and
|
|
149
|
+
# cannot drift on which headers are trusted.
|
|
126
150
|
#
|
|
127
151
|
# @param env [Hash] Rack environment
|
|
128
152
|
# @return [String] Resolved client IP address
|
|
129
153
|
def resolve_client_ip(env)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
# If we don't have a security config, use direct connection
|
|
133
|
-
return remote_addr unless @security_config
|
|
134
|
-
|
|
135
|
-
# If REMOTE_ADDR is not from a trusted proxy, it's the client IP
|
|
136
|
-
return remote_addr unless trusted_proxy?(remote_addr)
|
|
137
|
-
|
|
138
|
-
# REMOTE_ADDR is from a trusted proxy, check forwarded headers
|
|
139
|
-
forwarded_ips = [
|
|
140
|
-
env['HTTP_X_FORWARDED_FOR'],
|
|
141
|
-
env['HTTP_X_REAL_IP'],
|
|
142
|
-
env['HTTP_X_CLIENT_IP'],
|
|
143
|
-
].compact.map { |header| header.split(/,\s*/) }.flatten
|
|
144
|
-
|
|
145
|
-
# Return the first valid public IP from forwarded headers
|
|
146
|
-
forwarded_ips.each do |ip|
|
|
147
|
-
clean_ip = validate_ip_address(ip.strip)
|
|
148
|
-
next unless clean_ip
|
|
149
|
-
|
|
150
|
-
# Return first IP that's not from a trusted proxy
|
|
151
|
-
return clean_ip unless trusted_proxy?(clean_ip)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Fallback to remote address if no valid forwarded IPs
|
|
155
|
-
remote_addr
|
|
154
|
+
Otto::Utils.resolve_client_ip(env, @security_config)
|
|
156
155
|
end
|
|
157
156
|
|
|
158
157
|
# Mask X-Forwarded-For and related proxy headers
|
|
@@ -182,26 +181,6 @@ class Otto
|
|
|
182
181
|
@security_config.trusted_proxy?(ip)
|
|
183
182
|
end
|
|
184
183
|
|
|
185
|
-
# Validate and clean IP address
|
|
186
|
-
#
|
|
187
|
-
# @param ip [String, nil] IP address to validate
|
|
188
|
-
# @return [String, nil] Cleaned IP or nil if invalid
|
|
189
|
-
def validate_ip_address(ip)
|
|
190
|
-
return nil if ip.nil? || ip.empty?
|
|
191
|
-
|
|
192
|
-
# Remove any port number
|
|
193
|
-
clean_ip = ip.split(':').first
|
|
194
|
-
|
|
195
|
-
# Basic IPv4 format validation
|
|
196
|
-
return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
|
197
|
-
|
|
198
|
-
# Validate each octet
|
|
199
|
-
octets = clean_ip.split('.')
|
|
200
|
-
return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
|
|
201
|
-
|
|
202
|
-
clean_ip
|
|
203
|
-
end
|
|
204
|
-
|
|
205
184
|
# Apply no-privacy settings (privacy explicitly disabled)
|
|
206
185
|
#
|
|
207
186
|
# When privacy is disabled, original IP is available for
|
|
@@ -209,6 +188,11 @@ class Otto
|
|
|
209
188
|
#
|
|
210
189
|
# @param env [Hash] Rack environment
|
|
211
190
|
def apply_no_privacy(env)
|
|
191
|
+
# Resolve the canonical client IP once, even with privacy disabled, so
|
|
192
|
+
# downstream code can read env['otto.client_ip'] instead of re-deriving
|
|
193
|
+
# it from REMOTE_ADDR / forwarded headers.
|
|
194
|
+
env['otto.client_ip'] = resolve_client_ip(env)
|
|
195
|
+
|
|
212
196
|
# Store original values for explicit access when privacy is disabled
|
|
213
197
|
if env['REMOTE_ADDR']
|
|
214
198
|
env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
|
|
@@ -16,13 +16,11 @@ class Otto
|
|
|
16
16
|
NULL_BYTE = /\0/
|
|
17
17
|
|
|
18
18
|
# HTML/XSS sanitization is handled by Loofah library for better security coverage
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
/\d+\s*(=|>|<|>=|<=|<>|!=)\s*\d+/i,
|
|
25
|
-
].freeze
|
|
19
|
+
#
|
|
20
|
+
# NOTE: There is deliberately no SQL-injection pattern matching here.
|
|
21
|
+
# Input-validation blocklists for SQL are bypassable theater and produce
|
|
22
|
+
# false positives on legitimate input; the correct defense is
|
|
23
|
+
# parameterized queries / prepared statements at the data-access layer.
|
|
26
24
|
|
|
27
25
|
def initialize(app, config = nil)
|
|
28
26
|
@app = app
|
|
@@ -181,14 +179,7 @@ class Otto
|
|
|
181
179
|
sanitized = Loofah.fragment(original).scrub!(:whitewash).to_s
|
|
182
180
|
|
|
183
181
|
# Remove control characters (sanitize, don't block)
|
|
184
|
-
sanitized
|
|
185
|
-
|
|
186
|
-
# Check for SQL injection patterns
|
|
187
|
-
SQL_INJECTION_PATTERNS.each do |pattern|
|
|
188
|
-
raise Otto::Security::ValidationError, 'Potential SQL injection detected' if sanitized.match?(pattern)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
sanitized
|
|
182
|
+
sanitized.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
|
|
192
183
|
end
|
|
193
184
|
|
|
194
185
|
include Otto::Security::ValidationHelpers
|
data/lib/otto/security.rb
CHANGED
data/lib/otto/utils.rb
CHANGED
|
@@ -2,11 +2,32 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require 'ipaddr'
|
|
6
|
+
|
|
5
7
|
class Otto
|
|
6
8
|
# Utility methods for common operations and helpers
|
|
7
9
|
module Utils
|
|
8
10
|
extend self
|
|
9
11
|
|
|
12
|
+
# Forwarded-for style headers consulted (in order) when resolving the real
|
|
13
|
+
# client IP from behind a trusted proxy. Shared by IPPrivacyMiddleware and
|
|
14
|
+
# Otto::Request so the two resolvers cannot drift.
|
|
15
|
+
FORWARDED_FOR_HEADERS = %w[
|
|
16
|
+
HTTP_X_FORWARDED_FOR
|
|
17
|
+
HTTP_X_REAL_IP
|
|
18
|
+
HTTP_X_CLIENT_IP
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
|
|
22
|
+
# predicates do not cover but that should still be treated as non-public
|
|
23
|
+
# (e.g. when picking the real client out of a forwarded chain).
|
|
24
|
+
SPECIAL_USE_RANGES = [
|
|
25
|
+
IPAddr.new('0.0.0.0/8'), # "this" network / unspecified (IPv4)
|
|
26
|
+
IPAddr.new('224.0.0.0/4'), # IPv4 multicast
|
|
27
|
+
IPAddr.new('::/128'), # IPv6 unspecified
|
|
28
|
+
IPAddr.new('ff00::/8'), # IPv6 multicast
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
10
31
|
# @return [Time] Current time in UTC
|
|
11
32
|
def now
|
|
12
33
|
Time.now.utc
|
|
@@ -35,5 +56,154 @@ class Otto
|
|
|
35
56
|
def yes?(value)
|
|
36
57
|
!value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
|
|
37
58
|
end
|
|
59
|
+
|
|
60
|
+
# Validate and normalize an IP address (IPv4 and IPv6).
|
|
61
|
+
#
|
|
62
|
+
# Strips an optional port (IPv6-safe), validates with IPAddr, and returns
|
|
63
|
+
# the cleaned address string, or nil if the input is blank or malformed.
|
|
64
|
+
#
|
|
65
|
+
# @param ip [String, nil] candidate address, optionally with a port
|
|
66
|
+
# @return [String, nil] cleaned IP string, or nil if invalid
|
|
67
|
+
def normalize_ip(ip)
|
|
68
|
+
return nil if ip.nil? || ip.empty?
|
|
69
|
+
|
|
70
|
+
candidate = strip_ip_port(ip.strip)
|
|
71
|
+
return nil if candidate.nil? || candidate.empty?
|
|
72
|
+
|
|
73
|
+
# IPAddr validates both IPv4 and IPv6; raises for malformed input
|
|
74
|
+
IPAddr.new(candidate)
|
|
75
|
+
candidate
|
|
76
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Strip an optional port without corrupting IPv6 addresses.
|
|
81
|
+
#
|
|
82
|
+
# Handles bracketed IPv6 with a port (`[2001:db8::1]:443`) and IPv4
|
|
83
|
+
# host:port (`203.0.113.5:443`). A bare IPv6 address (multiple colons,
|
|
84
|
+
# no brackets) is returned unchanged.
|
|
85
|
+
#
|
|
86
|
+
# @param ip [String] candidate address, possibly including a port
|
|
87
|
+
# @return [String] address with any port removed
|
|
88
|
+
def strip_ip_port(ip)
|
|
89
|
+
if ip.start_with?('[')
|
|
90
|
+
inner = ip[/\A\[([^\]]+)\]/, 1]
|
|
91
|
+
return inner if inner
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return ip.split(':', 2).first if ip.count(':') == 1
|
|
95
|
+
|
|
96
|
+
ip
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Resolve the real client IP from a Rack env, honoring forwarded headers
|
|
100
|
+
# only when the connecting peer (REMOTE_ADDR) is a trusted proxy.
|
|
101
|
+
#
|
|
102
|
+
# This is the single canonical resolver shared by IPPrivacyMiddleware
|
|
103
|
+
# ("resolve once") and Otto::Request#client_ipaddress (its no-middleware
|
|
104
|
+
# fallback), so both paths agree on which headers to trust and how to walk
|
|
105
|
+
# a proxy chain. It walks the forwarded chain left-to-right and returns the
|
|
106
|
+
# first address that is not itself a trusted proxy; if the peer is not a
|
|
107
|
+
# trusted proxy (or there is no config) it returns REMOTE_ADDR unchanged.
|
|
108
|
+
#
|
|
109
|
+
# @param env [Hash] Rack environment
|
|
110
|
+
# @param security_config [Otto::Security::Config, nil] config exposing #trusted_proxy?
|
|
111
|
+
# @return [String, nil] resolved client IP (the raw REMOTE_ADDR when no proxy applies)
|
|
112
|
+
def resolve_client_ip(env, security_config)
|
|
113
|
+
remote_addr = env['REMOTE_ADDR']
|
|
114
|
+
|
|
115
|
+
# Count-based ("trust the last N hops") mode for non-enumerable proxy
|
|
116
|
+
# tiers (Fly, cloud load balancers, dynamic reverse proxies) where the
|
|
117
|
+
# CIDR-walk below has no enumerable proxy IPs to match. Mirrors Express
|
|
118
|
+
# `trust proxy = N`. Takes precedence over CIDR-walk; the two modes are
|
|
119
|
+
# mutually exclusive (enforced at config freeze).
|
|
120
|
+
return resolve_client_ip_by_depth(env, security_config) if security_config&.trusted_proxy_depth_mode?
|
|
121
|
+
|
|
122
|
+
# No config, or the peer is a direct (untrusted) connection: REMOTE_ADDR
|
|
123
|
+
# is the client. Don't honor forwarded headers from untrusted sources.
|
|
124
|
+
return remote_addr unless security_config&.trusted_proxy?(remote_addr)
|
|
125
|
+
|
|
126
|
+
forwarded_ips = FORWARDED_FOR_HEADERS
|
|
127
|
+
.filter_map { |header| env[header] }
|
|
128
|
+
.flat_map { |value| value.split(/,\s*/) }
|
|
129
|
+
|
|
130
|
+
forwarded_ips.each do |candidate|
|
|
131
|
+
clean_ip = normalize_ip(candidate.strip)
|
|
132
|
+
next unless clean_ip
|
|
133
|
+
|
|
134
|
+
# First address in the chain that isn't a known proxy is the client.
|
|
135
|
+
return clean_ip unless security_config.trusted_proxy?(clean_ip)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Whole chain was trusted proxies (or empty): fall back to the peer.
|
|
139
|
+
remote_addr
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Resolve the client IP by trusting a fixed number of proxy hops, counted
|
|
143
|
+
# from the right of the forwarded chain (Express `trust proxy = N`). Used
|
|
144
|
+
# when the proxy tier's addresses cannot be enumerated as CIDRs.
|
|
145
|
+
#
|
|
146
|
+
# The chain is X-Forwarded-For (leftmost = client .. rightmost = nearest
|
|
147
|
+
# proxy) plus REMOTE_ADDR (the direct peer). With depth N the client is
|
|
148
|
+
# chain[-(N+1)] — exactly N trusted hops from the right, equivalent to
|
|
149
|
+
# Express's addrs[N]. This is robust to X-Forwarded-For padding: a forged
|
|
150
|
+
# leftmost entry is never reached.
|
|
151
|
+
#
|
|
152
|
+
# SECURITY: depth trust ASSUMES ORIGIN LOCKDOWN — the app must be
|
|
153
|
+
# unreachable except through the proxy tier. Without it, a direct client
|
|
154
|
+
# could pad X-Forwarded-For to land a forged value at the target index.
|
|
155
|
+
# This is the inherent trade vs CIDR-walk (a fixed hop count instead of
|
|
156
|
+
# enumerable proxy addresses).
|
|
157
|
+
#
|
|
158
|
+
# Only X-Forwarded-For is consulted; X-Real-IP / X-Client-IP are
|
|
159
|
+
# single-value and cannot express a hop chain. Positions are counted raw
|
|
160
|
+
# (never dropped), so junk padding cannot shift the index; only the
|
|
161
|
+
# selected entry is validated. If the chain is shorter than N+1 (a request
|
|
162
|
+
# that may have bypassed the proxy tier) or the selected entry is invalid,
|
|
163
|
+
# REMOTE_ADDR is returned rather than a spoofable forwarded value.
|
|
164
|
+
#
|
|
165
|
+
# @param env [Hash] Rack environment
|
|
166
|
+
# @param security_config [Otto::Security::Config] config exposing #trusted_proxy_depth
|
|
167
|
+
# @return [String, nil] resolved client IP (REMOTE_ADDR on short chain / invalid target)
|
|
168
|
+
def resolve_client_ip_by_depth(env, security_config)
|
|
169
|
+
remote_addr = env['REMOTE_ADDR']
|
|
170
|
+
depth = security_config.trusted_proxy_depth.to_i
|
|
171
|
+
|
|
172
|
+
# Split on commas keeping every position (-1 preserves trailing empty
|
|
173
|
+
# fields) so a malformed hop still counts as a position. The client must
|
|
174
|
+
# be located by counting from the right; dropping entries here would let
|
|
175
|
+
# padding shift the index.
|
|
176
|
+
forwarded = env['HTTP_X_FORWARDED_FOR'].to_s.split(',', -1)
|
|
177
|
+
chain = forwarded + [remote_addr]
|
|
178
|
+
|
|
179
|
+
index = chain.length - (depth + 1)
|
|
180
|
+
return remote_addr if index.negative? # chain shorter than depth + 1
|
|
181
|
+
|
|
182
|
+
normalize_ip(chain[index].to_s.strip) || remote_addr
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Whether an address is non-public: RFC1918 private, loopback, link-local,
|
|
186
|
+
# multicast, or unspecified — for both IPv4 and IPv6.
|
|
187
|
+
#
|
|
188
|
+
# Uses IPAddr's family-aware predicates (which also fold IPv4-mapped IPv6
|
|
189
|
+
# via #native) plus an explicit set of special-use ranges that the
|
|
190
|
+
# predicates don't cover (IPv4 0.0.0.0/8 and 224.0.0.0/4, IPv6 ::/128 and
|
|
191
|
+
# ff00::/8). Returns false for malformed input rather than raising.
|
|
192
|
+
#
|
|
193
|
+
# @param ip [String, IPAddr, nil] address to classify
|
|
194
|
+
# @return [Boolean]
|
|
195
|
+
def private_ip?(ip)
|
|
196
|
+
return false if ip.nil?
|
|
197
|
+
return false if ip.respond_to?(:empty?) && ip.empty?
|
|
198
|
+
|
|
199
|
+
addr = ip.is_a?(IPAddr) ? ip : IPAddr.new(strip_ip_port(ip.to_s.strip))
|
|
200
|
+
addr = addr.native # fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to IPv4
|
|
201
|
+
|
|
202
|
+
return true if addr.private? || addr.loopback? || addr.link_local?
|
|
203
|
+
|
|
204
|
+
SPECIAL_USE_RANGES.any? { |range| range.family == addr.family && range.include?(addr) }
|
|
205
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
206
|
+
false
|
|
207
|
+
end
|
|
38
208
|
end
|
|
39
209
|
end
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -214,7 +214,15 @@ class Otto
|
|
|
214
214
|
end
|
|
215
215
|
|
|
216
216
|
class << self
|
|
217
|
-
attr_accessor :debug
|
|
217
|
+
attr_accessor :debug # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
|
218
|
+
attr_writer :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
|
219
|
+
|
|
220
|
+
# Otto's logger. Never nil: falls back to a default $stdout logger so call
|
|
221
|
+
# sites can log unconditionally without defensive `&.` guards. Assign your
|
|
222
|
+
# own logger (or a null logger to silence) via `Otto.logger = ...`.
|
|
223
|
+
def logger
|
|
224
|
+
@logger ||= Logger.new($stdout, Logger::INFO)
|
|
225
|
+
end
|
|
218
226
|
|
|
219
227
|
# Helper method for structured logging that works with both standard Logger and structured loggers
|
|
220
228
|
def structured_log(level, message, data = {})
|
data/otto.gemspec
CHANGED
|
@@ -21,7 +21,14 @@ Gem::Specification.new do |spec|
|
|
|
21
21
|
spec.required_ruby_version = ['>= 3.2', '< 4.1']
|
|
22
22
|
|
|
23
23
|
spec.add_dependency 'concurrent-ruby', '~> 1.3', '< 2.0'
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
# ipaddr is a default gem on every supported Ruby (3.2+), so `require
|
|
26
|
+
# 'ipaddr'` works without a gemspec dependency. Declaring one collides
|
|
27
|
+
# with bundler 2.7.x's default-gem handling: the lockfile pins a version
|
|
28
|
+
# newer than the activated default and bundler refuses to swap, breaking
|
|
29
|
+
# `bundle exec` (including `rake release`). Drop the declaration and
|
|
30
|
+
# rely on the runtime default gem until bundler ships default-gem
|
|
31
|
+
# override support for ipaddr.
|
|
25
32
|
|
|
26
33
|
# Logger is not part of the default gems as of Ruby 3.5.0
|
|
27
34
|
spec.add_dependency 'logger', '~> 1', '< 2.0'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
@@ -29,26 +29,6 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '2.0'
|
|
32
|
-
- !ruby/object:Gem::Dependency
|
|
33
|
-
name: ipaddr
|
|
34
|
-
requirement: !ruby/object:Gem::Requirement
|
|
35
|
-
requirements:
|
|
36
|
-
- - "~>"
|
|
37
|
-
- !ruby/object:Gem::Version
|
|
38
|
-
version: '1'
|
|
39
|
-
- - "<"
|
|
40
|
-
- !ruby/object:Gem::Version
|
|
41
|
-
version: '2.0'
|
|
42
|
-
type: :runtime
|
|
43
|
-
prerelease: false
|
|
44
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
-
requirements:
|
|
46
|
-
- - "~>"
|
|
47
|
-
- !ruby/object:Gem::Version
|
|
48
|
-
version: '1'
|
|
49
|
-
- - "<"
|
|
50
|
-
- !ruby/object:Gem::Version
|
|
51
|
-
version: '2.0'
|
|
52
32
|
- !ruby/object:Gem::Dependency
|
|
53
33
|
name: logger
|
|
54
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -162,6 +142,7 @@ files:
|
|
|
162
142
|
- docs/ipaddr-encoding-quirk.md
|
|
163
143
|
- docs/migrating/v2.0.0-pre1.md
|
|
164
144
|
- docs/migrating/v2.0.0-pre2.md
|
|
145
|
+
- docs/migrating/v2.3.0.md
|
|
165
146
|
- docs/modern-authentication-authorization-landscape.md
|
|
166
147
|
- docs/multi-strategy-authentication-design.md
|
|
167
148
|
- examples/.gitignore
|
|
@@ -295,6 +276,7 @@ files:
|
|
|
295
276
|
- lib/otto/security/authorization_error.rb
|
|
296
277
|
- lib/otto/security/config.rb
|
|
297
278
|
- lib/otto/security/configurator.rb
|
|
279
|
+
- lib/otto/security/constant_resolver.rb
|
|
298
280
|
- lib/otto/security/core.rb
|
|
299
281
|
- lib/otto/security/csrf.rb
|
|
300
282
|
- lib/otto/security/middleware/csrf_middleware.rb
|