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
@@ -7,6 +7,7 @@ require 'digest'
7
7
  require 'openssl'
8
8
  require 'ipaddr'
9
9
  require_relative '../core/freezable'
10
+ require_relative 'csp/policy'
10
11
 
11
12
  class Otto
12
13
  module Security
@@ -47,7 +48,9 @@ class Otto
47
48
  # Endpoint group name shared by the CSP `report-to` directive and the
48
49
  # `Reporting-Endpoints` response header (modern Reporting API). Browsers
49
50
  # match the directive's group to the header's key, so both must agree.
50
- CSP_REPORTING_GROUP = 'otto-csp'
51
+ # Aliases {Otto::Security::CSP::Policy::REPORTING_GROUP} — the one source
52
+ # the policy builder uses — so the header and the directive cannot drift.
53
+ CSP_REPORTING_GROUP = Otto::Security::CSP::Policy::REPORTING_GROUP
51
54
 
52
55
  # Error raised when CSRF protection is enabled in production without an
53
56
  # explicitly configured secret. A randomly-generated per-process secret
@@ -67,9 +70,10 @@ class Otto
67
70
  attr_reader :csrf_protection, :csrf_header_key,
68
71
  :trusted_proxies, :require_secure_cookies,
69
72
  :security_headers,
70
- :csp_nonce_enabled, :debug_csp, :mcp_auth,
73
+ :csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
71
74
  :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
72
- :csp_report_uri, :csp_report_to_url, :csp_violation_callback
75
+ :csp_report_uri, :csp_report_to_url, :csp_violation_callback,
76
+ :csp_directive_overrides
73
77
 
74
78
  # Initialize security configuration with safe defaults
75
79
  #
@@ -92,10 +96,13 @@ class Otto
92
96
  @input_validation = true
93
97
  @csp_nonce_enabled = false
94
98
  @debug_csp = false
99
+ @csp_nonce_key = 'otto.nonce'
95
100
  @csp_policy = nil
96
101
  @csp_report_uri = nil
97
102
  @csp_report_to_url = nil
98
103
  @csp_violation_callback = nil
104
+ @csp_directive_overrides = {}
105
+ @csp_script_src_override_warned = false
99
106
  @rate_limiting_config = { custom_rules: {} }
100
107
  @ip_privacy_config = Otto::Privacy::Config.new
101
108
 
@@ -363,19 +370,85 @@ class Otto
363
370
  # Unlike enable_csp!, this doesn't set a static policy but enables the response
364
371
  # helper to generate CSP headers with nonces on a per-request basis.
365
372
  #
373
+ # Per-directive overrides may be supplied to customize the emitted nonce
374
+ # policy without vendoring the gem. They merge into Otto's base directive
375
+ # sets ({Otto::Security::CSP::Policy.development_directives} /
376
+ # {Otto::Security::CSP::Policy.production_directives}): a matching directive
377
+ # is replaced in place, a new directive is appended, and a nil/false value
378
+ # removes a directive. See {#csp_directive_overrides=} for the accepted
379
+ # shape.
380
+ #
366
381
  # @param debug [Boolean] Enable debug logging for CSP headers (default: false)
382
+ # @param directives [Hash] per-directive overrides merged into the base set
367
383
  # @return [void]
368
384
  # @raise [FrozenError] if configuration is frozen
369
385
  #
370
386
  # @example
371
387
  # config.enable_csp_with_nonce!(debug: true)
372
- def enable_csp_with_nonce!(debug: false)
388
+ #
389
+ # @example Restore data: workers (blob: is the default worker-src token)
390
+ # config.enable_csp_with_nonce!(directives: { 'worker-src' => "'self' data: blob:" })
391
+ def enable_csp_with_nonce!(debug: false, directives: {})
373
392
  ensure_not_frozen!
374
393
 
394
+ # Apply overrides before toggling state so a bad +directives+ argument
395
+ # raises without leaving the config half-updated (nonce enabled but
396
+ # overrides not merged).
397
+ merge_csp_directives(directives) unless directives.nil? || directives.empty?
375
398
  @csp_nonce_enabled = true
376
399
  @debug_csp = debug
377
400
  end
378
401
 
402
+ # Replace the per-directive overrides applied to the nonce CSP policy.
403
+ #
404
+ # Overrides merge into Otto's base directive sets when
405
+ # {#generate_nonce_csp} builds the policy, so a consuming app can adjust
406
+ # ANY directive rather than only `report-uri`/`report-to`. Keys are
407
+ # directive names (String or Symbol, matched case-insensitively); values
408
+ # are the source list as a String (`"'self' blob:"`) or Array
409
+ # (`%w['self' blob:]`), or nil/false to REMOVE the directive.
410
+ #
411
+ # Keys are normalized on store (lowercased, hyphenated) via
412
+ # {Otto::Security::CSP::Policy.normalize_overrides}, so the stored hash
413
+ # never accumulates logically-identical entries under different key styles
414
+ # (`'WORKER-SRC'` and `:worker_src` both read back as `'worker-src'`).
415
+ #
416
+ # @note Overriding `script-src` while nonce mode is enabled disables
417
+ # nonce-based script protection: the per-request nonce cannot be
418
+ # included in a static override, so it is stripped from the emitted
419
+ # header. A warning is logged when a `script-src` override is stored.
420
+ # See {Otto::Security::CSP::Policy.merge_directives}.
421
+ #
422
+ # @param overrides [Hash] directive name => source list / nil
423
+ # @return [void]
424
+ # @raise [FrozenError] if configuration is frozen
425
+ #
426
+ # @example
427
+ # config.csp_directive_overrides = { 'worker-src' => "'self' data: blob:" }
428
+ def csp_directive_overrides=(overrides)
429
+ ensure_not_frozen!
430
+
431
+ normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
432
+ warn_if_script_src_overridden(normalized)
433
+ @csp_directive_overrides = normalized
434
+ end
435
+
436
+ # Merge additional per-directive overrides into the existing set, leaving
437
+ # untouched any directive not named in +overrides+ (last write wins for a
438
+ # repeated directive). Use this to accumulate overrides incrementally;
439
+ # use {#csp_directive_overrides=} to replace them wholesale.
440
+ #
441
+ # @param overrides [Hash] directive name => source list / nil
442
+ # @return [void]
443
+ # @raise [FrozenError] if configuration is frozen
444
+ def merge_csp_directives(overrides)
445
+ ensure_not_frozen!
446
+
447
+ normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
448
+ warn_if_script_src_overridden(normalized)
449
+ @csp_directive_overrides = @csp_directive_overrides.merge(normalized)
450
+ end
451
+
379
452
  # Disable CSP nonce support
380
453
  #
381
454
  # @return [void]
@@ -393,6 +466,22 @@ class Otto
393
466
  @csp_nonce_enabled
394
467
  end
395
468
 
469
+ # Set the Rack env key the framework-owned lazy nonce is memoized under
470
+ # ({Otto::Security::CSP.nonce} / {Otto::Request#csp_nonce}). Defaults to
471
+ # `'otto.nonce'`; override it for an app with an existing convention (e.g.
472
+ # `'onetime.nonce'`) so the accessor adopts that app's env key without a
473
+ # rename. A blank value resets to the default.
474
+ #
475
+ # @param key [String] the env key
476
+ # @return [void]
477
+ # @raise [FrozenError] if configuration is frozen
478
+ def csp_nonce_key=(key)
479
+ ensure_not_frozen!
480
+
481
+ normalized = key.to_s.strip
482
+ @csp_nonce_key = normalized.empty? ? 'otto.nonce' : normalized
483
+ end
484
+
396
485
  # Check if CSP debug logging is enabled
397
486
  #
398
487
  # @return [Boolean] true if CSP debug logging is enabled
@@ -506,16 +595,23 @@ class Otto
506
595
 
507
596
  # Generate a CSP policy string with the provided nonce
508
597
  #
598
+ # Thin facade over {Otto::Security::CSP::Policy.nonce_policy}; the directive
599
+ # sets and report-uri/report-to assembly live there now. Any configured
600
+ # {#csp_directive_overrides} are merged into the base directive set. Output
601
+ # is byte-identical to Otto's historical policy when no overrides or
602
+ # reporting are configured.
603
+ #
509
604
  # @param nonce [String] The nonce value to include in the CSP
510
605
  # @param development_mode [Boolean] Whether to use development-friendly directives
511
606
  # @return [String] Complete CSP policy string
512
607
  def generate_nonce_csp(nonce, development_mode: false)
513
- directives = development_mode ? development_csp_directives(nonce) : production_csp_directives(nonce)
514
- report_uri_directive = csp_report_directive
515
- report_to_directive = csp_report_to_directive
516
- directives += ["#{report_uri_directive};"] if report_uri_directive
517
- directives += ["#{report_to_directive};"] if report_to_directive
518
- directives.join(' ')
608
+ Otto::Security::CSP::Policy.nonce_policy(
609
+ nonce,
610
+ development_mode: development_mode,
611
+ report_uri: @csp_report_uri,
612
+ report_to_url: @csp_report_to_url,
613
+ directive_overrides: @csp_directive_overrides
614
+ )
519
615
  end
520
616
 
521
617
  # Enable X-Frame-Options header to prevent clickjacking
@@ -795,6 +891,29 @@ class Otto
795
891
  MSG
796
892
  end
797
893
 
894
+ # Warn once per config instance when a `script-src` override is stored for
895
+ # the nonce CSP policy. The per-request nonce is generated at response time
896
+ # and therefore cannot be present in a static override, so replacing (or
897
+ # removing) `script-src` necessarily strips the nonce from the emitted
898
+ # header — the browser then accepts any inline script the page carries the
899
+ # nonce attribute on, voiding nonce-based protection. Overriding any other
900
+ # directive (e.g. `worker-src`) is unaffected and stays silent.
901
+ #
902
+ # @param normalized [Hash] normalized (lowercased/hyphenated) overrides
903
+ # @return [void]
904
+ def warn_if_script_src_overridden(normalized)
905
+ return unless normalized.key?('script-src')
906
+ return if @csp_script_src_override_warned
907
+
908
+ @csp_script_src_override_warned = true
909
+ Otto.structured_log(:warn, <<~MSG.gsub(/\s+/, ' ').strip, directive: 'script-src')
910
+ [Otto::CSP] A script-src override was configured while nonce mode is in
911
+ use. The per-request nonce cannot be included in a static override, so
912
+ nonce-based script protection is disabled for this policy. Remove the
913
+ script-src override to keep nonce enforcement.
914
+ MSG
915
+ end
916
+
798
917
  # Freeze-time backstop: refuse to finalize a production configuration that
799
918
  # enables CSRF with a generated (non-configured) secret. Mirrors
800
919
  # #validate_trusted_proxy_config! so the failure surfaces at boot, before
@@ -888,28 +1007,6 @@ class Otto
888
1007
  existing
889
1008
  end
890
1009
 
891
- # The `report-uri` directive to append to emitted policies, or nil when no
892
- # report URI is configured. No trailing semicolon (callers add their own
893
- # separator to match each policy style).
894
- #
895
- # @return [String, nil]
896
- def csp_report_directive
897
- return nil if @csp_report_uri.nil? || @csp_report_uri.empty?
898
-
899
- "report-uri #{@csp_report_uri}"
900
- end
901
-
902
- # The `report-to` directive (modern Reporting API) to append to emitted
903
- # policies, or nil when no reporting endpoint URL is configured. Its group
904
- # name matches the Reporting-Endpoints header. No trailing semicolon.
905
- #
906
- # @return [String, nil]
907
- def csp_report_to_directive
908
- return nil if @csp_report_to_url.nil? || @csp_report_to_url.empty?
909
-
910
- "report-to #{CSP_REPORTING_GROUP}"
911
- end
912
-
913
1010
  # The `Reporting-Endpoints` response header value mapping the CSP reporting
914
1011
  # group to the configured absolute endpoint URL, e.g.
915
1012
  # `otto-csp="https://example.com/_/csp-report"`.
@@ -920,62 +1017,18 @@ class Otto
920
1017
  end
921
1018
 
922
1019
  # Build the stored static-CSP header value: the base policy plus the
923
- # optional report-uri and report-to directives. Byte-identical to the bare
924
- # policy when no reporting is configured, so existing static-CSP output is
925
- # unchanged.
1020
+ # optional report-uri and report-to directives. Thin facade over
1021
+ # {Otto::Security::CSP::Policy.static_policy}; byte-identical to the bare
1022
+ # policy when no reporting is configured.
926
1023
  #
927
1024
  # @param policy [String] the base policy passed to {#enable_csp!}
928
1025
  # @return [String]
929
1026
  def build_static_csp(policy)
930
- [policy, csp_report_directive, csp_report_to_directive].compact.join('; ')
931
- end
932
-
933
- # Generate CSP directives for development environment
934
- #
935
- # Development mode allows inline scripts/styles and hot reloading connections
936
- # for better developer experience with build tools like Vite.
937
- #
938
- # @param nonce [String] The nonce value to include in script-src
939
- # @return [Array<String>] Array of CSP directive strings
940
- def development_csp_directives(nonce)
941
- [
942
- "default-src 'none';",
943
- "script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
944
- "style-src 'self' 'unsafe-inline';",
945
- "connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
946
- "img-src 'self' data:;",
947
- "font-src 'self';",
948
- "object-src 'none';",
949
- "base-uri 'self';",
950
- "form-action 'self';",
951
- "frame-ancestors 'none';",
952
- "manifest-src 'self';",
953
- "worker-src 'self' data:;",
954
- ]
955
- end
956
-
957
- # Generate CSP directives for production environment
958
- #
959
- # Production mode is more restrictive, only allowing HTTPS connections
960
- # and nonce-only scripts for enhanced XSS protection.
961
- #
962
- # @param nonce [String] The nonce value to include in script-src
963
- # @return [Array<String>] Array of CSP directive strings
964
- def production_csp_directives(nonce)
965
- [
966
- "default-src 'none';", # Restrict to same origin by default
967
- "script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
968
- "style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
969
- "connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
970
- "img-src 'self' data:;", # Allow images from same origin and data URIs
971
- "font-src 'self';", # Allow fonts from same origin only
972
- "object-src 'none';", # Block <object>, <embed>, and <applet> elements
973
- "base-uri 'self';", # Restrict <base> tag targets to same origin
974
- "form-action 'self';", # Restrict form submissions to same origin
975
- "frame-ancestors 'none';", # Prevent site from being embedded in frames
976
- "manifest-src 'self';", # Allow web app manifests from same origin
977
- "worker-src 'self' data:;", # Allow Workers from same origin and data blobs
978
- ]
1027
+ Otto::Security::CSP::Policy.static_policy(
1028
+ policy,
1029
+ report_uri: @csp_report_uri,
1030
+ report_to_url: @csp_report_to_url
1031
+ )
979
1032
  end
980
1033
  end
981
1034
 
@@ -198,6 +198,22 @@ class Otto
198
198
  @security_config.enable_csp_with_nonce!(debug: debug)
199
199
  end
200
200
 
201
+ # Mount {Otto::Security::CSP::EmitMiddleware} (passive backstop that emits a
202
+ # nonce CSP for responses lacking one, never clobbering). Enable nonce-CSP
203
+ # ({#enable_csp_with_nonce!}) for it to emit anything; until then it is
204
+ # INERT (a transparent pass-through), not an error, and the two may be
205
+ # enabled in either order. Emit-if-consumed by default — see
206
+ # {Otto::Security::Core#enable_csp_emission!}.
207
+ #
208
+ # @param eager [Boolean] mint-and-emit for every eligible HTML response
209
+ # @param development_mode [Boolean, #call, nil] development-directive toggle;
210
+ # a callable is evaluated per request with the env
211
+ def enable_csp_emission!(eager: false, development_mode: nil)
212
+ return if middleware_enabled?(Otto::Security::CSP::EmitMiddleware)
213
+
214
+ @middleware_stack.add(Otto::Security::CSP::EmitMiddleware, eager: eager, development_mode: development_mode)
215
+ end
216
+
201
217
  # Enable turnkey CSP violation reporting: set the report URI (appends a
202
218
  # `report-uri` directive to emitted policies), register the callback, and
203
219
  # inject {Otto::Security::CSP::ReportMiddleware} pinned OUTERMOST so it
@@ -135,6 +135,38 @@ class Otto
135
135
  @security_config.enable_csp_with_nonce!(debug: debug)
136
136
  end
137
137
 
138
+ # Mount {Otto::Security::CSP::EmitMiddleware} so nonce-based CSP headers are
139
+ # applied to responses by the framework instead of hand-rolled in each app.
140
+ #
141
+ # It is a passive backstop: it emits a nonce CSP only for responses that
142
+ # would otherwise ship without one, and never clobbers a policy a route
143
+ # already set. Enable nonce-CSP via {#enable_csp_with_nonce!} for it to
144
+ # emit anything — until then the middleware is INERT (a transparent
145
+ # pass-through), NOT an error. The two may be enabled in either order:
146
+ # both read the same security config, so mounting the backstop first and
147
+ # enabling nonce-CSP later works. Enable-order independence is why this
148
+ # does not raise when nonce-CSP is off.
149
+ #
150
+ # By DEFAULT it is emit-if-consumed — it emits only when the request
151
+ # actually consumed a nonce (a view called {Otto::Request#csp_nonce}). This
152
+ # is the only safe blanket default: a nonce-only policy on a page that never
153
+ # stamped the nonce blocks every script.
154
+ #
155
+ # @param eager [Boolean] mint-and-emit for every eligible HTML response
156
+ # rather than only emit-if-consumed (see the middleware's caveats)
157
+ # @param development_mode [Boolean, #call, nil] whether to emit development
158
+ # directives; a callable is evaluated per request with the env
159
+ # @return [void]
160
+ # @example
161
+ # otto.enable_csp_with_nonce!
162
+ # otto.enable_csp_emission!(development_mode: -> (env) { ENV['RACK_ENV'] == 'development' })
163
+ def enable_csp_emission!(eager: false, development_mode: nil)
164
+ ensure_not_frozen!
165
+ return if @middleware.includes?(Otto::Security::CSP::EmitMiddleware)
166
+
167
+ @middleware.add(Otto::Security::CSP::EmitMiddleware, eager: eager, development_mode: development_mode)
168
+ end
169
+
138
170
  # Enable turnkey Content Security Policy violation reporting.
139
171
  #
140
172
  # This is the receiving half of Otto's CSP support. It:
@@ -0,0 +1,91 @@
1
+ # lib/otto/security/csp/emit_middleware.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'writer'
6
+ require_relative 'nonce'
7
+
8
+ class Otto
9
+ module Security
10
+ module CSP
11
+ # Rack middleware that emits a nonce-based Content-Security-Policy on the
12
+ # way out — the EMITTING sibling of {Otto::Security::CSP::ReportMiddleware}.
13
+ #
14
+ # It is a passive BACKSTOP: it runs the CSP through {Otto::Security::CSP::Writer}
15
+ # in `:backstop` mode, so it fills the gap for responses that would
16
+ # otherwise ship without a CSP but NEVER clobbers one a route or another
17
+ # layer already set. All the emission invariants (enabled / HTML-only /
18
+ # nonce-present / don't-clobber / lowercase key) are the Writer's, so this
19
+ # middleware carries none of that guard logic itself.
20
+ #
21
+ # DEFAULT: emit-if-consumed. It emits only when the request actually
22
+ # consumed a nonce (a view called {Otto::Request#csp_nonce}, memoizing it
23
+ # into the env). This is the safe default: a nonce-only `script-src` on an
24
+ # HTML page whose templates never stamped that nonce would block EVERY
25
+ # script on the page. "CSP responses whose request consumed a nonce" is
26
+ # sound; "CSP all HTML responses" is not.
27
+ #
28
+ # EAGER (opt-in): with `eager: true` it MINTS a nonce for every otherwise
29
+ # eligible response, even one that never touched it. Only safe when the app
30
+ # either uses no nonce-gated inline scripts or stamps the nonce another way;
31
+ # otherwise it reintroduces the blocked-script hazard above.
32
+ #
33
+ # INERT unless {Otto::Security::Config#csp_nonce_enabled?}. When nonce-CSP
34
+ # is off it is a transparent pass-through (and never mints a nonce).
35
+ class EmitMiddleware
36
+ # @param app [#call] the inner Rack app
37
+ # @param config [Otto::Security::Config, nil] security config (the
38
+ # middleware stack injects this); a nil config yields an inert instance
39
+ # @param eager [Boolean] mint-and-emit for every eligible response rather
40
+ # than only emit-if-consumed
41
+ # @param development_mode [Boolean, #call, nil] whether to emit the
42
+ # development directive set. A callable is invoked per request with the
43
+ # env (e.g. `->(env) { OT.conf.dig('development', 'enabled') }`); a plain
44
+ # value is used as-is; nil means production.
45
+ def initialize(app, config = nil, eager: false, development_mode: nil)
46
+ @app = app
47
+ @config = config || Otto::Security::Config.new
48
+ @eager = eager
49
+ @development_mode = development_mode
50
+ end
51
+
52
+ def call(env)
53
+ status, headers, body = @app.call(env)
54
+ apply_backstop(env, headers) if @config.csp_nonce_enabled?
55
+ [status, headers, body]
56
+ end
57
+
58
+ private
59
+
60
+ # Resolve the nonce per the eager/consumed policy and, when present, let
61
+ # the Writer apply the backstop CSP. The Writer re-checks every guard, so
62
+ # a non-HTML or already-CSP'd response is left untouched.
63
+ def apply_backstop(env, headers)
64
+ nonce = resolve_nonce(env)
65
+ return if nonce.nil? || nonce.empty?
66
+
67
+ Otto::Security::CSP::Writer.apply(
68
+ headers, nonce,
69
+ config: @config, mode: :backstop, development_mode: development_mode?(env)
70
+ )
71
+ end
72
+
73
+ # Eager mode mints a nonce for this request; the default emits only a
74
+ # nonce the request already consumed (memoized in env by a view).
75
+ def resolve_nonce(env)
76
+ return Otto::Security::CSP.nonce(env) if @eager
77
+ return Otto::Security::CSP.nonce(env) if Otto::Security::CSP.nonce?(env)
78
+
79
+ nil
80
+ end
81
+
82
+ def development_mode?(env)
83
+ mode = @development_mode
84
+ return mode.call(env) if mode.respond_to?(:call)
85
+
86
+ !!mode
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,78 @@
1
+ # lib/otto/security/csp/nonce.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'securerandom'
6
+
7
+ class Otto
8
+ module Security
9
+ # Content-Security-Policy support. The framework-owned lazy nonce accessor
10
+ # lives directly on this module ({.nonce} / {.nonce?}), beside the {Policy}
11
+ # builder, the {Writer} apply core, the {Parser}, and the report/emit
12
+ # middlewares.
13
+ module CSP
14
+ # Default Rack env key the per-request nonce is memoized under. Registered
15
+ # as documentation in {Otto::EnvKeys::NONCE}; per that module's convention
16
+ # the string literal (not the constant) is what the codebase passes around,
17
+ # so this DEFAULT_NONCE_KEY exists for the CSP code's own use and the two
18
+ # are kept identical.
19
+ DEFAULT_NONCE_KEY = 'otto.nonce'
20
+
21
+ module_function
22
+
23
+ # Framework-owned, request-scoped, LAZY CSP nonce.
24
+ #
25
+ # Generates a fresh base64 nonce on first access and memoizes it into the
26
+ # request env under the resolved key, so every later reader observes ONE
27
+ # value: the views that stamp `nonce="…"` onto `<script>`/`<link>` tags and
28
+ # the {Otto::Security::CSP::EmitMiddleware} that writes the `script-src
29
+ # 'nonce-…'` header both read it here. The header's nonce matching the
30
+ # views' nonce is therefore a STRUCTURAL property, not a convention each app
31
+ # re-implements (Rails' `request.content_security_policy_nonce` model).
32
+ #
33
+ # An untouched request never generates a nonce and pays nothing — which is
34
+ # also why the emit-if-consumed middleware is safe: it only emits a
35
+ # nonce-only policy for a request whose views actually consumed the nonce.
36
+ #
37
+ # A value already present under the key (e.g. an app that still mints its
38
+ # own under the same convention) is honored, not overwritten.
39
+ #
40
+ # @param env [Hash] the Rack request env (mutated: the nonce is memoized in)
41
+ # @param key [String, nil] override the env key; nil resolves it from the
42
+ # security config's {Otto::Security::Config#csp_nonce_key} (or the default)
43
+ # @return [String] the request's nonce
44
+ def nonce(env, key: nil)
45
+ resolved = key || nonce_key(env)
46
+ existing = env[resolved]
47
+ return existing if existing && !existing.empty?
48
+
49
+ env[resolved] = SecureRandom.base64(16)
50
+ end
51
+
52
+ # Whether a nonce was already minted for this request, WITHOUT minting one.
53
+ # This is the emit-if-consumed predicate.
54
+ #
55
+ # @param env [Hash]
56
+ # @param key [String, nil] see {.nonce}
57
+ # @return [Boolean]
58
+ def nonce?(env, key: nil)
59
+ value = env[key || nonce_key(env)]
60
+ !value.nil? && !value.empty?
61
+ end
62
+
63
+ # The env key the nonce lives under: the app's configured convention
64
+ # ({Otto::Security::Config#csp_nonce_key}) when a security config is present
65
+ # on the env, else the framework default. Lets an app with an existing
66
+ # convention (e.g. `onetime.nonce`) adopt the accessor without renaming its
67
+ # env key.
68
+ #
69
+ # @param env [Hash]
70
+ # @return [String]
71
+ def nonce_key(env)
72
+ config = env['otto.security_config']
73
+ configured = config.csp_nonce_key if config.respond_to?(:csp_nonce_key)
74
+ configured && !configured.empty? ? configured : DEFAULT_NONCE_KEY
75
+ end
76
+ end
77
+ end
78
+ end