otto 2.4.0 → 2.6.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby-lint.yml +1 -1
  3. data/.github/workflows/yardoc.yml +1 -1
  4. data/CHANGELOG.rst +135 -0
  5. data/Gemfile +1 -1
  6. data/Gemfile.lock +5 -5
  7. data/README.md +54 -0
  8. data/examples/advanced_routes/README.md +49 -0
  9. data/examples/advanced_routes/config.rb +15 -2
  10. data/examples/advanced_routes/routes +12 -0
  11. data/examples/lambda_handlers/README.md +128 -0
  12. data/examples/lambda_handlers/config.ru +26 -0
  13. data/examples/lambda_handlers/handlers.rb +75 -0
  14. data/examples/lambda_handlers/routes +28 -0
  15. data/lib/otto/core/configuration.rb +103 -1
  16. data/lib/otto/core/middleware_stack.rb +1 -0
  17. data/lib/otto/core/router.rb +67 -10
  18. data/lib/otto/core/uri_generator.rb +36 -2
  19. data/lib/otto/env_keys.rb +21 -0
  20. data/lib/otto/errors.rb +7 -0
  21. data/lib/otto/mcp/route_parser.rb +15 -4
  22. data/lib/otto/privacy/config.rb +37 -1
  23. data/lib/otto/privacy/core.rb +18 -1
  24. data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
  25. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  26. data/lib/otto/privacy.rb +1 -0
  27. data/lib/otto/request.rb +41 -0
  28. data/lib/otto/response.rb +80 -43
  29. data/lib/otto/route.rb +103 -41
  30. data/lib/otto/route_definition.rb +56 -6
  31. data/lib/otto/route_handlers/base.rb +4 -0
  32. data/lib/otto/route_handlers/factory.rb +15 -0
  33. data/lib/otto/route_handlers/lambda.rb +47 -32
  34. data/lib/otto/security/config.rb +137 -84
  35. data/lib/otto/security/configurator.rb +16 -0
  36. data/lib/otto/security/core.rb +32 -0
  37. data/lib/otto/security/csp/emit_middleware.rb +91 -0
  38. data/lib/otto/security/csp/nonce.rb +78 -0
  39. data/lib/otto/security/csp/policy.rb +273 -0
  40. data/lib/otto/security/csp/writer.rb +197 -0
  41. data/lib/otto/security/csp.rb +20 -8
  42. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  43. data/lib/otto/security/csrf_validation.rb +75 -0
  44. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  45. data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
  46. data/lib/otto/security.rb +1 -0
  47. data/lib/otto/version.rb +1 -1
  48. data/lib/otto.rb +26 -2
  49. metadata +13 -2
@@ -0,0 +1,273 @@
1
+ # lib/otto/security/csp/policy.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Security
7
+ module CSP
8
+ # Assembles Content-Security-Policy strings from Otto's directive sets and
9
+ # the optional reporting directives.
10
+ #
11
+ # This is the policy-BUILDING half of Otto's CSP support, extracted from
12
+ # {Otto::Security::Config} so the domain (directive sets, report-uri /
13
+ # report-to assembly) lives beside the parser and the middlewares under
14
+ # {Otto::Security::CSP}. {Otto::Security::Config} keeps thin delegating
15
+ # facades ({Otto::Security::Config#generate_nonce_csp} and its static
16
+ # counterpart), so callers and output are unchanged — the assembly logic
17
+ # simply has a home of its own now.
18
+ #
19
+ # All methods are pure functions of their arguments (the report URI/URL are
20
+ # passed in, not read from global state), so the same policy string can be
21
+ # produced from any surface without a Config in hand.
22
+ module Policy
23
+ module_function
24
+
25
+ # Endpoint group name shared by the CSP `report-to` directive and the
26
+ # `Reporting-Endpoints` response header (modern Reporting API). Browsers
27
+ # match the directive's group to the header's key, so both must agree.
28
+ # {Otto::Security::Config::CSP_REPORTING_GROUP} aliases this so the two
29
+ # can never drift.
30
+ REPORTING_GROUP = 'otto-csp'
31
+
32
+ # Build the per-request nonce CSP policy string.
33
+ #
34
+ # Byte-identical to Otto's historical {Otto::Security::Config#generate_nonce_csp}
35
+ # output: the base directive set (development or production) followed by
36
+ # the optional `report-uri` and `report-to` directives, each terminated
37
+ # with `;` and joined by a single space.
38
+ #
39
+ # @param nonce [String] nonce value injected into `script-src`
40
+ # @param development_mode [Boolean] use the development directive set
41
+ # @param report_uri [String, nil] path for the `report-uri` directive
42
+ # (omitted when nil/empty)
43
+ # @param report_to_url [String, nil] absolute URL configured for the
44
+ # modern Reporting API; its presence (not its value) toggles the
45
+ # `report-to <group>` directive (omitted when nil/empty)
46
+ # @param directive_overrides [Hash, nil] per-directive overrides merged
47
+ # into the base set before reporting directives are appended. See
48
+ # {.merge_directives} for the accepted shape (replace a directive's
49
+ # sources, add a new directive, or remove one with a nil/false value).
50
+ # @return [String] complete CSP policy string
51
+ def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil)
52
+ directives = development_mode ? development_directives(nonce) : production_directives(nonce)
53
+ directives = merge_directives(directives, directive_overrides)
54
+ uri_directive = report_uri_directive(report_uri)
55
+ to_directive = report_to_directive(report_to_url)
56
+ directives += ["#{uri_directive};"] if uri_directive
57
+ directives += ["#{to_directive};"] if to_directive
58
+ directives.join(' ')
59
+ end
60
+
61
+ # Build a static CSP header value: a base policy plus the optional
62
+ # reporting directives, joined `'; '`. Byte-identical to the bare policy
63
+ # when no reporting is configured.
64
+ #
65
+ # @param base [String] the base policy (e.g. from {Otto::Security::Config#enable_csp!})
66
+ # @param report_uri [String, nil] path for the `report-uri` directive
67
+ # @param report_to_url [String, nil] absolute URL toggling `report-to`
68
+ # @return [String]
69
+ def static_policy(base, report_uri: nil, report_to_url: nil)
70
+ [base, report_uri_directive(report_uri), report_to_directive(report_to_url)].compact.join('; ')
71
+ end
72
+
73
+ # The `report-uri` directive, or nil when no report URI is configured.
74
+ # No trailing semicolon (callers add their own separator).
75
+ #
76
+ # @param uri [String, nil]
77
+ # @return [String, nil]
78
+ def report_uri_directive(uri)
79
+ return nil if uri.nil? || uri.empty?
80
+
81
+ "report-uri #{uri}"
82
+ end
83
+
84
+ # The `report-to` directive (modern Reporting API), or nil when no
85
+ # reporting endpoint URL is configured. Its group name matches the
86
+ # Reporting-Endpoints header. No trailing semicolon.
87
+ #
88
+ # @param url [String, nil]
89
+ # @return [String, nil]
90
+ def report_to_directive(url)
91
+ return nil if url.nil? || url.empty?
92
+
93
+ "report-to #{REPORTING_GROUP}"
94
+ end
95
+
96
+ # Merge per-directive overrides into a base directive set.
97
+ #
98
+ # This is the customization seam the hardcoded directive sets previously
99
+ # lacked: a consuming app can adjust ANY directive (e.g. re-allow
100
+ # `data:` workers via `worker-src 'self' data: blob:`) without
101
+ # vendoring the gem. Order is
102
+ # preserved — an override that matches an existing directive replaces it
103
+ # in place; an override for a directive not in the base set is appended
104
+ # after the base directives (before any reporting directives).
105
+ #
106
+ # Override values:
107
+ # - a String → the directive's source list verbatim, e.g.
108
+ # `'worker-src' => "'self' blob:"` yields `worker-src 'self' blob:;`
109
+ # - an Array → sources joined with a single space, e.g.
110
+ # `%w['self' blob:]`
111
+ # - `nil`/`false` → REMOVE the directive from the emitted policy
112
+ #
113
+ # Directive names are matched case-insensitively (CSP directive names are
114
+ # case-insensitive) and may be given as Strings or Symbols.
115
+ #
116
+ # @note The per-request nonce is embedded in `script-src` (production)
117
+ # and cannot be reproduced in a static override, so replacing (or
118
+ # removing) `script-src` strips the nonce from the emitted header and
119
+ # DEFEATS nonce protection: the browser then accepts any inline script
120
+ # the page carries the nonce attribute on. Overriding `script-src`
121
+ # while nonce mode is enabled therefore disables nonce enforcement;
122
+ # {Otto::Security::Config} logs a warning when such an override is
123
+ # configured. Override other directives freely.
124
+ #
125
+ # @param directives [Array<String>] base directive strings, each `;`-terminated
126
+ # @param overrides [Hash, nil] directive name => source list / nil
127
+ # @return [Array<String>] merged directive strings, each `;`-terminated
128
+ # @raise [ArgumentError] if an override name or source token contains a
129
+ # `;`, newline, or carriage return (see {.build_directive})
130
+ def merge_directives(directives, overrides)
131
+ return directives if overrides.nil? || overrides.empty?
132
+
133
+ normalized = normalize_overrides(overrides)
134
+ consumed = {}
135
+
136
+ merged = directives.filter_map do |directive|
137
+ name = directive_name(directive)
138
+ next directive unless normalized.key?(name)
139
+
140
+ consumed[name] = true
141
+ build_directive(name, normalized[name])
142
+ end
143
+
144
+ normalized.each do |name, value|
145
+ next if consumed[name]
146
+
147
+ appended = build_directive(name, value)
148
+ merged << appended if appended
149
+ end
150
+
151
+ merged
152
+ end
153
+
154
+ # Normalize an overrides hash to lowercased, hyphenated String keys so
155
+ # lookups are case-insensitive and Symbol/String keys are
156
+ # interchangeable. Underscores map to hyphens (no CSP directive contains
157
+ # an underscore) so a Symbol key like `:worker_src` addresses the
158
+ # `worker-src` directive. Blank keys are dropped.
159
+ #
160
+ # @param overrides [Hash]
161
+ # @return [Hash{String=>Object}]
162
+ def normalize_overrides(overrides)
163
+ overrides.each_with_object({}) do |(key, value), acc|
164
+ name = key.to_s.strip.downcase.tr('_', '-')
165
+ acc[name] = value unless name.empty?
166
+ end
167
+ end
168
+
169
+ # The directive name (first token) of a `;`-terminated directive string,
170
+ # lowercased for case-insensitive matching.
171
+ #
172
+ # @param directive [String]
173
+ # @return [String]
174
+ def directive_name(directive)
175
+ directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase
176
+ end
177
+
178
+ # Build a single `;`-terminated directive string from a name and an
179
+ # override value, or nil when the value signals removal (nil/false).
180
+ #
181
+ # The directive name and each source token are validated against CSP's
182
+ # separator characters: a name or token containing `;` (which separates
183
+ # directives), a newline, or a carriage return raises {ArgumentError}
184
+ # rather than silently injecting extra directives — a real footgun when
185
+ # overrides come from env/config files. (The `false` removal sentinel is
186
+ # checked before {Array} so a bare `false` never becomes a `[false]`
187
+ # source list.)
188
+ #
189
+ # @param name [String] directive name
190
+ # @param value [String, Array, nil, false] source list, or nil/false to remove
191
+ # @return [String, nil]
192
+ # @raise [ArgumentError] if the name or a source token contains a `;`,
193
+ # newline, or carriage return
194
+ def build_directive(name, value)
195
+ return nil if value.nil? || value == false
196
+
197
+ reject_injection!('directive name', name)
198
+ sources = Array(value).filter_map do |token|
199
+ str = token.to_s.strip
200
+ next if str.empty?
201
+
202
+ reject_injection!("source for #{name}", str)
203
+ str
204
+ end.join(' ')
205
+ sources.empty? ? "#{name};" : "#{name} #{sources};"
206
+ end
207
+
208
+ # Raise {ArgumentError} when +text+ carries a CSP directive/token
209
+ # separator (`;`, newline, or carriage return) that would let an
210
+ # override break out of its directive and inject another.
211
+ #
212
+ # @param label [String] what is being validated (for the error message)
213
+ # @param text [String]
214
+ # @return [void]
215
+ # @raise [ArgumentError] if +text+ contains `;`, `\n`, or `\r`
216
+ def reject_injection!(label, text)
217
+ return unless text.match?(/[;\r\n]/)
218
+
219
+ raise ArgumentError,
220
+ "invalid CSP #{label}: #{text.inspect} contains a ';', newline, or carriage return"
221
+ end
222
+
223
+ # CSP directives for the development environment.
224
+ #
225
+ # Development mode allows inline scripts/styles and hot reloading
226
+ # connections for better developer experience with build tools like Vite.
227
+ #
228
+ # @param nonce [String] nonce value injected into `script-src`
229
+ # @return [Array<String>] directive strings, each terminated with `;`
230
+ def development_directives(nonce)
231
+ [
232
+ "default-src 'none';",
233
+ "script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
234
+ "style-src 'self' 'unsafe-inline';",
235
+ "connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
236
+ "img-src 'self' data:;",
237
+ "font-src 'self';",
238
+ "object-src 'none';",
239
+ "base-uri 'self';",
240
+ "form-action 'self';",
241
+ "frame-ancestors 'none';",
242
+ "manifest-src 'self';",
243
+ "worker-src 'self' blob:;",
244
+ ]
245
+ end
246
+
247
+ # CSP directives for the production environment.
248
+ #
249
+ # Production mode is more restrictive, only allowing HTTPS connections
250
+ # and nonce-only scripts for enhanced XSS protection.
251
+ #
252
+ # @param nonce [String] nonce value injected into `script-src`
253
+ # @return [Array<String>] directive strings, each terminated with `;`
254
+ def production_directives(nonce)
255
+ [
256
+ "default-src 'none';", # Restrict to same origin by default
257
+ "script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
258
+ "style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
259
+ "connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
260
+ "img-src 'self' data:;", # Allow images from same origin and data URIs
261
+ "font-src 'self';", # Allow fonts from same origin only
262
+ "object-src 'none';", # Block <object>, <embed>, and <applet> elements
263
+ "base-uri 'self';", # Restrict <base> tag targets to same origin
264
+ "form-action 'self';", # Restrict form submissions to same origin
265
+ "frame-ancestors 'none';", # Prevent site from being embedded in frames
266
+ "manifest-src 'self';", # Allow web app manifests from same origin
267
+ "worker-src 'self' blob:;", # Allow Workers from same origin and blob: URLs
268
+ ]
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,197 @@
1
+ # lib/otto/security/csp/writer.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Security
7
+ module CSP
8
+ # The single structural apply core for nonce-based Content-Security-Policy
9
+ # emission. Every in-framework surface that writes a nonce CSP onto a
10
+ # response — {Otto::Response#apply_csp}, {Otto::Security::CSP::EmitMiddleware},
11
+ # and the deprecated {Otto::Response#send_csp_headers} shim — routes through
12
+ # {.apply}, so the emission invariants are properties of ONE method rather
13
+ # than guard logic re-implemented (and re-reviewed) at each surface:
14
+ #
15
+ # - **Enabled only.** No header unless the security config has nonce-CSP on.
16
+ # - **Nonce present.** A nil/empty nonce never produces a broken
17
+ # `script-src 'nonce-'` policy; it skips.
18
+ # - **HTML only.** Non-HTML responses (JSON, redirects, static assets) are
19
+ # left untouched.
20
+ # - **Passive layers never clobber.** In `:backstop` mode an existing CSP is
21
+ # deferred to; only an explicit `:override` replaces one.
22
+ #
23
+ # Writes are **in-place and key-scoped**: {.apply} finds any case-variant of
24
+ # the CSP key (Rack 3 mandates lowercase response-header keys, but a
25
+ # canonical-/mixed-cased key from a downstream layer is a spec violation this
26
+ # corrects in place), deletes it, and writes the lowercase key into the
27
+ # CALLER'S headers hash. There is no wrapping, no copy, and no
28
+ # "callers-must-use-the-return-value" contract — the `[status, headers,
29
+ # body]` tuple never needs reassignment. A frozen headers hash therefore
30
+ # fails loud (FrozenError) on write, surfacing the downstream SPEC violation
31
+ # rather than silently dropping the policy.
32
+ #
33
+ # The return is a {Result}, not the headers: `result.applied?`,
34
+ # `result.policy`, `result.skip_reason` give uniform observability across
35
+ # every surface (and drive the optional debug log) without any cleverness to
36
+ # detect "did anything happen".
37
+ class Writer
38
+ # Canonical (lowercase, per Rack 3 SPEC) response-header keys.
39
+ CSP_HEADER = 'content-security-policy'
40
+ CONTENT_TYPE_HEADER = 'content-type'
41
+
42
+ # Emission modes. `:override` is a deliberate per-request call that
43
+ # REPLACES any existing CSP (the caller owns this response's policy).
44
+ # `:backstop` is a passive layer that DEFERS to an existing CSP (it only
45
+ # fills the gap, never clobbers).
46
+ MODES = %i[override backstop].freeze
47
+
48
+ # Outcome of an {Writer.apply} call.
49
+ #
50
+ # `applied?` is the single source of truth for "did a header get
51
+ # written". `policy` is the emitted policy on success, or the pre-existing
52
+ # policy when a `:backstop` deferred to one. `skip_reason` is one of
53
+ # `:disabled`, `:blank_nonce`, `:non_html`, `:existing_csp` when skipped,
54
+ # else nil.
55
+ class Result
56
+ # Recognized skip reasons, in the order {Writer.apply} evaluates them.
57
+ SKIP_REASONS = %i[disabled blank_nonce non_html existing_csp].freeze
58
+
59
+ attr_reader :policy, :skip_reason, :mode
60
+
61
+ def initialize(applied:, mode:, policy: nil, skip_reason: nil)
62
+ @applied = applied
63
+ @mode = mode
64
+ @policy = policy
65
+ @skip_reason = skip_reason
66
+ end
67
+
68
+ # Build an "applied" result for a written policy.
69
+ def self.applied(policy, mode:)
70
+ new(applied: true, mode: mode, policy: policy)
71
+ end
72
+
73
+ # Build a "skipped" result. `policy` carries the pre-existing policy for
74
+ # the `:existing_csp` case (observability), nil otherwise.
75
+ def self.skipped(reason, mode:, policy: nil)
76
+ new(applied: false, mode: mode, skip_reason: reason, policy: policy)
77
+ end
78
+
79
+ # @return [Boolean] true when a CSP header was written
80
+ def applied?
81
+ @applied
82
+ end
83
+
84
+ # @return [Boolean] true when no header was written
85
+ def skipped?
86
+ !@applied
87
+ end
88
+ end
89
+
90
+ # Apply a nonce-based CSP to the caller's response headers, in place.
91
+ #
92
+ # @param headers [Hash] the Rack response headers hash, mutated in place.
93
+ # MUST be mutable (Rack 3 SPEC); a frozen hash raises FrozenError.
94
+ # @param nonce [String, nil] the per-request nonce.
95
+ # @param config [Otto::Security::Config, nil] source of the enabled gate
96
+ # and the policy string ({Otto::Security::Config#generate_nonce_csp}).
97
+ # @param mode [Symbol] one of {MODES}.
98
+ # @param development_mode [Boolean] use the development directive set.
99
+ # @return [Result]
100
+ # @raise [ArgumentError] if mode is not one of {MODES}
101
+ # @raise [FrozenError] if a write is attempted against a frozen headers hash
102
+ def self.apply(headers, nonce, config:, mode: :override, development_mode: false)
103
+ unless MODES.include?(mode)
104
+ raise ArgumentError, "mode must be one of #{MODES.join(', ')}, got #{mode.inspect}"
105
+ end
106
+
107
+ result = evaluate(headers, nonce, config, mode, development_mode)
108
+ log_debug(config, result)
109
+ result
110
+ end
111
+
112
+ # Guarded core: returns a Result and performs the in-place write when it
113
+ # 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)
116
+ return Result.skipped(:disabled, mode: mode) unless enabled?(config)
117
+ return Result.skipped(:blank_nonce, mode: mode) if blank?(nonce)
118
+ return Result.skipped(:non_html, mode: mode) unless html_response?(headers)
119
+
120
+ existing = existing_csp(headers)
121
+ return Result.skipped(:existing_csp, mode: mode, policy: existing) if existing && mode == :backstop
122
+
123
+ policy = config.generate_nonce_csp(nonce, development_mode: development_mode)
124
+ write_csp(headers, policy)
125
+ Result.applied(policy, mode: mode)
126
+ end
127
+ private_class_method :evaluate
128
+
129
+ # In-place, key-scoped write. Delete any case-variant of the CSP key
130
+ # (correcting a downstream SPEC violation), then write the canonical
131
+ # lowercase key into the caller's hash. Variant keys are collected before
132
+ # deleting so we never mutate the hash while iterating it.
133
+ def self.write_csp(headers, policy)
134
+ variant_keys = headers.keys.select { |key| key != CSP_HEADER && key.to_s.casecmp?(CSP_HEADER) }
135
+ variant_keys.each { |key| headers.delete(key) }
136
+ headers[CSP_HEADER] = policy
137
+ end
138
+ private_class_method :write_csp
139
+
140
+ # Whether the config has nonce-CSP enabled (nil/foreign configs are "off").
141
+ def self.enabled?(config)
142
+ config.respond_to?(:csp_nonce_enabled?) && config.csp_nonce_enabled?
143
+ end
144
+ private_class_method :enabled?
145
+
146
+ # Whether the response is HTML, by the leading media type of its
147
+ # Content-Type (case-insensitive; charset and other parameters ignored).
148
+ # The media type must be exactly `text/html` — matched on the token
149
+ # before any `;`, so `text/html; charset=utf-8` is HTML but `text/html5`
150
+ # or `text/html-foo` is not. Absent Content-Type is treated as non-HTML:
151
+ # a nonce-only CSP on a response the templates never stamped would block
152
+ # every script.
153
+ def self.html_response?(headers)
154
+ content_type = lookup(headers, CONTENT_TYPE_HEADER)
155
+ return false if content_type.nil?
156
+
157
+ media_type = content_type.to_s.split(';', 2).first.to_s.strip.downcase
158
+ media_type == 'text/html'
159
+ end
160
+ private_class_method :html_response?
161
+
162
+ # The existing CSP value (any case-variant key), or nil.
163
+ def self.existing_csp(headers)
164
+ lookup(headers, CSP_HEADER)
165
+ end
166
+ private_class_method :existing_csp
167
+
168
+ # Case-insensitive header read: fast path for the canonical lowercase key,
169
+ # else a scan for a case-variant.
170
+ def self.lookup(headers, name)
171
+ return headers[name] if headers.key?(name)
172
+
173
+ headers.each { |key, value| return value if key.to_s.casecmp?(name) }
174
+ nil
175
+ end
176
+ private_class_method :lookup
177
+
178
+ def self.blank?(value)
179
+ value.nil? || value.to_s.empty?
180
+ end
181
+ private_class_method :blank?
182
+
183
+ # Uniform debug observability: when the config opts into CSP debugging,
184
+ # log the outcome — applied policy OR skip reason — so "why didn't my page
185
+ # get a CSP?" no longer needs a debugger.
186
+ def self.log_debug(config, result)
187
+ return unless config.respond_to?(:debug_csp?) && config.debug_csp?
188
+ return unless defined?(Otto.logger) && Otto.logger
189
+
190
+ detail = result.applied? ? "applied (#{result.mode}) #{result.policy}" : "skipped (#{result.skip_reason})"
191
+ Otto.logger.debug("[CSP] #{detail}")
192
+ end
193
+ private_class_method :log_debug
194
+ end
195
+ end
196
+ end
197
+ end
@@ -3,17 +3,29 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  #
6
- # Index file for Content-Security-Policy violation reporting components.
6
+ # Index file for Content-Security-Policy components both halves of Otto's CSP
7
+ # support live under Otto::Security::CSP.
7
8
  #
8
- # Otto emits CSP headers via Otto::Security::Config (static #enable_csp! and
9
- # nonce-based #generate_nonce_csp / Otto::Response#send_csp_headers). This
10
- # module adds the receiving half: a turnkey violation-report endpoint plus a
11
- # callback API, so an application can collect CSP reports with a few lines of
12
- # config instead of hand-rolling a parser, size cap, and CSRF exemption.
9
+ # EMISSION (delano/otto#180):
10
+ # - Policy — builds the policy string (directive sets, report-uri/report-to).
11
+ # - Nonce — the framework-owned lazy nonce (Otto::Security::CSP.nonce /
12
+ # Otto::Request#csp_nonce), memoized in env['otto.nonce'].
13
+ # - Writer — the single structural apply core (in-place, key-scoped writes,
14
+ # Result object, :override / :backstop modes) that every surface
15
+ # routes through: Otto::Response#apply_csp, the EmitMiddleware,
16
+ # and the deprecated Otto::Response#send_csp_headers shim.
17
+ # - EmitMiddleware — passive backstop that emits a nonce CSP for responses whose
18
+ # request consumed a nonce (emit-if-consumed). See
19
+ # Otto::Security::Core#enable_csp_emission!.
13
20
  #
14
- # See Otto::Security::Core#enable_csp_reporting! for the primary entry point and
15
- # delano/otto#174 for the design.
21
+ # RECEPTION (delano/otto#174):
22
+ # - Report, Parser, ReportMiddleware — a turnkey violation-report endpoint plus a
23
+ # callback API. See Otto::Security::Core#enable_csp_reporting!.
16
24
 
25
+ require_relative 'csp/policy'
26
+ require_relative 'csp/nonce'
27
+ require_relative 'csp/writer'
17
28
  require_relative 'csp/report'
18
29
  require_relative 'csp/parser'
19
30
  require_relative 'csp/report_middleware'
31
+ require_relative 'csp/emit_middleware'
@@ -0,0 +1,68 @@
1
+ # lib/otto/security/csrf_enforcement_wrapper.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'csrf_validation'
6
+
7
+ class Otto
8
+ module Security
9
+ # Per-route CSRF enforcement, applied at the handler layer.
10
+ #
11
+ # CSRF enforcement lives here rather than in the global +CSRFMiddleware+
12
+ # because +csrf=exempt+ is a per-route option: it is only known once a
13
+ # route has been matched (the middleware runs *ahead* of route matching and
14
+ # never sees route options, so a global block could not honor exemption —
15
+ # issue #186). This wrapper runs after matching, alongside +RouteAuthWrapper+,
16
+ # where +route_definition.csrf_exempt?+ is directly available. It is composed
17
+ # by +HandlerFactory+ only when CSRF protection is enabled, and wraps outside
18
+ # +RouteAuthWrapper+ so a forged unsafe request is rejected before any
19
+ # authentication work runs.
20
+ #
21
+ # The global +CSRFMiddleware+ retains only token *injection* into HTML
22
+ # responses (a response-shaping concern that is method/content-type based,
23
+ # not route based, so it stays global).
24
+ class CSRFEnforcementWrapper
25
+ include CSRFValidation
26
+
27
+ attr_reader :wrapped_handler, :route_definition, :config
28
+
29
+ # @param wrapped_handler [#call] the handler to guard
30
+ # @param route_definition [Otto::RouteDefinition] the matched route
31
+ # @param config [Otto::Security::Config] security config exposing CSRF settings
32
+ def initialize(wrapped_handler, route_definition, config)
33
+ @wrapped_handler = wrapped_handler
34
+ @route_definition = route_definition
35
+ @config = config
36
+ end
37
+
38
+ # @param env [Hash] Rack environment
39
+ # @param extra_params [Hash] Additional parameters passed through to the handler
40
+ # @return [Array] Rack response tuple
41
+ def call(env, extra_params = {})
42
+ return wrapped_handler.call(env, extra_params) unless enforce?(env)
43
+
44
+ request = Otto::Request.new(env)
45
+ return wrapped_handler.call(env, extra_params) if valid_csrf_token?(request)
46
+
47
+ Otto.structured_log(:warn, 'CSRF validation failed',
48
+ Otto::LoggingHelpers.request_context(env).merge(
49
+ handler: route_definition.definition,
50
+ referrer: request.referrer
51
+ ))
52
+ csrf_error_response
53
+ end
54
+
55
+ private
56
+
57
+ # Whether this request must present a valid CSRF token. Only unsafe
58
+ # methods on a non-exempt route are enforced when protection is enabled.
59
+ def enforce?(env)
60
+ return false unless config&.csrf_enabled?
61
+ return false if safe_method?(env['REQUEST_METHOD'])
62
+ return false if route_definition.csrf_exempt?
63
+
64
+ true
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,75 @@
1
+ # lib/otto/security/csrf_validation.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'json'
6
+
7
+ class Otto
8
+ module Security
9
+ # Shared CSRF token mechanics.
10
+ #
11
+ # Both the global +CSRFMiddleware+ (which injects tokens into HTML
12
+ # responses) and the per-route +CSRFEnforcementWrapper+ (which enforces
13
+ # tokens on unsafe requests, honoring +csrf=exempt+) mix this in so the
14
+ # two cannot drift on what counts as a safe method, where a token may be
15
+ # carried, or how a rejection is shaped. The including object must expose a
16
+ # +@config+ (an +Otto::Security::Config+).
17
+ module CSRFValidation
18
+ # HTTP methods that never mutate state and so are exempt from token
19
+ # validation (RFC 7231 safe methods plus TRACE).
20
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
21
+
22
+ # Static 403 body. Frozen once so a rejected request does not re-serialize
23
+ # the same JSON on every call.
24
+ CSRF_ERROR_BODY = {
25
+ error: 'CSRF token validation failed',
26
+ message: 'The request could not be authenticated. Please refresh the page and try again.',
27
+ }.to_json.freeze
28
+
29
+ private
30
+
31
+ def safe_method?(method)
32
+ SAFE_METHODS.include?(method.to_s.upcase)
33
+ end
34
+
35
+ def valid_csrf_token?(request)
36
+ token = extract_csrf_token(request)
37
+ # Reject nil / blank / whitespace-only tokens up front, before creating
38
+ # a session or running HMAC verification — obviously-malformed input
39
+ # should not cause session churn (#186 review).
40
+ return false if token.nil? || token.strip.empty?
41
+
42
+ session_id = extract_session_id(request)
43
+ @config.verify_csrf_token(token, session_id)
44
+ end
45
+
46
+ def extract_csrf_token(request)
47
+ # Try form parameter first
48
+ token = request.params[@config.csrf_token_key]
49
+
50
+ # Try header if not in params
51
+ token ||= request.env[@config.csrf_header_key]
52
+
53
+ # Try alternative header format
54
+ token ||= request.env['HTTP_X_CSRF_TOKEN'] if request.env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
55
+
56
+ token
57
+ end
58
+
59
+ def extract_session_id(request)
60
+ @config.get_or_create_session_id(request)
61
+ end
62
+
63
+ def csrf_error_response
64
+ [
65
+ 403,
66
+ {
67
+ 'content-type' => 'application/json',
68
+ 'content-length' => CSRF_ERROR_BODY.bytesize.to_s,
69
+ },
70
+ [CSRF_ERROR_BODY],
71
+ ]
72
+ end
73
+ end
74
+ end
75
+ end