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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/CHANGELOG.rst +135 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/README.md +54 -0
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +1 -0
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +37 -1
- data/lib/otto/privacy/core.rb +18 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +4 -20
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +1 -0
- data/lib/otto/request.rb +41 -0
- data/lib/otto/response.rb +80 -43
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/config.rb +137 -84
- data/lib/otto/security/configurator.rb +16 -0
- data/lib/otto/security/core.rb +32 -0
- data/lib/otto/security/csp/emit_middleware.rb +91 -0
- data/lib/otto/security/csp/nonce.rb +78 -0
- data/lib/otto/security/csp/policy.rb +273 -0
- data/lib/otto/security/csp/writer.rb +197 -0
- data/lib/otto/security/csp.rb +20 -8
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -6
- data/lib/otto/security.rb +1 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -2
- metadata +13 -2
data/lib/otto/security/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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.
|
|
924
|
-
#
|
|
925
|
-
#
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
data/lib/otto/security/core.rb
CHANGED
|
@@ -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
|