otto 2.2.0 → 2.3.1
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 +7 -2
- data/.github/workflows/claude.yml +7 -2
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/CHANGELOG.rst +189 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -3
- data/Rakefile +21 -0
- data/docs/migrating/v2.3.0.md +288 -0
- data/lib/otto/core/configuration.rb +12 -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 +351 -32
- data/lib/otto/security/configurator.rb +30 -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 +249 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -1
- data/otto.gemspec +8 -1
- metadata +4 -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,233 @@ 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 the configured forwarded header (leftmost = client ..
|
|
147
|
+
# rightmost = nearest proxy) plus REMOTE_ADDR (the direct peer). With depth
|
|
148
|
+
# N the client is chain[-(N+1)] — exactly N trusted hops from the right,
|
|
149
|
+
# equivalent to Express's addrs[N]. This is robust to forwarded-header
|
|
150
|
+
# padding: a forged 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 the forwarded header 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
|
+
# The forwarded chain is selected by security_config.trusted_proxy_header:
|
|
159
|
+
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both' (RFC 7239
|
|
160
|
+
# when it carries a `for=`, otherwise X-Forwarded-For — mirrors
|
|
161
|
+
# OneTimeSecret's site.network.trusted_proxy.header). X-Real-IP / X-Client-IP
|
|
162
|
+
# are single-value and cannot express a hop chain, so they are never
|
|
163
|
+
# consulted in depth mode. Positions are counted raw (never dropped), so junk
|
|
164
|
+
# padding cannot shift the index; only the selected entry is validated. If
|
|
165
|
+
# the chain is shorter than N+1 (a request that may have bypassed the proxy
|
|
166
|
+
# tier) or the selected entry is invalid, REMOTE_ADDR is returned rather than
|
|
167
|
+
# a spoofable forwarded value.
|
|
168
|
+
#
|
|
169
|
+
# @param env [Hash] Rack environment
|
|
170
|
+
# @param security_config [Otto::Security::Config] config exposing #trusted_proxy_depth and #trusted_proxy_header
|
|
171
|
+
# @return [String, nil] resolved client IP (REMOTE_ADDR on short chain / invalid target)
|
|
172
|
+
def resolve_client_ip_by_depth(env, security_config)
|
|
173
|
+
remote_addr = env['REMOTE_ADDR']
|
|
174
|
+
depth = security_config.trusted_proxy_depth.to_i
|
|
175
|
+
|
|
176
|
+
# Build the positional hop chain from the configured header, keeping every
|
|
177
|
+
# position (junk/empty entries included) so the client can be located by
|
|
178
|
+
# counting from the right; dropping entries would let padding shift the
|
|
179
|
+
# index. REMOTE_ADDR (the direct peer) is the rightmost hop.
|
|
180
|
+
forwarded = forwarded_chain_for_depth(env, security_config.trusted_proxy_header)
|
|
181
|
+
chain = forwarded + [remote_addr]
|
|
182
|
+
|
|
183
|
+
index = chain.length - (depth + 1)
|
|
184
|
+
return remote_addr if index.negative? # chain shorter than depth + 1
|
|
185
|
+
|
|
186
|
+
normalize_ip(chain[index].to_s.strip) || remote_addr
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Positional forwarded-hop chain for depth resolution, selected by header
|
|
190
|
+
# mode. Each element is one hop (preserving count); values are raw — only the
|
|
191
|
+
# finally-selected entry is normalized. Mirrors OneTimeSecret's
|
|
192
|
+
# site.network.trusted_proxy.header semantics.
|
|
193
|
+
#
|
|
194
|
+
# @param env [Hash] Rack environment
|
|
195
|
+
# @param header_mode [String] 'X-Forwarded-For', 'Forwarded', or 'Both'
|
|
196
|
+
# @return [Array<String>] one entry per hop (may include blank/invalid entries)
|
|
197
|
+
def forwarded_chain_for_depth(env, header_mode)
|
|
198
|
+
case header_mode
|
|
199
|
+
when 'Forwarded'
|
|
200
|
+
rfc7239_for_chain(env['HTTP_FORWARDED'])
|
|
201
|
+
when 'Both'
|
|
202
|
+
# RFC 7239 wins when it carries at least one `for=`; otherwise fall back
|
|
203
|
+
# to X-Forwarded-For. The chains are NOT merged (matches OTS's
|
|
204
|
+
# `extract_rfc7239_forwarded(env) || extract_x_forwarded_for(env)`).
|
|
205
|
+
forwarded = rfc7239_for_chain(env['HTTP_FORWARDED'])
|
|
206
|
+
forwarded.any? { |entry| !entry.empty? } ? forwarded : xff_chain(env['HTTP_X_FORWARDED_FOR'])
|
|
207
|
+
else
|
|
208
|
+
xff_chain(env['HTTP_X_FORWARDED_FOR'])
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Split X-Forwarded-For into raw positional entries. `-1` keeps trailing
|
|
213
|
+
# empty fields so a malformed/empty hop still counts as a position.
|
|
214
|
+
#
|
|
215
|
+
# @param value [String, nil] raw X-Forwarded-For header value
|
|
216
|
+
# @return [Array<String>]
|
|
217
|
+
def xff_chain(value)
|
|
218
|
+
value.to_s.split(',', -1)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Extract the per-hop `for=` chain from an RFC 7239 Forwarded header,
|
|
222
|
+
# preserving one position per forwarded-element. Elements without a `for=`
|
|
223
|
+
# parameter yield a blank placeholder so they still occupy a hop position
|
|
224
|
+
# (raw position counting). The extracted token is only unquoted here; port
|
|
225
|
+
# and IPv6 brackets are left for normalize_ip when the entry is selected.
|
|
226
|
+
# Obfuscated (`for=_hidden`) and `for=unknown` identifiers are preserved as
|
|
227
|
+
# positions but normalize to nil (→ REMOTE_ADDR fallback if selected).
|
|
228
|
+
# Commas separate forwarded-elements (and join multiple Forwarded headers).
|
|
229
|
+
# A nil/blank header splits to [] (not ['']), so an absent Forwarded header
|
|
230
|
+
# yields an empty chain and depth's explicit short-chain guard returns
|
|
231
|
+
# REMOTE_ADDR — symmetric with xff_chain.
|
|
232
|
+
#
|
|
233
|
+
# @param value [String, nil] raw Forwarded header value
|
|
234
|
+
# @return [Array<String>] one `for=` token per forwarded-element
|
|
235
|
+
def rfc7239_for_chain(value)
|
|
236
|
+
value.to_s.split(',', -1).map { |element| rfc7239_for_value(element) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Pull the `for=` token out of a single RFC 7239 forwarded-element. The value
|
|
240
|
+
# is a quoted-string (which may itself legally contain ';') or an unquoted
|
|
241
|
+
# token ending at the next ';'. The quoted form is matched first so a ';'
|
|
242
|
+
# inside DQUOTEs is NOT treated as a parameter separator — otherwise a value
|
|
243
|
+
# like for="1.2.3.4;junk" would be truncated to a valid-looking IP instead of
|
|
244
|
+
# being rejected. Only DQUOTE wrappers are stripped: RFC 7239 quoted-strings
|
|
245
|
+
# use DQUOTE exclusively, so a value like for='1.2.3.4' keeps its quotes,
|
|
246
|
+
# fails normalize_ip, and safely falls back to REMOTE_ADDR rather than being
|
|
247
|
+
# permissively accepted. This is deliberately stricter than OTS (which strips
|
|
248
|
+
# both ['"]), consistent with depth's other intentionally-not-reconciled-down
|
|
249
|
+
# safety properties. The raw value (port / IPv6 brackets intact) is left for
|
|
250
|
+
# normalize_ip when the entry is selected. Returns '' when the element carries
|
|
251
|
+
# no `for=` parameter, preserving the hop position. The `for=` pair may be the
|
|
252
|
+
# element's first pair or follow a ';'; leading whitespace (e.g. after a comma
|
|
253
|
+
# split) is tolerated.
|
|
254
|
+
#
|
|
255
|
+
# @param element [String] one forwarded-element (e.g. 'for=1.2.3.4;proto=https')
|
|
256
|
+
# @return [String]
|
|
257
|
+
def rfc7239_for_value(element)
|
|
258
|
+
match = element.match(/(?:\A|;)\s*for=(?:"([^"]*)"|([^;]+))/i)
|
|
259
|
+
return '' unless match
|
|
260
|
+
|
|
261
|
+
(match[1] || match[2]).strip
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Whether an address is non-public: RFC1918 private, loopback, link-local,
|
|
265
|
+
# multicast, or unspecified — for both IPv4 and IPv6.
|
|
266
|
+
#
|
|
267
|
+
# Uses IPAddr's family-aware predicates (which also fold IPv4-mapped IPv6
|
|
268
|
+
# via #native) plus an explicit set of special-use ranges that the
|
|
269
|
+
# predicates don't cover (IPv4 0.0.0.0/8 and 224.0.0.0/4, IPv6 ::/128 and
|
|
270
|
+
# ff00::/8). Returns false for malformed input rather than raising.
|
|
271
|
+
#
|
|
272
|
+
# @param ip [String, IPAddr, nil] address to classify
|
|
273
|
+
# @return [Boolean]
|
|
274
|
+
def private_ip?(ip)
|
|
275
|
+
return false if ip.nil?
|
|
276
|
+
return false if ip.respond_to?(:empty?) && ip.empty?
|
|
277
|
+
|
|
278
|
+
addr = ip.is_a?(IPAddr) ? ip : IPAddr.new(strip_ip_port(ip.to_s.strip))
|
|
279
|
+
addr = addr.native # fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to IPv4
|
|
280
|
+
|
|
281
|
+
return true if addr.private? || addr.loopback? || addr.link_local?
|
|
282
|
+
|
|
283
|
+
SPECIAL_USE_RANGES.any? { |range| range.family == addr.family && range.include?(addr) }
|
|
284
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
285
|
+
false
|
|
286
|
+
end
|
|
38
287
|
end
|
|
39
288
|
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.1
|
|
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
|
|
@@ -155,6 +135,7 @@ files:
|
|
|
155
135
|
- Gemfile.lock
|
|
156
136
|
- LICENSE.txt
|
|
157
137
|
- README.md
|
|
138
|
+
- Rakefile
|
|
158
139
|
- bin/rspec
|
|
159
140
|
- changelog.d/README.md
|
|
160
141
|
- changelog.d/scriv.ini
|
|
@@ -162,6 +143,7 @@ files:
|
|
|
162
143
|
- docs/ipaddr-encoding-quirk.md
|
|
163
144
|
- docs/migrating/v2.0.0-pre1.md
|
|
164
145
|
- docs/migrating/v2.0.0-pre2.md
|
|
146
|
+
- docs/migrating/v2.3.0.md
|
|
165
147
|
- docs/modern-authentication-authorization-landscape.md
|
|
166
148
|
- docs/multi-strategy-authentication-design.md
|
|
167
149
|
- examples/.gitignore
|
|
@@ -295,6 +277,7 @@ files:
|
|
|
295
277
|
- lib/otto/security/authorization_error.rb
|
|
296
278
|
- lib/otto/security/config.rb
|
|
297
279
|
- lib/otto/security/configurator.rb
|
|
280
|
+
- lib/otto/security/constant_resolver.rb
|
|
298
281
|
- lib/otto/security/core.rb
|
|
299
282
|
- lib/otto/security/csrf.rb
|
|
300
283
|
- lib/otto/security/middleware/csrf_middleware.rb
|