otto 2.8.0 → 2.9.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/ruby-lint.yml +9 -8
- data/.rubocop.yml +2 -1
- data/.rubocop_todo.yml +1384 -0
- data/CHANGELOG.rst +76 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +4 -4
- data/README.md +64 -1
- data/docs/.gitignore +1 -0
- data/docs/enrichment.md +128 -0
- data/docs/geo-country.md +2 -1
- data/lib/otto/core/error_handler.rb +15 -2
- data/lib/otto/env_keys.rb +46 -0
- data/lib/otto/privacy/anonymizer_resolver.rb +138 -0
- data/lib/otto/privacy/asn_resolver.rb +149 -0
- data/lib/otto/privacy/config.rb +159 -11
- data/lib/otto/privacy/core.rb +53 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +15 -2
- data/lib/otto/privacy.rb +9 -0
- data/lib/otto/request.rb +39 -0
- data/lib/otto/response.rb +7 -1
- data/lib/otto/security/config.rb +51 -6
- data/lib/otto/security/csp/emit_middleware.rb +2 -1
- data/lib/otto/security/csp/policy.rb +164 -10
- 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 +6 -1
- data/lib/otto/version.rb +1 -1
- metadata +7 -2
|
@@ -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'
|
|
@@ -220,7 +220,8 @@ class Otto
|
|
|
220
220
|
#
|
|
221
221
|
# This early return also means NONE of the privacy fingerprint
|
|
222
222
|
# values are produced for exempt IPs — no otto.privacy.fingerprint,
|
|
223
|
-
# masked_ip, hashed_ip, geo_country,
|
|
223
|
+
# masked_ip, hashed_ip, geo_country, asn, anonymizer, or
|
|
224
|
+
# correlation_hash. That is
|
|
224
225
|
# intentional and consistent: the correlation hash targets public
|
|
225
226
|
# audit-trail traffic, so req.ip_correlation_hash is nil for
|
|
226
227
|
# localhost / RFC-1918 addresses (the default dev path) even when a
|
|
@@ -251,6 +252,10 @@ class Otto
|
|
|
251
252
|
env['otto.privacy.masked_ip'] = fingerprint.masked_ip
|
|
252
253
|
env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
|
|
253
254
|
env['otto.privacy.geo_country'] = fingerprint.country
|
|
255
|
+
# nil unless the operator opted in; '**' when enabled but
|
|
256
|
+
# unresolved, so a consumer can tell "off" from "no answer".
|
|
257
|
+
env['otto.privacy.asn'] = fingerprint.asn
|
|
258
|
+
env['otto.privacy.anonymizer'] = fingerprint.anonymizer
|
|
254
259
|
|
|
255
260
|
# Fingerprint the FULL client IP here — while client_ip is still the
|
|
256
261
|
# real address, before REMOTE_ADDR is masked below — so it identifies
|
data/lib/otto/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -132,6 +132,7 @@ files:
|
|
|
132
132
|
- ".reek.yml"
|
|
133
133
|
- ".rspec"
|
|
134
134
|
- ".rubocop.yml"
|
|
135
|
+
- ".rubocop_todo.yml"
|
|
135
136
|
- ".yardopts"
|
|
136
137
|
- AGENTS.md
|
|
137
138
|
- CHANGELOG.rst
|
|
@@ -146,6 +147,7 @@ files:
|
|
|
146
147
|
- docs/.gitignore
|
|
147
148
|
- docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
|
|
148
149
|
- docs/1108-STREAMING_SUPPORT_SUMMARY.md
|
|
150
|
+
- docs/enrichment.md
|
|
149
151
|
- docs/geo-country.md
|
|
150
152
|
- docs/ipaddr-encoding-quirk.md
|
|
151
153
|
- docs/migrating/v2.0.0-pre1.md
|
|
@@ -256,6 +258,8 @@ files:
|
|
|
256
258
|
- lib/otto/mcp/schema_validation.rb
|
|
257
259
|
- lib/otto/mcp/server.rb
|
|
258
260
|
- lib/otto/privacy.rb
|
|
261
|
+
- lib/otto/privacy/anonymizer_resolver.rb
|
|
262
|
+
- lib/otto/privacy/asn_resolver.rb
|
|
259
263
|
- lib/otto/privacy/config.rb
|
|
260
264
|
- lib/otto/privacy/core.rb
|
|
261
265
|
- lib/otto/privacy/geo_resolver.rb
|
|
@@ -308,6 +312,7 @@ files:
|
|
|
308
312
|
- lib/otto/security/csp/policy.rb
|
|
309
313
|
- lib/otto/security/csp/report.rb
|
|
310
314
|
- lib/otto/security/csp/report_middleware.rb
|
|
315
|
+
- lib/otto/security/csp/request_extras.rb
|
|
311
316
|
- lib/otto/security/csp/writer.rb
|
|
312
317
|
- lib/otto/security/csrf.rb
|
|
313
318
|
- lib/otto/security/csrf_enforcement_wrapper.rb
|