otto 2.11.0 → 2.12.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/.rubocop_todo.yml +2 -2
- data/CHANGELOG.rst +100 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +68 -5
- data/docs/guides/testing-guide.md +115 -2
- data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
- data/lib/otto/core/configuration.rb +5 -3
- data/lib/otto/core/router.rb +16 -20
- data/lib/otto/env_keys.rb +2 -1
- data/lib/otto/privacy/config.rb +19 -13
- data/lib/otto/response.rb +5 -2
- data/lib/otto/route.rb +1 -1
- data/lib/otto/route_handlers/base.rb +1 -1
- data/lib/otto/route_handlers/logic_class.rb +86 -34
- data/lib/otto/security/config.rb +349 -303
- data/lib/otto/security/configurator.rb +82 -45
- data/lib/otto/security/core.rb +4 -2
- data/lib/otto/security/csp/report_middleware.rb +17 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
- data/lib/otto/security/rate_limiter.rb +8 -5
- data/lib/otto/security/trusted_proxy_config.rb +396 -0
- data/lib/otto/static.rb +7 -6
- data/lib/otto/testing.rb +148 -0
- data/lib/otto/utils.rb +63 -14
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +7 -2
- metadata +4 -2
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# lib/otto/security/trusted_proxy_config.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'ipaddr'
|
|
6
|
+
require_relative '../core/freezable'
|
|
7
|
+
|
|
8
|
+
class Otto
|
|
9
|
+
module Security
|
|
10
|
+
# Trusted-proxy resolution settings for one Otto application.
|
|
11
|
+
#
|
|
12
|
+
# An application answers "which peer may speak for the client?" in at most
|
|
13
|
+
# one of three ways (#mode):
|
|
14
|
+
#
|
|
15
|
+
# - :filter — enumerated proxy entries (IP, CIDR, Regexp, or a legacy
|
|
16
|
+
# string prefix); the client IP is found by walking the forwarded chain
|
|
17
|
+
# past trusted hops.
|
|
18
|
+
# - :depth — trust the last N hops, for proxy tiers whose addresses cannot
|
|
19
|
+
# be enumerated (Fly, cloud load balancers, dynamic reverse proxies).
|
|
20
|
+
# - :none — the explicit operator assertion that no proxy is trusted.
|
|
21
|
+
#
|
|
22
|
+
# #header picks the forwarded header depth mode counts hops from.
|
|
23
|
+
#
|
|
24
|
+
# This object owns the rules that keep those settings coherent. #mode is
|
|
25
|
+
# derived from the stored settings rather than stored beside them, and
|
|
26
|
+
# every mutator checks the state it would produce with #ensure_compatible!,
|
|
27
|
+
# the same check #validate! runs at freeze, so each rule is written once.
|
|
28
|
+
#
|
|
29
|
+
# Rules that involve other objects stay with Otto::Security::Config: the
|
|
30
|
+
# ip_privacy geo_header vs depth conflict, and pinning Rack's
|
|
31
|
+
# process-global forwarding family. Config keeps this object private and
|
|
32
|
+
# routes every change through its own setters (add_trusted_proxy,
|
|
33
|
+
# trusted_proxy_depth=, trusted_proxy_header=, trust_no_proxies!), which
|
|
34
|
+
# add those checks; #check_depth! and #check_header! let Config run them
|
|
35
|
+
# before anything is stored.
|
|
36
|
+
class TrustedProxyConfig
|
|
37
|
+
include Otto::Core::Freezable
|
|
38
|
+
|
|
39
|
+
# Error raised when the two mutually-exclusive trusted-proxy resolution
|
|
40
|
+
# modes are configured together: CIDR-walk (enumerated trusted_proxies)
|
|
41
|
+
# and count-based depth (trusted_proxy_depth >= 1).
|
|
42
|
+
PROXY_MODE_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
43
|
+
Cannot configure both trusted_proxies (CIDR filter mode) and
|
|
44
|
+
trusted_proxy_depth >= 1 (count mode). Enumerate proxy CIDRs OR set a
|
|
45
|
+
hop count, not both.
|
|
46
|
+
MSG
|
|
47
|
+
|
|
48
|
+
# Error raised when the explicit "trust no proxy" assertion
|
|
49
|
+
# (trust_no_proxies!, `trusted_proxies: :none`) is combined with an
|
|
50
|
+
# actual trust grant (enumerated CIDRs or a depth >= 1). The two say
|
|
51
|
+
# opposite things about the same peer, so the combination is refused at
|
|
52
|
+
# configuration time rather than silently resolved in one direction.
|
|
53
|
+
TRUST_NO_PROXIES_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
54
|
+
Cannot combine trusted_proxies: :none (trust no proxy) with
|
|
55
|
+
trusted_proxies CIDRs or trusted_proxy_depth >= 1. Assert :none OR
|
|
56
|
+
grant trust, not both.
|
|
57
|
+
MSG
|
|
58
|
+
|
|
59
|
+
# Error raised when the trust-nobody sentinel arrives as a proxy ENTRY
|
|
60
|
+
# (`trusted_proxies: ['none']`, as a YAML/JSON list naturally yields, or
|
|
61
|
+
# `add_trusted_proxy('none')`) instead of as the whole option. Inside a
|
|
62
|
+
# list it would otherwise register a legacy string-prefix matcher that
|
|
63
|
+
# matches nothing: peers would be untrusted, but trust_no_proxies? would
|
|
64
|
+
# stay false and the config would stake a forwarding-family claim, so the
|
|
65
|
+
# explicit assertion would be silently replaced by a lookalike.
|
|
66
|
+
TRUST_NO_PROXIES_ENTRY_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
67
|
+
trusted_proxies entry :none is the trust-nobody assertion, not a proxy
|
|
68
|
+
address. Pass trusted_proxies: :none as the whole option (not inside a
|
|
69
|
+
list) or call trust_no_proxies! instead.
|
|
70
|
+
MSG
|
|
71
|
+
|
|
72
|
+
# Error raised when a non-default header is combined with CIDR filter
|
|
73
|
+
# mode. Otto's CIDR-walk resolves the client IP from the X-Forwarded-For
|
|
74
|
+
# family only (X-Forwarded-For, then X-Real-IP, then X-Client-IP —
|
|
75
|
+
# Otto::Utils::FORWARDED_FOR_HEADERS), never RFC 7239 Forwarded, while
|
|
76
|
+
# trusted_proxy_header also pins Rack's forwarding family; honoring
|
|
77
|
+
# 'Forwarded' or 'Both' there would make Rack read a header Otto ignores,
|
|
78
|
+
# recreating the disagreement the pin exists to close.
|
|
79
|
+
FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
|
|
80
|
+
Cannot configure trusted_proxy_header 'Forwarded' or 'Both' together
|
|
81
|
+
with trusted_proxies (CIDR filter mode): CIDR-walk resolves client IPs
|
|
82
|
+
from the X-Forwarded-For family only (X-Forwarded-For, X-Real-IP,
|
|
83
|
+
X-Client-IP), never RFC 7239 Forwarded. Use trusted_proxy_depth (count
|
|
84
|
+
mode) to read the RFC 7239 Forwarded header.
|
|
85
|
+
MSG
|
|
86
|
+
|
|
87
|
+
# Sentinel accepted wherever a trusted_proxies list is accepted, meaning
|
|
88
|
+
# "the operator asserts that NO proxy is trusted". See #trust_none!.
|
|
89
|
+
TRUST_NO_PROXIES = :none
|
|
90
|
+
|
|
91
|
+
# Forwarded-header sources depth mode can count hops from:
|
|
92
|
+
# X-Forwarded-For (default), the RFC 7239 Forwarded header, or Both
|
|
93
|
+
# (Forwarded when present, else X-Forwarded-For). Mirrors OneTimeSecret's
|
|
94
|
+
# site.network.trusted_proxy.header. Only consulted in depth mode;
|
|
95
|
+
# CIDR-walk is unaffected.
|
|
96
|
+
HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
|
|
97
|
+
DEFAULT_HEADER = 'X-Forwarded-For'
|
|
98
|
+
|
|
99
|
+
# Whether a trusted_proxies option value is the trust-nobody sentinel.
|
|
100
|
+
# Accepts the symbol and the String spelling 'none' (case-insensitive),
|
|
101
|
+
# which is what YAML/ENV-driven configuration naturally produces; without
|
|
102
|
+
# this, 'none' would fall through to #add and install a legacy
|
|
103
|
+
# string-prefix matcher, silently inverting the assertion.
|
|
104
|
+
#
|
|
105
|
+
# @param value [Object] raw trusted_proxies option
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def self.trust_no_proxies_option?(value)
|
|
108
|
+
(value.is_a?(Symbol) || value.is_a?(String)) && value.to_s.casecmp?('none')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Proxy entries in registration order (filter mode).
|
|
112
|
+
# @return [Array<String, Regexp>]
|
|
113
|
+
attr_reader :proxies
|
|
114
|
+
|
|
115
|
+
# Count-based depth; nil or 0 disables depth mode.
|
|
116
|
+
# @return [Integer, nil]
|
|
117
|
+
attr_reader :depth
|
|
118
|
+
|
|
119
|
+
# Canonical forwarded header depth mode counts hops from.
|
|
120
|
+
# @return [String] one of HEADERS
|
|
121
|
+
attr_reader :header
|
|
122
|
+
|
|
123
|
+
def initialize
|
|
124
|
+
@proxies = []
|
|
125
|
+
@matchers = []
|
|
126
|
+
@trust_none = false
|
|
127
|
+
@depth = nil
|
|
128
|
+
@header = DEFAULT_HEADER
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The active resolution mode, or nil when proxy trust is unconfigured.
|
|
132
|
+
#
|
|
133
|
+
# @return [Symbol, nil] :filter, :depth, :none, or nil
|
|
134
|
+
def mode
|
|
135
|
+
return :filter if filter?
|
|
136
|
+
return :depth if depth?
|
|
137
|
+
|
|
138
|
+
:none if trust_none?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Whether any mode is configured. When false, Otto leaves
|
|
142
|
+
# env['otto.via_trusted_proxy'] absent (the tri-state contract).
|
|
143
|
+
#
|
|
144
|
+
# @return [Boolean]
|
|
145
|
+
def configured?
|
|
146
|
+
!mode.nil?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Whether proxy entries are registered (CIDR filter mode).
|
|
150
|
+
#
|
|
151
|
+
# @return [Boolean]
|
|
152
|
+
def filter?
|
|
153
|
+
@matchers.any?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Whether count-based depth mode is active. Integer-strict, so a value
|
|
157
|
+
# that never passed #depth= cannot enable it.
|
|
158
|
+
#
|
|
159
|
+
# @return [Boolean] true when depth is an Integer >= 1
|
|
160
|
+
def depth?
|
|
161
|
+
@depth.is_a?(Integer) && @depth >= 1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Whether the operator asserted that no proxy is trusted.
|
|
165
|
+
#
|
|
166
|
+
# @return [Boolean]
|
|
167
|
+
def trust_none?
|
|
168
|
+
@trust_none
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Whether request handling reads a forwarded chain, and so depends on
|
|
172
|
+
# Rack's process-global forwarding family. True in filter and depth mode;
|
|
173
|
+
# false under trust-nobody (reads nothing) and when unconfigured.
|
|
174
|
+
#
|
|
175
|
+
# @return [Boolean]
|
|
176
|
+
def forwarding_family_dependent?
|
|
177
|
+
filter? || depth?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Whether #header is the X-Forwarded-For default.
|
|
181
|
+
#
|
|
182
|
+
# @return [Boolean]
|
|
183
|
+
def default_header?
|
|
184
|
+
@header == DEFAULT_HEADER
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Register one entry or a list of entries (filter mode). The whole list
|
|
188
|
+
# is validated before anything is registered, so a rejected list leaves
|
|
189
|
+
# this object untouched.
|
|
190
|
+
#
|
|
191
|
+
# @param proxy [String, Regexp, Array<String, Regexp>] entry or entries
|
|
192
|
+
# @raise [ArgumentError] on a mode conflict, a trust-nobody sentinel
|
|
193
|
+
# inside the list, or an unsupported type
|
|
194
|
+
# @raise [FrozenError] if frozen
|
|
195
|
+
# @return [void]
|
|
196
|
+
def add(proxy)
|
|
197
|
+
ensure_not_frozen!
|
|
198
|
+
# Adding claims filter mode even when the list is empty, so the
|
|
199
|
+
# conflict surfaces at the call that introduced it.
|
|
200
|
+
ensure_compatible!(filter: true)
|
|
201
|
+
Array(proxy).each do |entry|
|
|
202
|
+
raise ArgumentError, TRUST_NO_PROXIES_ENTRY_MESSAGE if self.class.trust_no_proxies_option?(entry)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
case proxy
|
|
206
|
+
when String, Regexp
|
|
207
|
+
@proxies << proxy
|
|
208
|
+
@matchers << build_matcher(proxy)
|
|
209
|
+
when Array
|
|
210
|
+
# Build every matcher before touching state, so a failure partway
|
|
211
|
+
# through cannot leave entries and matchers out of step.
|
|
212
|
+
matchers = proxy.map { |entry| build_matcher(entry) }
|
|
213
|
+
@proxies.concat(proxy)
|
|
214
|
+
@matchers.concat(matchers)
|
|
215
|
+
else
|
|
216
|
+
raise ArgumentError, 'Proxy must be a String, Regexp, or Array'
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Assert that no proxy is trusted.
|
|
221
|
+
#
|
|
222
|
+
# @raise [ArgumentError] if entries or a depth >= 1 are configured
|
|
223
|
+
# @raise [FrozenError] if frozen
|
|
224
|
+
# @return [void]
|
|
225
|
+
def trust_none!
|
|
226
|
+
ensure_not_frozen!
|
|
227
|
+
ensure_compatible!(trust_none: true)
|
|
228
|
+
@trust_none = true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Raise unless depth could be assigned: a non-negative Integer or nil,
|
|
232
|
+
# compatible with the current mode. Stores nothing.
|
|
233
|
+
#
|
|
234
|
+
# @param depth [Object] candidate value
|
|
235
|
+
# @raise [ArgumentError] if invalid or conflicting
|
|
236
|
+
# @return [Integer, nil] depth
|
|
237
|
+
def check_depth!(depth)
|
|
238
|
+
validate_depth_value!(depth)
|
|
239
|
+
ensure_compatible!(depth: depth.to_i >= 1)
|
|
240
|
+
depth
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @param depth [Integer, nil] number of trusted hops (nil/0 disables depth mode)
|
|
244
|
+
# @raise [ArgumentError] if invalid or conflicting (see #check_depth!)
|
|
245
|
+
# @raise [FrozenError] if frozen
|
|
246
|
+
def depth=(depth)
|
|
247
|
+
ensure_not_frozen!
|
|
248
|
+
@depth = check_depth!(depth)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Raise unless header could be assigned, and return its canonical
|
|
252
|
+
# spelling. Matching is case-insensitive and ignores surrounding
|
|
253
|
+
# whitespace; an unrecognized value fails loud instead of silently
|
|
254
|
+
# resolving from the wrong header. Stores nothing.
|
|
255
|
+
#
|
|
256
|
+
# @param header [Object] candidate value
|
|
257
|
+
# @raise [ArgumentError] if unrecognized or conflicting with filter mode
|
|
258
|
+
# @return [String] canonical header (one of HEADERS)
|
|
259
|
+
def check_header!(header)
|
|
260
|
+
candidate = header.to_s.strip
|
|
261
|
+
canonical = HEADERS.find { |allowed| allowed.casecmp?(candidate) }
|
|
262
|
+
raise ArgumentError, invalid_header_message(header) unless canonical
|
|
263
|
+
|
|
264
|
+
ensure_compatible!(header: canonical)
|
|
265
|
+
canonical
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @param header [String] one of HEADERS (case-insensitive)
|
|
269
|
+
# @raise [ArgumentError] if invalid or conflicting (see #check_header!)
|
|
270
|
+
# @raise [FrozenError] if frozen
|
|
271
|
+
def header=(header)
|
|
272
|
+
ensure_not_frozen!
|
|
273
|
+
@header = check_header!(header)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Whether ip matches a registered entry.
|
|
277
|
+
#
|
|
278
|
+
# String entries that parse as an IP or CIDR range are matched with
|
|
279
|
+
# proper IPAddr containment (IPv4 and IPv6). Entries that are not valid
|
|
280
|
+
# IPs (e.g. a bare prefix like '172.16.') fall back to the legacy
|
|
281
|
+
# exact/prefix string match for backward compatibility. Regexp entries
|
|
282
|
+
# are matched against the raw IP string. Entries are parsed once at
|
|
283
|
+
# registration, never per request.
|
|
284
|
+
#
|
|
285
|
+
# @param ip [String] IP address to check
|
|
286
|
+
# @return [Boolean]
|
|
287
|
+
def trusted?(ip)
|
|
288
|
+
return false if @matchers.empty? || ip.nil? || ip.empty?
|
|
289
|
+
|
|
290
|
+
# Fold IPv4-mapped IPv6 (::ffff:a.b.c.d) to plain IPv4 so a dual-stack
|
|
291
|
+
# peer presented in mapped form still matches an IPv4 proxy entry.
|
|
292
|
+
client = parse_ipaddr(ip)&.native
|
|
293
|
+
|
|
294
|
+
@matchers.any? do |entry, range|
|
|
295
|
+
if range
|
|
296
|
+
# Pre-parsed IP/CIDR entry -> proper containment
|
|
297
|
+
client && ip_in_range?(range, client)
|
|
298
|
+
elsif entry.is_a?(Regexp)
|
|
299
|
+
entry.match?(ip)
|
|
300
|
+
elsif entry.is_a?(String)
|
|
301
|
+
# Legacy non-IP entry (e.g. '172.16.') -> exact/prefix match
|
|
302
|
+
ip == entry || ip.start_with?(entry)
|
|
303
|
+
else
|
|
304
|
+
false
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Re-check every rule against the stored state. The mutators already
|
|
310
|
+
# enforce them; this is the freeze-time backstop for state that bypassed
|
|
311
|
+
# them (a direct instance-variable write).
|
|
312
|
+
#
|
|
313
|
+
# @raise [ArgumentError] if any rule is violated
|
|
314
|
+
# @return [void]
|
|
315
|
+
def validate!
|
|
316
|
+
raise ArgumentError, invalid_header_message(@header) unless HEADERS.include?(@header)
|
|
317
|
+
|
|
318
|
+
validate_depth_value!(@depth)
|
|
319
|
+
ensure_compatible!
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
private
|
|
323
|
+
|
|
324
|
+
def ensure_not_frozen!
|
|
325
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen?
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The mutual-exclusion rules, in one place. Each flag says whether that
|
|
329
|
+
# mode would be active; a mutator overrides the one it is about to
|
|
330
|
+
# change and the rest default to the stored state.
|
|
331
|
+
def ensure_compatible!(filter: filter?, depth: depth?, trust_none: trust_none?, header: @header)
|
|
332
|
+
raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if filter && depth
|
|
333
|
+
raise ArgumentError, TRUST_NO_PROXIES_CONFLICT_MESSAGE if trust_none && (filter || depth)
|
|
334
|
+
raise ArgumentError, FORWARDED_HEADER_CIDR_CONFLICT_MESSAGE if filter && header != DEFAULT_HEADER
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Type and range only, so an invalid value raises a clear ArgumentError
|
|
338
|
+
# instead of a downstream NoMethodError from #to_i coercion.
|
|
339
|
+
def validate_depth_value!(depth)
|
|
340
|
+
return if depth.nil?
|
|
341
|
+
|
|
342
|
+
unless depth.is_a?(Integer)
|
|
343
|
+
raise ArgumentError,
|
|
344
|
+
"trusted_proxy_depth must be an Integer or nil, got #{depth.class}"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def invalid_header_message(header)
|
|
351
|
+
"trusted_proxy_header must be one of #{HEADERS.join(', ')}, got #{header.inspect}"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Parse a value into an IPAddr, returning nil for invalid / non-IP input.
|
|
355
|
+
def parse_ipaddr(value)
|
|
356
|
+
IPAddr.new(value)
|
|
357
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Build a cached [raw_entry, parsed_range_or_nil] tuple at registration.
|
|
362
|
+
#
|
|
363
|
+
# The parsed range is folded through IPAddr#native, to match the fold
|
|
364
|
+
# #trusted? applies to the client address. Without it a mapped-IPv6
|
|
365
|
+
# proxy entry (::ffff:10.0.0.0/104) could never match, because
|
|
366
|
+
# #ip_in_range?'s family check would reject the folded IPv4 client — a
|
|
367
|
+
# proxy silently untrusted, which is what gates otto.via_trusted_proxy,
|
|
368
|
+
# secure?, and geo-header trust. #native returns self for entries that
|
|
369
|
+
# are not IPv4-mapped/compatible.
|
|
370
|
+
def build_matcher(entry)
|
|
371
|
+
return [entry, nil] unless entry.is_a?(String)
|
|
372
|
+
|
|
373
|
+
range = parse_ipaddr(entry)&.native
|
|
374
|
+
warn_legacy_proxy_entry(entry) unless range
|
|
375
|
+
[entry, range]
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def warn_legacy_proxy_entry(entry)
|
|
379
|
+
Otto.logger.warn(
|
|
380
|
+
"[Otto::Security::Config] trusted proxy #{entry.inspect} is not a " \
|
|
381
|
+
'valid IP or CIDR; using legacy string-prefix matching. Prefer a ' \
|
|
382
|
+
"CIDR range (e.g. '172.16.0.0/12')."
|
|
383
|
+
)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# CIDR/host containment that is safe across address families.
|
|
387
|
+
def ip_in_range?(range, client)
|
|
388
|
+
return false unless range.family == client.family
|
|
389
|
+
|
|
390
|
+
range.include?(client)
|
|
391
|
+
rescue IPAddr::InvalidAddressError
|
|
392
|
+
false
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
data/lib/otto/static.rb
CHANGED
|
@@ -7,12 +7,12 @@ class Otto
|
|
|
7
7
|
module Static
|
|
8
8
|
extend self
|
|
9
9
|
|
|
10
|
-
def server_error
|
|
11
|
-
[500, security_headers.merge({ 'content-type' => 'text/plain' }), ['Server error']]
|
|
10
|
+
def server_error(security_config = nil)
|
|
11
|
+
[500, security_headers(security_config).merge({ 'content-type' => 'text/plain' }), ['Server error']]
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def not_found
|
|
15
|
-
[404, security_headers.merge({ 'content-type' => 'text/plain' }), ['Not Found']]
|
|
14
|
+
def not_found(security_config = nil)
|
|
15
|
+
[404, security_headers(security_config).merge({ 'content-type' => 'text/plain' }), ['Not Found']]
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Return a per-request copy of a Rack triple so callers can never hand a
|
|
@@ -53,12 +53,13 @@ class Otto
|
|
|
53
53
|
copied
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
def security_headers
|
|
56
|
+
def security_headers(security_config = nil)
|
|
57
57
|
{
|
|
58
58
|
'x-frame-options' => 'DENY',
|
|
59
59
|
'x-content-type-options' => 'nosniff',
|
|
60
60
|
'x-xss-protection' => '1; mode=block',
|
|
61
|
-
'referrer-policy' =>
|
|
61
|
+
'referrer-policy' => security_config&.referrer_policy ||
|
|
62
|
+
Otto::Security::Config::DEFAULT_REFERRER_POLICY,
|
|
62
63
|
}
|
|
63
64
|
end
|
|
64
65
|
|
data/lib/otto/testing.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# lib/otto/testing.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'rack/mock'
|
|
6
|
+
require_relative '../otto'
|
|
7
|
+
|
|
8
|
+
class Otto
|
|
9
|
+
# Test support for applications built on Otto, independent of the test
|
|
10
|
+
# framework. `require 'otto'` does not load this file; require
|
|
11
|
+
# 'otto/testing' from a test helper. Loading it is the opt-in for the
|
|
12
|
+
# resets below, which production code must never call.
|
|
13
|
+
#
|
|
14
|
+
# @example RSpec
|
|
15
|
+
# require 'otto/testing'
|
|
16
|
+
# RSpec.configure { |c| c.before { Otto::Testing.reset! } }
|
|
17
|
+
#
|
|
18
|
+
# @example Minitest
|
|
19
|
+
# require 'otto/testing'
|
|
20
|
+
# class Minitest::Test
|
|
21
|
+
# def before_setup
|
|
22
|
+
# super
|
|
23
|
+
# Otto::Testing.reset!
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @example Tryouts (setup runs once per file, so reset inside each case)
|
|
28
|
+
# ## a depth-mode app reading Forwarded
|
|
29
|
+
# Otto::Testing.reset!
|
|
30
|
+
# Otto.new(nil, trusted_proxy_depth: 1, trusted_proxy_header: 'Forwarded')
|
|
31
|
+
module Testing
|
|
32
|
+
# Stands in for the application behind IPPrivacyMiddleware; only the env
|
|
33
|
+
# the middleware leaves behind is of interest.
|
|
34
|
+
RESOLVED_APP = ->(_env) { [200, {}, []] }
|
|
35
|
+
private_constant :RESOLVED_APP
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Clear the process-global state Otto accumulates across Otto.new calls,
|
|
40
|
+
# so one test's applications cannot decide whether the next test's raise.
|
|
41
|
+
#
|
|
42
|
+
# Today that is the forwarding-family registry and
|
|
43
|
+
# Rack::Request.forwarded_priority, which Otto pins from
|
|
44
|
+
# trusted_proxy_header. Rack's setting is one per process, so a second
|
|
45
|
+
# application choosing a different family raises ArgumentError; in a
|
|
46
|
+
# suite that builds applications with different families, the outcome
|
|
47
|
+
# would otherwise depend on test order.
|
|
48
|
+
#
|
|
49
|
+
# Call it before every test (or after every test), not once per suite.
|
|
50
|
+
#
|
|
51
|
+
# @return [nil]
|
|
52
|
+
def reset!
|
|
53
|
+
Otto::Security::Config.send(:reset_rack_forwarding_family!)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Build a Rack env as IPPrivacyMiddleware leaves it for a request arriving
|
|
58
|
+
# directly from client_ip: env['otto.client_ip'] (masked under the
|
|
59
|
+
# privacy profile in force), env['otto.ip_match'] over the unmasked
|
|
60
|
+
# address, the rewritten REMOTE_ADDR, and the proxy-trust verdict when
|
|
61
|
+
# trust is configured.
|
|
62
|
+
#
|
|
63
|
+
# Use it instead of writing env['otto.client_ip'] by hand. A hand-written
|
|
64
|
+
# value looks to the middleware like a prior pass, so the precise
|
|
65
|
+
# otto.ip_match is never built and every CIDR check denies.
|
|
66
|
+
#
|
|
67
|
+
# security_config is required because the app's own IPPrivacyMiddleware
|
|
68
|
+
# keeps whatever this resolution produced: pass the application's
|
|
69
|
+
# (otto.security_config) so the privacy profile and proxy trust match the
|
|
70
|
+
# app under test. nil means an unconfigured middleware: public addresses
|
|
71
|
+
# masked, no proxy trust.
|
|
72
|
+
#
|
|
73
|
+
# The request is direct, so the headers in
|
|
74
|
+
# Otto::Utils::CLIENT_ADDRESS_HEADERS (X-Forwarded-For, X-Real-IP,
|
|
75
|
+
# X-Client-IP, Forwarded) raise ArgumentError: under a config
|
|
76
|
+
# that trusts the peer they would move the resolved address away from
|
|
77
|
+
# client_ip. For a relayed request, build the env with REMOTE_ADDR and the
|
|
78
|
+
# forwarded headers and call {.resolve_client_ip!}.
|
|
79
|
+
#
|
|
80
|
+
# @param uri [String] passed to Rack::MockRequest.env_for
|
|
81
|
+
# @param client_ip [String, nil] the connecting address; nil builds a
|
|
82
|
+
# request with no resolvable client IP, whose otto.ip_match denies
|
|
83
|
+
# every range and whose env['otto.client_ip'] is nil
|
|
84
|
+
# @param security_config [Otto::Security::Config, nil]
|
|
85
|
+
# @param rack_options [Hash] remaining Rack::MockRequest.env_for options
|
|
86
|
+
# (method:, params:, input:, and String env keys such as
|
|
87
|
+
# 'HTTP_USER_AGENT')
|
|
88
|
+
# @return [Hash] the env
|
|
89
|
+
# @raise [ArgumentError] if rack_options carry a forwarded-for header
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# env = Otto::Testing.env_for('/admin', client_ip: '203.0.113.9',
|
|
93
|
+
# security_config: otto.security_config)
|
|
94
|
+
# env['otto.client_ip'] # => "203.0.113.0"
|
|
95
|
+
# env['otto.ip_match'].call(['203.0.113.9/32']) # => true
|
|
96
|
+
def env_for(uri = '/', client_ip:, security_config:, **rack_options)
|
|
97
|
+
forwarded = Otto::Utils::CLIENT_ADDRESS_HEADERS & rack_options.keys
|
|
98
|
+
unless forwarded.empty?
|
|
99
|
+
raise ArgumentError, "env_for builds a direct request from client_ip; #{forwarded.join(', ')} " \
|
|
100
|
+
'would change which address resolves. Build the env yourself and call ' \
|
|
101
|
+
'Otto::Testing.resolve_client_ip! for a relayed request.'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
env = Rack::MockRequest.env_for(uri, rack_options)
|
|
105
|
+
if client_ip.nil?
|
|
106
|
+
env.delete('REMOTE_ADDR')
|
|
107
|
+
else
|
|
108
|
+
env['REMOTE_ADDR'] = client_ip
|
|
109
|
+
end
|
|
110
|
+
resolve_client_ip!(env, security_config)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Run IPPrivacyMiddleware over an existing env, in place, so it carries
|
|
114
|
+
# the keys the middleware writes, all derived from one resolution. Use it
|
|
115
|
+
# for relayed requests: REMOTE_ADDR is the proxy and the forwarded headers
|
|
116
|
+
# carry the client.
|
|
117
|
+
#
|
|
118
|
+
# Pass the application's security config. Its proxy trust decides whether
|
|
119
|
+
# the forwarded headers are read at all; resolved without it, the proxy
|
|
120
|
+
# becomes the client, and the app's middleware keeps that result.
|
|
121
|
+
#
|
|
122
|
+
# @param env [Hash] Rack env, with REMOTE_ADDR and any forwarded headers
|
|
123
|
+
# @param security_config [Otto::Security::Config, nil] see {.env_for}
|
|
124
|
+
# @return [Hash] the same env
|
|
125
|
+
# @raise [ArgumentError] if env was already resolved, since the
|
|
126
|
+
# middleware would keep the earlier result instead of applying
|
|
127
|
+
# security_config
|
|
128
|
+
# @raise [RuntimeError] if the middleware returned without installing
|
|
129
|
+
# otto.ip_match, which every path that resolves an env writes
|
|
130
|
+
def resolve_client_ip!(env, security_config)
|
|
131
|
+
if env.key?('otto.client_ip') || env.key?('otto.ip_match')
|
|
132
|
+
raise ArgumentError, 'env already carries otto.client_ip or otto.ip_match, so IPPrivacyMiddleware ' \
|
|
133
|
+
'would keep that result instead of resolving under this security_config'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
Otto::Security::Middleware::IPPrivacyMiddleware.new(RESOLVED_APP, security_config).call(env)
|
|
137
|
+
# The middleware's response is discarded, so check its effect instead: a
|
|
138
|
+
# path that answered before resolving would otherwise hand back an env
|
|
139
|
+
# that looks built and is not.
|
|
140
|
+
unless env.key?('otto.ip_match')
|
|
141
|
+
raise 'IPPrivacyMiddleware returned without installing otto.ip_match; Otto::Testing ' \
|
|
142
|
+
'no longer matches the middleware and needs updating'
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
env
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/otto/utils.rb
CHANGED
|
@@ -45,6 +45,14 @@ class Otto
|
|
|
45
45
|
# fallback scan cannot drift.
|
|
46
46
|
RELAY_MARKER_HEADERS = (FORWARDED_FOR_HEADERS + FORWARDED_AUTHORITY_HEADERS).uniq.freeze
|
|
47
47
|
|
|
48
|
+
# Headers the client-IP resolver may read an address from: the
|
|
49
|
+
# forwarded-for family (the CIDR walk, and X-Forwarded-For in depth mode)
|
|
50
|
+
# and RFC 7239 Forwarded (depth mode with trusted_proxy_header 'Forwarded'
|
|
51
|
+
# or 'Both'). A header resolve_client_ip starts reading belongs here, so
|
|
52
|
+
# that IPPrivacyMiddleware deletes it when no client IP resolves and
|
|
53
|
+
# Otto::Testing.env_for refuses it in a request it builds as direct.
|
|
54
|
+
CLIENT_ADDRESS_HEADERS = (FORWARDED_FOR_HEADERS + %w[HTTP_FORWARDED]).freeze
|
|
55
|
+
|
|
48
56
|
# Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
|
|
49
57
|
# predicates do not cover but that should still be treated as non-public
|
|
50
58
|
# (e.g. when picking the real client out of a forwarded chain).
|
|
@@ -100,20 +108,21 @@ class Otto
|
|
|
100
108
|
# scrub invalid/undefined bytes, and strip a single trailing slash.
|
|
101
109
|
#
|
|
102
110
|
# This is the SINGLE SOURCE OF TRUTH shared by the router
|
|
103
|
-
# (Otto::Core::Router#handle_request,
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
# (
|
|
116
|
-
#
|
|
111
|
+
# (Otto::Core::Router#handle_request, through #routing_path) and every guard
|
|
112
|
+
# that compares a request path or a configured path against what the router
|
|
113
|
+
# dispatches (Otto::CaddyTLS::LocalhostGuard, Otto::MCP.endpoint_path?).
|
|
114
|
+
# For a request, call #routing_path rather than passing PATH_INFO here
|
|
115
|
+
# yourself. Guard and router MUST normalize identically: if a crafted
|
|
116
|
+
# path — a trailing slash, a percent-encoded byte, an invalid UTF-8 byte —
|
|
117
|
+
# normalized differently in the guard than in the router, the router could
|
|
118
|
+
# dispatch a request the guard let through. One implementation makes that
|
|
119
|
+
# drift impossible.
|
|
120
|
+
#
|
|
121
|
+
# Robust to invalid input. Rack::Utils.unescape raises ArgumentError on a
|
|
122
|
+
# malformed escape (%zz, a trailing %) and on an invalid byte in a
|
|
123
|
+
# UTF-8-tagged string (a raw \xFF); either way the raw string is kept.
|
|
124
|
+
# Invalid UTF-8 is scrubbed after that, so a raw \xFF and a percent-encoded
|
|
125
|
+
# %FF normalize alike. The method itself does not raise.
|
|
117
126
|
#
|
|
118
127
|
# @param raw_path [String, nil] a raw PATH_INFO or a configured endpoint
|
|
119
128
|
# @return [String] normalized path suitable for exact literal comparison
|
|
@@ -131,6 +140,46 @@ class Otto
|
|
|
131
140
|
.gsub(%r{/$}, '')
|
|
132
141
|
end
|
|
133
142
|
|
|
143
|
+
# The path Otto's router matches for this request. Use it in any code that
|
|
144
|
+
# judges a request by its path before the router sees it: guards,
|
|
145
|
+
# throttles, session skips, audit filters.
|
|
146
|
+
#
|
|
147
|
+
# The router does not match raw PATH_INFO. It matches this value, and
|
|
148
|
+
# Otto::Core::Router#handle_request calls this method to get it, so a guard
|
|
149
|
+
# that reads routing_path sees the path the router dispatches on. A guard
|
|
150
|
+
# that reads anything else can see a different path, and when it matches
|
|
151
|
+
# less than the router does the difference is a bypass: GET /%63olonel is
|
|
152
|
+
# '/%63olonel' as raw PATH_INFO and '/colonel' to the router.
|
|
153
|
+
#
|
|
154
|
+
# By default the result is mount-relative. Rack::URLMap
|
|
155
|
+
# (`map '/api' { run otto }`) moves the mount prefix into SCRIPT_NAME and
|
|
156
|
+
# leaves the remainder in PATH_INFO, which is all the router sees; this is
|
|
157
|
+
# the form to compare against paths as written in a routes file.
|
|
158
|
+
#
|
|
159
|
+
# With +include_mount: true+ SCRIPT_NAME and PATH_INFO are joined and then
|
|
160
|
+
# normalized as one string, giving the request's full path. That is
|
|
161
|
+
# the form for middleware shared by several mounted apps and configured
|
|
162
|
+
# with external URLs: inside an app mounted at /api/v2, '/status' is the
|
|
163
|
+
# mount-relative path and '/api/v2/status' the mounted one, and matching
|
|
164
|
+
# the mount-relative form would also match every other app's /status.
|
|
165
|
+
#
|
|
166
|
+
# The value is normalize_path output, so root is '' (the router's literal
|
|
167
|
+
# table keys root the same way) and a configured path must go through
|
|
168
|
+
# normalize_path before an exact comparison. Never raises: a malformed
|
|
169
|
+
# escape such as %zz is kept as written.
|
|
170
|
+
#
|
|
171
|
+
# Not memoized: middleware may rewrite PATH_INFO or SCRIPT_NAME, and the
|
|
172
|
+
# router must see the value as it stands at dispatch.
|
|
173
|
+
#
|
|
174
|
+
# @param env [Hash] Rack environment
|
|
175
|
+
# @param include_mount [Boolean] prepend SCRIPT_NAME (the mount prefix)
|
|
176
|
+
# @return [String] normalized path
|
|
177
|
+
def routing_path(env, include_mount: false)
|
|
178
|
+
path = env['PATH_INFO']
|
|
179
|
+
path = "#{env['SCRIPT_NAME']}#{path}" if include_mount
|
|
180
|
+
normalize_path(path)
|
|
181
|
+
end
|
|
182
|
+
|
|
134
183
|
# Validate and normalize an IP address (IPv4 and IPv6).
|
|
135
184
|
#
|
|
136
185
|
# Strips an optional port (IPv6-safe), validates with IPAddr, and returns
|
data/lib/otto/version.rb
CHANGED