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.
@@ -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
- def self.evaluate(headers, nonce, config, mode, development_mode)
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
- policy = config.generate_nonce_csp(nonce, development_mode: development_mode)
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
@@ -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, or correlation_hash. That is
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
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.8.0'
6
+ VERSION = '2.9.0'
7
7
  end
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.8.0
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-07 00:00:00.000000000 Z
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