otto 2.8.1 → 2.10.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/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +239 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +139 -38
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +42 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/response.rb +7 -1
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +379 -20
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/csp/emit_middleware.rb +2 -1
- data/lib/otto/security/csp/policy.rb +159 -7
- data/lib/otto/security/csp/request_extras.rb +256 -0
- data/lib/otto/security/csp/writer.rb +153 -12
- data/lib/otto/security/csp.rb +5 -0
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +32 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- data/docs/reverse-proxy-network-services.md +0 -371
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require_relative 'request_extras'
|
|
6
|
+
|
|
5
7
|
class Otto
|
|
6
8
|
module Security
|
|
7
9
|
module CSP
|
|
@@ -39,6 +41,10 @@ class Otto
|
|
|
39
41
|
CSP_HEADER = 'content-security-policy'
|
|
40
42
|
CONTENT_TYPE_HEADER = 'content-type'
|
|
41
43
|
|
|
44
|
+
# Method#parameters types that declare a keyword parameter (used by
|
|
45
|
+
# {.supports_extra_directives?}).
|
|
46
|
+
KEYWORD_PARAM_TYPES = %i[key keyreq].freeze
|
|
47
|
+
|
|
42
48
|
# Emission modes. `:override` is a deliberate per-request call that
|
|
43
49
|
# REPLACES any existing CSP (the caller owns this response's policy).
|
|
44
50
|
# `:backstop` is a passive layer that DEFERS to an existing CSP (it only
|
|
@@ -51,23 +57,26 @@ class Otto
|
|
|
51
57
|
# written". `policy` is the emitted policy on success, or the pre-existing
|
|
52
58
|
# policy when a `:backstop` deferred to one. `skip_reason` is one of
|
|
53
59
|
# `:disabled`, `:blank_nonce`, `:non_html`, `:existing_csp` when skipped,
|
|
54
|
-
# else nil.
|
|
60
|
+
# else nil. `extra_directives` carries the request-scoped extras that
|
|
61
|
+
# were ACTUALLY folded into the policy — entries dropped during the
|
|
62
|
+
# append (absent directive) are excluded — or nil when none landed.
|
|
55
63
|
class Result
|
|
56
64
|
# Recognized skip reasons, in the order {Writer.apply} evaluates them.
|
|
57
65
|
SKIP_REASONS = %i[disabled blank_nonce non_html existing_csp].freeze
|
|
58
66
|
|
|
59
|
-
attr_reader :policy, :skip_reason, :mode
|
|
67
|
+
attr_reader :policy, :skip_reason, :mode, :extra_directives
|
|
60
68
|
|
|
61
|
-
def initialize(applied:, mode:, policy: nil, skip_reason: nil)
|
|
69
|
+
def initialize(applied:, mode:, policy: nil, skip_reason: nil, extra_directives: nil)
|
|
62
70
|
@applied = applied
|
|
63
71
|
@mode = mode
|
|
64
72
|
@policy = policy
|
|
65
73
|
@skip_reason = skip_reason
|
|
74
|
+
@extra_directives = extra_directives
|
|
66
75
|
end
|
|
67
76
|
|
|
68
77
|
# Build an "applied" result for a written policy.
|
|
69
|
-
def self.applied(policy, mode:)
|
|
70
|
-
new(applied: true, mode: mode, policy: policy)
|
|
78
|
+
def self.applied(policy, mode:, extra_directives: nil)
|
|
79
|
+
new(applied: true, mode: mode, policy: policy, extra_directives: extra_directives)
|
|
71
80
|
end
|
|
72
81
|
|
|
73
82
|
# Build a "skipped" result. `policy` carries the pre-existing policy for
|
|
@@ -96,23 +105,35 @@ class Otto
|
|
|
96
105
|
# and the policy string ({Otto::Security::Config#generate_nonce_csp}).
|
|
97
106
|
# @param mode [Symbol] one of {MODES}.
|
|
98
107
|
# @param development_mode [Boolean] use the development directive set.
|
|
108
|
+
# @param env [Hash, nil] the Rack environment. When given AND the
|
|
109
|
+
# config has opted the channel in
|
|
110
|
+
# ({Otto::Security::Config#enable_csp_request_extras!}), any
|
|
111
|
+
# request-scoped directive extras the app wrote to
|
|
112
|
+
# `env['otto.csp.extra_directives']` are sanitized
|
|
113
|
+
# ({Otto::Security::CSP::RequestExtras.from_env}) and folded
|
|
114
|
+
# ADDITIVELY into the policy. Nil (surfaces with no env in hand)
|
|
115
|
+
# simply builds the policy without extras; without the opt-in the
|
|
116
|
+
# env key is ignored entirely.
|
|
99
117
|
# @return [Result]
|
|
100
118
|
# @raise [ArgumentError] if mode is not one of {MODES}
|
|
101
119
|
# @raise [FrozenError] if a write is attempted against a frozen headers hash
|
|
102
|
-
def self.apply(headers, nonce, config:, mode: :override, development_mode: false)
|
|
120
|
+
def self.apply(headers, nonce, config:, mode: :override, development_mode: false, env: nil)
|
|
103
121
|
unless MODES.include?(mode)
|
|
104
122
|
raise ArgumentError, "mode must be one of #{MODES.join(', ')}, got #{mode.inspect}"
|
|
105
123
|
end
|
|
106
124
|
|
|
107
|
-
result = evaluate(headers, nonce, config, mode, development_mode)
|
|
125
|
+
result = evaluate(headers, nonce, config, mode, development_mode, env)
|
|
108
126
|
log_debug(config, result)
|
|
109
127
|
result
|
|
110
128
|
end
|
|
111
129
|
|
|
112
130
|
# Guarded core: returns a Result and performs the in-place write when it
|
|
113
131
|
# applies. Guards are evaluated most-fundamental first so the reported
|
|
114
|
-
# skip_reason is stable and meaningful.
|
|
115
|
-
|
|
132
|
+
# skip_reason is stable and meaningful. Request-scoped extras are
|
|
133
|
+
# resolved only once every guard has passed — so a skipped response
|
|
134
|
+
# never logs extras drops for a policy that was never built — and only
|
|
135
|
+
# when the config opted the channel in (see {.resolve_extras}).
|
|
136
|
+
def self.evaluate(headers, nonce, config, mode, development_mode, env)
|
|
116
137
|
return Result.skipped(:disabled, mode: mode) unless enabled?(config)
|
|
117
138
|
return Result.skipped(:blank_nonce, mode: mode) if blank?(nonce)
|
|
118
139
|
return Result.skipped(:non_html, mode: mode) unless html_response?(headers)
|
|
@@ -120,12 +141,126 @@ class Otto
|
|
|
120
141
|
existing = existing_csp(headers)
|
|
121
142
|
return Result.skipped(:existing_csp, mode: mode, policy: existing) if existing && mode == :backstop
|
|
122
143
|
|
|
123
|
-
|
|
144
|
+
extras = resolve_extras(config, env)
|
|
145
|
+
policy, applied_extras = build_policy(config, nonce, development_mode, extras, env)
|
|
124
146
|
write_csp(headers, policy)
|
|
125
|
-
Result.applied(policy, mode: mode)
|
|
147
|
+
Result.applied(policy, mode: mode, extra_directives: applied_extras)
|
|
126
148
|
end
|
|
127
149
|
private_class_method :evaluate
|
|
128
150
|
|
|
151
|
+
# Build the policy string, folding in the request-scoped extras when
|
|
152
|
+
# the config supports them. Returns `[policy, applied_extras]` where
|
|
153
|
+
# applied_extras is the hash of extras entries that ACTUALLY landed in
|
|
154
|
+
# the policy (nil when none did), as reported back by
|
|
155
|
+
# {Policy.append_extra_sources} through the outcome block — never the
|
|
156
|
+
# pre-append input, so {Result#extra_directives} and the debug log can
|
|
157
|
+
# only claim what happened. Entries the append dropped (absent
|
|
158
|
+
# directive) are logged HERE, the one place with the env in hand, with
|
|
159
|
+
# full request context; Policy stays a pure function of its arguments.
|
|
160
|
+
#
|
|
161
|
+
# Configs are duck-typed (see {.enabled?}): one with the pre-#243
|
|
162
|
+
# `generate_nonce_csp` signature would raise ArgumentError on the
|
|
163
|
+
# `extra_directives:` kwarg at request time — violating the extras
|
|
164
|
+
# channel's never-raises invariant — so the kwarg is passed only when
|
|
165
|
+
# the signature declares it. Otherwise the historical call shape is
|
|
166
|
+
# used and the extras are dropped with a single structured warn
|
|
167
|
+
# (`reason: :config_without_extras_support`).
|
|
168
|
+
#
|
|
169
|
+
# Declaring the kwarg is only half the duck-config protocol: opting
|
|
170
|
+
# in means BOTH accepting `extra_directives:` AND invoking the
|
|
171
|
+
# outcome block. A config that takes the kwarg but never yields
|
|
172
|
+
# leaves the outcome unknown — its policy string is still used as
|
|
173
|
+
# returned, but no applied extras are reported
|
|
174
|
+
# ({Result#extra_directives} stays nil; never fabricated from the
|
|
175
|
+
# pre-append input) and a single structured warn
|
|
176
|
+
# (`reason: :config_outcome_not_reported`) flags the gap.
|
|
177
|
+
def self.build_policy(config, nonce, development_mode, extras, env)
|
|
178
|
+
return [config.generate_nonce_csp(nonce, development_mode: development_mode), nil] if extras.nil?
|
|
179
|
+
|
|
180
|
+
unless supports_extra_directives?(config)
|
|
181
|
+
Otto.structured_log(
|
|
182
|
+
:warn, 'CSP request extras dropped',
|
|
183
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
184
|
+
directives: extras.keys.join(' '),
|
|
185
|
+
reason: :config_without_extras_support
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return [config.generate_nonce_csp(nonce, development_mode: development_mode), nil]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
applied = nil
|
|
192
|
+
reported = false
|
|
193
|
+
policy = config.generate_nonce_csp(
|
|
194
|
+
nonce, development_mode: development_mode, extra_directives: extras
|
|
195
|
+
) do |applied_extras, dropped_extras|
|
|
196
|
+
reported = true
|
|
197
|
+
applied = applied_extras unless applied_extras.empty?
|
|
198
|
+
log_dropped_extras(env, dropped_extras)
|
|
199
|
+
end
|
|
200
|
+
unless reported
|
|
201
|
+
Otto.structured_log(
|
|
202
|
+
:warn, 'CSP request extras outcome unreported',
|
|
203
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
204
|
+
directives: extras.keys.join(' '),
|
|
205
|
+
reason: :config_outcome_not_reported
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
[policy, applied]
|
|
210
|
+
end
|
|
211
|
+
private_class_method :build_policy
|
|
212
|
+
|
|
213
|
+
# Whether the config's generate_nonce_csp declares the
|
|
214
|
+
# `extra_directives:` keyword (or accepts arbitrary keywords).
|
|
215
|
+
def self.supports_extra_directives?(config)
|
|
216
|
+
parameters = config.method(:generate_nonce_csp).parameters
|
|
217
|
+
parameters.any? { |type, name| KEYWORD_PARAM_TYPES.include?(type) && name == :extra_directives } ||
|
|
218
|
+
parameters.any? { |type, _name| type == :keyrest }
|
|
219
|
+
rescue NameError
|
|
220
|
+
false
|
|
221
|
+
end
|
|
222
|
+
private_class_method :supports_extra_directives?
|
|
223
|
+
|
|
224
|
+
# Log each extras entry the policy build dropped, once per entry, with
|
|
225
|
+
# full request context. The reason distinguishes the two ways an entry
|
|
226
|
+
# fails to land: its directive was absent from the built policy
|
|
227
|
+
# (:absent_directive) or the directive takes no value at all, so a
|
|
228
|
+
# source could never be appended to it (:valueless_directive) — the
|
|
229
|
+
# latter is an app bug worth naming precisely, since the entry is a
|
|
230
|
+
# no-op rather than a policy gap.
|
|
231
|
+
def self.log_dropped_extras(env, dropped)
|
|
232
|
+
return if dropped.nil? || dropped.empty?
|
|
233
|
+
|
|
234
|
+
context = Otto::LoggingHelpers.request_context(env)
|
|
235
|
+
dropped.each do |name, tokens|
|
|
236
|
+
reason = Policy.valueless_directive?(name) ? :valueless_directive : :absent_directive
|
|
237
|
+
Otto.structured_log(
|
|
238
|
+
:warn, 'CSP request extra dropped',
|
|
239
|
+
context.merge(
|
|
240
|
+
directive: name,
|
|
241
|
+
token: Array(tokens).join(' ').inspect.slice(0, 128),
|
|
242
|
+
reason: reason
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
private_class_method :log_dropped_extras
|
|
248
|
+
|
|
249
|
+
# Resolve the request-scoped extras, gated on the boot-time opt-in.
|
|
250
|
+
# The env key is a write surface ANY middleware in the Rack stack can
|
|
251
|
+
# reach — a lower-trust position than boot code — so the channel does
|
|
252
|
+
# not exist until {Otto::Security::Config#enable_csp_request_extras!}
|
|
253
|
+
# was called: when disabled (including duck-typed configs without the
|
|
254
|
+
# predicate) the env key is ignored entirely, with no sanitize work
|
|
255
|
+
# and no logs.
|
|
256
|
+
def self.resolve_extras(config, env)
|
|
257
|
+
return nil unless env
|
|
258
|
+
return nil unless config.respond_to?(:csp_request_extras_enabled?) && config.csp_request_extras_enabled?
|
|
259
|
+
|
|
260
|
+
RequestExtras.from_env(env)
|
|
261
|
+
end
|
|
262
|
+
private_class_method :resolve_extras
|
|
263
|
+
|
|
129
264
|
# In-place, key-scoped write. Delete any case-variant of the CSP key
|
|
130
265
|
# (correcting a downstream SPEC violation), then write the canonical
|
|
131
266
|
# lowercase key into the caller's hash. Variant keys are collected before
|
|
@@ -182,12 +317,18 @@ class Otto
|
|
|
182
317
|
|
|
183
318
|
# Uniform debug observability: when the config opts into CSP debugging,
|
|
184
319
|
# log the outcome — applied policy OR skip reason — so "why didn't my page
|
|
185
|
-
# get a CSP?" no longer needs a debugger.
|
|
320
|
+
# get a CSP?" no longer needs a debugger. When request-scoped extras
|
|
321
|
+
# were folded in, note which directives and how many tokens: the
|
|
322
|
+
# EmitMiddleware backstop is otherwise silent about them, so this is
|
|
323
|
+
# the observable trace that a request's extras actually landed.
|
|
186
324
|
def self.log_debug(config, result)
|
|
187
325
|
return unless config.respond_to?(:debug_csp?) && config.debug_csp?
|
|
188
326
|
return unless defined?(Otto.logger) && Otto.logger
|
|
189
327
|
|
|
190
328
|
detail = result.applied? ? "applied (#{result.mode}) #{result.policy}" : "skipped (#{result.skip_reason})"
|
|
329
|
+
if (extras = result.extra_directives)
|
|
330
|
+
detail += " extras[#{extras.keys.join(' ')}](#{extras.each_value.sum(&:length)} tokens)"
|
|
331
|
+
end
|
|
191
332
|
Otto.logger.debug("[CSP] #{detail}")
|
|
192
333
|
end
|
|
193
334
|
private_class_method :log_debug
|
data/lib/otto/security/csp.rb
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
# Result object, :override / :backstop modes) that every surface
|
|
15
15
|
# routes through: Otto::Response#apply_csp, the EmitMiddleware,
|
|
16
16
|
# and the deprecated Otto::Response#send_csp_headers shim.
|
|
17
|
+
# - RequestExtras — reads + sanitizes the opt-in request-scoped directive
|
|
18
|
+
# extras from env['otto.csp.extra_directives'] (delano/otto#243);
|
|
19
|
+
# the Writer folds the survivors in additively via
|
|
20
|
+
# Policy.append_extra_sources.
|
|
17
21
|
# - EmitMiddleware — passive backstop that emits a nonce CSP for responses whose
|
|
18
22
|
# request consumed a nonce (emit-if-consumed). See
|
|
19
23
|
# Otto::Security::Core#enable_csp_emission!.
|
|
@@ -23,6 +27,7 @@
|
|
|
23
27
|
# callback API. See Otto::Security::Core#enable_csp_reporting!.
|
|
24
28
|
|
|
25
29
|
require_relative 'csp/policy'
|
|
30
|
+
require_relative 'csp/request_extras'
|
|
26
31
|
require_relative 'csp/nonce'
|
|
27
32
|
require_relative 'csp/writer'
|
|
28
33
|
require_relative 'csp/report'
|
|
@@ -36,6 +36,14 @@ class Otto
|
|
|
36
36
|
# # env['otto.original_ip'] also contains real IP
|
|
37
37
|
#
|
|
38
38
|
class IPPrivacyMiddleware
|
|
39
|
+
# Forwarding metadata Rack::Request reads without consulting Otto's
|
|
40
|
+
# proxy trust verdict: host (#host/#authority), scheme (#scheme/#ssl?),
|
|
41
|
+
# and port (#port), from both the X-Forwarded-* family and RFC 7239
|
|
42
|
+
# Forwarded (host=, proto=, and the port inside for=). Defined in
|
|
43
|
+
# Otto::Utils so Otto::Utils::RELAY_MARKER_HEADERS is built from the
|
|
44
|
+
# same list and cannot fall out of step with what is scrubbed here.
|
|
45
|
+
UNTRUSTED_FORWARDING_METADATA_HEADERS = Otto::Utils::FORWARDED_AUTHORITY_HEADERS
|
|
46
|
+
|
|
39
47
|
# Initialize IP Privacy middleware
|
|
40
48
|
#
|
|
41
49
|
# @param app [#call] Rack application
|
|
@@ -55,8 +63,13 @@ class Otto
|
|
|
55
63
|
# canonical client IP for this request, do not re-resolve or re-mask.
|
|
56
64
|
# This makes stacking two instances (e.g. an app-level mount plus
|
|
57
65
|
# Otto's built-in router mount) order-safe instead of double-masking.
|
|
66
|
+
#
|
|
67
|
+
# Client-IP resolution is idempotent, but proxy TRUST is not: the
|
|
68
|
+
# prior pass may have run under a different (or no) configuration,
|
|
69
|
+
# so this instance still enforces its own trust posture below.
|
|
58
70
|
if env.key?('otto.client_ip')
|
|
59
71
|
ensure_ip_match_present(env)
|
|
72
|
+
enforce_proxy_trust_after_prior_pass(env)
|
|
60
73
|
return @app.call(env)
|
|
61
74
|
end
|
|
62
75
|
|
|
@@ -65,7 +78,9 @@ class Otto
|
|
|
65
78
|
# REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
|
|
66
79
|
#
|
|
67
80
|
# TRI-STATE: the key is written ONLY when the operator configured
|
|
68
|
-
# proxy trust (CIDR matchers
|
|
81
|
+
# proxy trust (CIDR matchers, a depth, or the explicit trust-nobody
|
|
82
|
+
# assertion `trusted_proxies: :none`, which makes the value false for
|
|
83
|
+
# every peer, #259). Present, its value is
|
|
69
84
|
# authoritative in both directions — true means the peer matched a
|
|
70
85
|
# CIDR (filter mode) or depth mode is active (configuring a depth
|
|
71
86
|
# asserts the connecting peer IS the operator's proxy tier, #226);
|
|
@@ -77,9 +92,24 @@ class Otto
|
|
|
77
92
|
# forced consumers into grant-only reads (#228).
|
|
78
93
|
# respond_to?: like geo_headers_trusted?, a partial/duck-typed
|
|
79
94
|
# config (or nil) that cannot report trust state is "unconfigured".
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
proxy_trust_configured = @security_config.respond_to?(:proxy_trust_configured?) &&
|
|
96
|
+
@security_config.proxy_trust_configured?
|
|
97
|
+
via_trusted_proxy = proxy_trust_configured && trusted_proxy?(env['REMOTE_ADDR'])
|
|
98
|
+
env['otto.via_trusted_proxy'] = via_trusted_proxy if proxy_trust_configured
|
|
99
|
+
|
|
100
|
+
# Record whether the request was relayed BEFORE the scrub below can
|
|
101
|
+
# delete the relay markers. Downstream middleware that must
|
|
102
|
+
# authenticate a direct local call (Otto::CaddyTLS::LocalhostGuard)
|
|
103
|
+
# runs after this one and would otherwise read a request whose
|
|
104
|
+
# HTTP_FORWARDED was deleted here as "direct". Leak-free boolean.
|
|
105
|
+
env['otto.peer_relayed'] = Otto::Utils.relayed_request?(env)
|
|
106
|
+
|
|
107
|
+
# A peer that failed configured proxy trust cannot supply forwarded
|
|
108
|
+
# host, scheme, or port either. Unconfigured trust (absent tri-state
|
|
109
|
+
# key) is left alone: the operator has asserted nothing, and the
|
|
110
|
+
# contract reserves that state for downstream heuristics (#228), so
|
|
111
|
+
# Rack keeps its own defaults there.
|
|
112
|
+
scrub_untrusted_forwarding_metadata(env) if proxy_trust_configured && !via_trusted_proxy
|
|
83
113
|
|
|
84
114
|
# Same rationale, for loopback: this middleware runs outermost, so a
|
|
85
115
|
# downstream middleware that must authenticate a DIRECT LOCAL CALL
|
|
@@ -121,6 +151,58 @@ class Otto
|
|
|
121
151
|
@config.enabled?
|
|
122
152
|
end
|
|
123
153
|
|
|
154
|
+
# Enforce THIS instance's proxy trust posture when a prior
|
|
155
|
+
# IPPrivacyMiddleware pass already resolved the client IP.
|
|
156
|
+
#
|
|
157
|
+
# The early return in #call keeps client-IP resolution idempotent, but
|
|
158
|
+
# trust enforcement must not ride on it: an outer instance mounted
|
|
159
|
+
# without the application's security config (the "unconfigured
|
|
160
|
+
# defaults" case in docs/guides/privacy.md) writes otto.client_ip and
|
|
161
|
+
# nothing else, and returning here would let an inner
|
|
162
|
+
# `trusted_proxies: :none` instance pass X-Forwarded-Host through
|
|
163
|
+
# untouched — the exact bypass the assertion exists to close.
|
|
164
|
+
#
|
|
165
|
+
# What can still be decided after the prior pass:
|
|
166
|
+
# - trust-nobody: the verdict is false for every peer, so it needs no
|
|
167
|
+
# peer address. Always enforced, overriding a laxer prior verdict.
|
|
168
|
+
# - depth mode: the verdict is true by assertion. Recorded only when
|
|
169
|
+
# no configured pass has spoken (key absent).
|
|
170
|
+
# - CIDR mode: needs the connecting peer, which the prior pass has
|
|
171
|
+
# rewritten (REMOTE_ADDR is now the resolved, possibly masked, client
|
|
172
|
+
# IP — matching a masked address against a narrow CIDR produces false
|
|
173
|
+
# ALLOWs, see #ensure_ip_match_present). A verdict recorded by a prior
|
|
174
|
+
# CONFIGURED pass is kept (the same-config stacking case). With none,
|
|
175
|
+
# fail closed: deny, scrub, and warn so the misconfiguration is
|
|
176
|
+
# diagnosable rather than a silent grant.
|
|
177
|
+
#
|
|
178
|
+
# Every deny also scrubs the authority carriers, which is idempotent:
|
|
179
|
+
# deleting an already-deleted key is a no-op.
|
|
180
|
+
#
|
|
181
|
+
# @param env [Hash] Rack environment
|
|
182
|
+
def enforce_proxy_trust_after_prior_pass(env)
|
|
183
|
+
return unless @security_config.respond_to?(:proxy_trust_configured?) &&
|
|
184
|
+
@security_config.proxy_trust_configured?
|
|
185
|
+
|
|
186
|
+
if @security_config.trust_no_proxies?
|
|
187
|
+
env['otto.via_trusted_proxy'] = false
|
|
188
|
+
scrub_untrusted_forwarding_metadata(env)
|
|
189
|
+
elsif env.key?('otto.via_trusted_proxy')
|
|
190
|
+
nil # a configured pass already decided; honor it
|
|
191
|
+
elsif @security_config.trusted_proxy_depth_mode?
|
|
192
|
+
env['otto.via_trusted_proxy'] = true
|
|
193
|
+
else
|
|
194
|
+
Otto.logger.warn(
|
|
195
|
+
'[IPPrivacyMiddleware] otto.client_ip was resolved by a prior ' \
|
|
196
|
+
'pass with no proxy trust configured, so the connecting peer ' \
|
|
197
|
+
'can no longer be matched against trusted_proxies; treating ' \
|
|
198
|
+
'the peer as untrusted and stripping forwarded authority. ' \
|
|
199
|
+
'Pass the application security config to the outer instance.'
|
|
200
|
+
)
|
|
201
|
+
env['otto.via_trusted_proxy'] = false
|
|
202
|
+
scrub_untrusted_forwarding_metadata(env)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
124
206
|
# Guarantee env['otto.ip_match'] exists on the idempotent-return path.
|
|
125
207
|
#
|
|
126
208
|
# Every path in this middleware that sets otto.client_ip installs the
|
|
@@ -389,6 +471,25 @@ class Otto
|
|
|
389
471
|
env['otto.ip_match'] = ->(cidrs) { Otto::Utils.ip_in_cidrs?(client_ip, cidrs) }
|
|
390
472
|
end
|
|
391
473
|
|
|
474
|
+
# Remove forwarding metadata supplied by a peer that failed proxy trust.
|
|
475
|
+
#
|
|
476
|
+
# Rack honors these according only to the process-global forwarding
|
|
477
|
+
# family, so without this an untrusted client would control
|
|
478
|
+
# request.host, request.ssl?, and request.port for every mounted Rack
|
|
479
|
+
# app (Rack::Session's Secure-cookie gate reads ssl?). Delete the
|
|
480
|
+
# carriers rather than editing them: this path runs only in CIDR filter
|
|
481
|
+
# mode or under the trust-nobody assertion (`trusted_proxies: :none`,
|
|
482
|
+
# #259), neither of which reads the Forwarded header itself, and a
|
|
483
|
+
# hand-rolled RFC 7239 parser that disagrees with Rack's on quoting
|
|
484
|
+
# (e.g. `for=a"b;host=evil`) would let a host= survive the edit.
|
|
485
|
+
# X-Forwarded-For stays, masked or not, because Otto's own resolution
|
|
486
|
+
# already ignores it from an untrusted peer.
|
|
487
|
+
#
|
|
488
|
+
# @param env [Hash] Rack environment
|
|
489
|
+
def scrub_untrusted_forwarding_metadata(env)
|
|
490
|
+
UNTRUSTED_FORWARDING_METADATA_HEADERS.each { |key| env.delete(key) }
|
|
491
|
+
end
|
|
492
|
+
|
|
392
493
|
# Delete forwarded IP headers outright.
|
|
393
494
|
#
|
|
394
495
|
# Used on the no-resolvable-client-IP path, where there is no masked IP
|
|
@@ -27,13 +27,8 @@ class Otto
|
|
|
27
27
|
def initialize(app, security_config = nil)
|
|
28
28
|
@app = app
|
|
29
29
|
@security_config = security_config
|
|
30
|
-
@rate_limiter_available = defined?(Rack::Attack)
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
configure_rate_limiting
|
|
34
|
-
else
|
|
35
|
-
Otto.logger.warn '[Otto] rack-attack not available - rate limiting disabled'
|
|
36
|
-
end
|
|
31
|
+
configure_rate_limiting
|
|
37
32
|
end
|
|
38
33
|
|
|
39
34
|
# Pass-through call - actual rate limiting handled by Rack::Attack
|
|
@@ -4,18 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
require 'rack/attack'
|
|
9
|
-
rescue LoadError
|
|
10
|
-
# rack-attack is optional - graceful fallback
|
|
11
|
-
end
|
|
7
|
+
require_relative '../optional_dependency'
|
|
12
8
|
|
|
13
9
|
class Otto
|
|
14
10
|
module Security
|
|
15
11
|
# Rate limiting implementation using Rack::Attack
|
|
16
12
|
class RateLimiting
|
|
13
|
+
RACK_ATTACK_REQUIREMENT = '~> 6.7'
|
|
14
|
+
|
|
15
|
+
def self.ensure_available!
|
|
16
|
+
Otto::OptionalDependency.require!(
|
|
17
|
+
'rack-attack',
|
|
18
|
+
RACK_ATTACK_REQUIREMENT,
|
|
19
|
+
require_path: 'rack/attack',
|
|
20
|
+
feature: 'Rate limiting'
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
17
24
|
def self.configure_rack_attack!(config = {})
|
|
18
|
-
|
|
25
|
+
ensure_available!
|
|
19
26
|
|
|
20
27
|
# Use provided cache store or default
|
|
21
28
|
Rack::Attack.cache.store = config[:cache_store] if config[:cache_store]
|
|
@@ -23,9 +30,13 @@ class Otto
|
|
|
23
30
|
# Default rules
|
|
24
31
|
default_requests_per_minute = config.fetch(:requests_per_minute, 100)
|
|
25
32
|
|
|
26
|
-
# General request throttling
|
|
33
|
+
# General request throttling. Internal paths (/_mcp, /_status, ...)
|
|
34
|
+
# are skipped by default. PATH_INFO, not Rack::Request#path: #path
|
|
35
|
+
# prepends SCRIPT_NAME, so with Otto mounted under `map '/api'` the
|
|
36
|
+
# internal /_mcp request read as /api/_mcp and was counted here while
|
|
37
|
+
# the MCP throttle, comparing the same way, never saw it at all.
|
|
27
38
|
Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
|
|
28
|
-
request.ip unless request.
|
|
39
|
+
request.ip unless request.path_info.start_with?('/_')
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
# Apply custom rules if provided
|
|
@@ -45,52 +56,76 @@ class Otto
|
|
|
45
56
|
end
|
|
46
57
|
end
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
now = match_data[:epoch_time]
|
|
52
|
-
|
|
53
|
-
headers = {
|
|
54
|
-
'content-type' => 'application/json',
|
|
55
|
-
'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
|
|
56
|
-
}
|
|
59
|
+
configure_responses
|
|
60
|
+
configure_logging
|
|
61
|
+
end
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
error: 'Rate limit exceeded',
|
|
67
|
-
message: 'Too many requests',
|
|
68
|
-
retry_after: headers['retry-after'].to_i,
|
|
69
|
-
limit: match_data[:limit],
|
|
70
|
-
period: match_data[:period],
|
|
71
|
-
}
|
|
72
|
-
[429, headers, [JSON.generate(error_response)]]
|
|
73
|
-
else
|
|
74
|
-
body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
|
|
75
|
-
headers['content-type'] = 'text/plain'
|
|
76
|
-
[429, headers, [body]]
|
|
77
|
-
end
|
|
78
|
-
end
|
|
63
|
+
# Rack::Attack holds a single throttled_responder and each subscriber to
|
|
64
|
+
# 'rack.attack' fires on every throttle event. Subclasses override
|
|
65
|
+
# throttled_response and log_throttled_request instead of registering
|
|
66
|
+
# their own responder or subscriber after super, so the hosting app never
|
|
67
|
+
# gets a redundant responder assignment or doubled log lines.
|
|
68
|
+
def self.configure_responses
|
|
69
|
+
Rack::Attack.throttled_responder = ->(request) { throttled_response(request) }
|
|
70
|
+
end
|
|
79
71
|
|
|
72
|
+
def self.configure_logging
|
|
80
73
|
# Log blocked requests if ActiveSupport is available
|
|
81
74
|
return unless defined?(ActiveSupport::Notifications)
|
|
82
75
|
|
|
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).
|
|
88
76
|
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
log_throttled_request(payload)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.throttled_response(request)
|
|
82
|
+
match_data = request.env['rack.attack.match_data']
|
|
83
|
+
general_throttled_response(request, throttle_headers(match_data), match_data)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.throttle_headers(match_data)
|
|
87
|
+
now = match_data[:epoch_time]
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
'content-type' => 'application/json',
|
|
91
|
+
'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Custom response for rate limited requests
|
|
96
|
+
def self.general_throttled_response(request, headers, match_data)
|
|
97
|
+
# Content negotiation for rate limit response
|
|
98
|
+
# Route's response_type takes precedence over Accept header
|
|
99
|
+
route_def = request.env['otto.route_definition']
|
|
100
|
+
wants_json = (route_def&.response_type == 'json') ||
|
|
101
|
+
request.env['HTTP_ACCEPT'].to_s.include?('application/json')
|
|
102
|
+
|
|
103
|
+
if wants_json
|
|
104
|
+
error_response = {
|
|
105
|
+
error: 'Rate limit exceeded',
|
|
106
|
+
message: 'Too many requests',
|
|
107
|
+
retry_after: headers['retry-after'].to_i,
|
|
108
|
+
limit: match_data[:limit],
|
|
109
|
+
period: match_data[:period],
|
|
110
|
+
}
|
|
111
|
+
[429, headers, [JSON.generate(error_response)]]
|
|
112
|
+
else
|
|
113
|
+
body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
|
|
114
|
+
headers['content-type'] = 'text/plain'
|
|
115
|
+
[429, headers, [body]]
|
|
92
116
|
end
|
|
93
117
|
end
|
|
118
|
+
|
|
119
|
+
# Rack::Attack is mounted by the hosting app AHEAD of Otto, so this
|
|
120
|
+
# subscriber sees the raw peer regardless of where IPPrivacyMiddleware
|
|
121
|
+
# sits in Otto's own stack. Log a masked address, never req.ip: a
|
|
122
|
+
# deployment on the default :masked profile must not write raw client
|
|
123
|
+
# IPs to its logs every time a limit trips (issue #219).
|
|
124
|
+
def self.log_throttled_request(payload)
|
|
125
|
+
req = payload[:request]
|
|
126
|
+
ip = Otto::LoggingHelpers.privacy_safe_ip(req.env, req.ip)
|
|
127
|
+
Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{ip}: #{payload[:matched]}"
|
|
128
|
+
end
|
|
94
129
|
end
|
|
95
130
|
end
|
|
96
131
|
end
|
data/lib/otto/utils.rb
CHANGED
|
@@ -19,9 +19,47 @@ class Otto
|
|
|
19
19
|
HTTP_X_CLIENT_IP
|
|
20
20
|
].freeze
|
|
21
21
|
|
|
22
|
+
# Forwarding metadata Rack::Request reads without consulting Otto's proxy
|
|
23
|
+
# trust verdict: host (#host/#authority), scheme (#scheme/#ssl?), and port
|
|
24
|
+
# (#port), from both the X-Forwarded-* family and RFC 7239 Forwarded
|
|
25
|
+
# (host=, proto=, and the port inside for=). IPPrivacyMiddleware deletes
|
|
26
|
+
# every one of these for a peer that failed configured proxy trust.
|
|
27
|
+
FORWARDED_AUTHORITY_HEADERS = %w[
|
|
28
|
+
HTTP_FORWARDED
|
|
29
|
+
HTTP_X_FORWARDED_HOST
|
|
30
|
+
HTTP_X_FORWARDED_PROTO
|
|
31
|
+
HTTP_X_FORWARDED_SCHEME
|
|
32
|
+
HTTP_X_FORWARDED_SSL
|
|
33
|
+
HTTP_X_FORWARDED_PORT
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Headers whose presence means the request was RELAYED by a proxy rather
|
|
37
|
+
# than issued directly by the peer: every forwarding carrier Otto knows —
|
|
38
|
+
# the forwarded-for family, RFC 7239 Forwarded, and the authority (host /
|
|
39
|
+
# scheme / port) carriers. The set must cover everything IPPrivacyMiddleware
|
|
40
|
+
# may DELETE on the untrusted-peer path: a carrier that is scrubbed but not
|
|
41
|
+
# counted here would let a relayed request look direct afterwards. Shared
|
|
42
|
+
# by IPPrivacyMiddleware (which records the verdict as
|
|
43
|
+
# env['otto.peer_relayed'] BEFORE the scrub) and
|
|
44
|
+
# Otto::CaddyTLS::LocalhostGuard, so the record and the guard's own
|
|
45
|
+
# fallback scan cannot drift.
|
|
46
|
+
RELAY_MARKER_HEADERS = (FORWARDED_FOR_HEADERS + FORWARDED_AUTHORITY_HEADERS).uniq.freeze
|
|
47
|
+
|
|
22
48
|
# Special-use IPv4/IPv6 ranges that IPAddr's #private?/#loopback?/#link_local?
|
|
23
49
|
# predicates do not cover but that should still be treated as non-public
|
|
24
50
|
# (e.g. when picking the real client out of a forwarded chain).
|
|
51
|
+
#
|
|
52
|
+
# The documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24,
|
|
53
|
+
# 2001:db8::/32 and the newer 3fff::/20) are deliberately NOT listed here.
|
|
54
|
+
# The predicate answers "could this be the real client", and non-public
|
|
55
|
+
# entries are skipped as proxy hops. Those ranges are not globally routable,
|
|
56
|
+
# but they are the universal convention for an example public client in tests
|
|
57
|
+
# and docs, including this repo's own specs and guides, so treating them as
|
|
58
|
+
# non-public would make the resolver skip the example client and silently
|
|
59
|
+
# invalidate those examples. There is no security gain either: an attacker who
|
|
60
|
+
# can inject into a chain injects a routable address, and a documentation
|
|
61
|
+
# address in a real chain means a misconfigured hop, which is better surfaced
|
|
62
|
+
# than skipped.
|
|
25
63
|
SPECIAL_USE_RANGES = [
|
|
26
64
|
IPAddr.new('0.0.0.0/8'), # "this" network / unspecified (IPv4)
|
|
27
65
|
IPAddr.new('224.0.0.0/4'), # IPv4 multicast
|
|
@@ -389,6 +427,18 @@ class Otto
|
|
|
389
427
|
end
|
|
390
428
|
end
|
|
391
429
|
|
|
430
|
+
# Whether any relay marker header is present on the request.
|
|
431
|
+
#
|
|
432
|
+
# Call this only from code that runs BEFORE forwarding carriers may be
|
|
433
|
+
# deleted (IPPrivacyMiddleware's untrusted-peer scrub); downstream code
|
|
434
|
+
# should prefer the recorded env['otto.peer_relayed'] boolean.
|
|
435
|
+
#
|
|
436
|
+
# @param env [Hash] Rack environment
|
|
437
|
+
# @return [Boolean]
|
|
438
|
+
def relayed_request?(env)
|
|
439
|
+
RELAY_MARKER_HEADERS.any? { |header| !env[header].to_s.strip.empty? }
|
|
440
|
+
end
|
|
441
|
+
|
|
392
442
|
# Whether an address is on the loopback interface.
|
|
393
443
|
#
|
|
394
444
|
# This is the RAW SOCKET PEER test used to authenticate a direct local call
|
data/lib/otto/version.rb
CHANGED