otto 2.3.0 → 2.4.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/dependabot.yml +1 -1
- data/.github/workflows/ci.yml +7 -1
- data/.github/workflows/claude-code-review.yml +33 -10
- data/.github/workflows/claude.yml +9 -2
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +12 -2
- data/.github/workflows/ruby-lint.yml +66 -0
- data/.github/workflows/yardoc.yml +117 -0
- data/.yardopts +15 -0
- data/CHANGELOG.rst +84 -0
- data/Gemfile +4 -1
- data/Gemfile.lock +23 -15
- data/README.md +96 -0
- data/Rakefile +21 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.3.0.md +50 -3
- data/docs/reverse-proxy-network-services.md +358 -0
- data/examples/caddy_tls_demo/README.md +100 -0
- data/examples/caddy_tls_demo/app.rb +41 -0
- data/examples/caddy_tls_demo/config.ru +31 -0
- data/examples/caddy_tls_demo/routes +9 -0
- data/examples/caddy_tls_demo/standalone.ru +38 -0
- data/lib/otto/caddy_tls/core.rb +74 -0
- data/lib/otto/caddy_tls/localhost_guard.rb +158 -0
- data/lib/otto/caddy_tls/server.rb +149 -0
- data/lib/otto/caddy_tls.rb +7 -0
- data/lib/otto/core/configuration.rb +9 -1
- data/lib/otto/core/middleware_management.rb +7 -7
- data/lib/otto/core/middleware_stack.rb +39 -5
- data/lib/otto/core/router.rb +4 -8
- data/lib/otto/security/config.rb +293 -2
- data/lib/otto/security/configurator.rb +53 -0
- data/lib/otto/security/core.rb +62 -0
- data/lib/otto/security/csp/parser.rb +120 -0
- data/lib/otto/security/csp/report.rb +147 -0
- data/lib/otto/security/csp/report_middleware.rb +120 -0
- data/lib/otto/security/csp.rb +19 -0
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +133 -18
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -3
- metadata +24 -3
data/lib/otto/core/router.rb
CHANGED
|
@@ -72,14 +72,10 @@ class Otto
|
|
|
72
72
|
path_info = '/' if path_info.to_s.empty?
|
|
73
73
|
|
|
74
74
|
begin
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
undef: :replace, # Replace characters undefined in UTF-8
|
|
80
|
-
replace: '' # Use empty string for replacement
|
|
81
|
-
)
|
|
82
|
-
.gsub(%r{/$}, '') # Remove trailing slash, if present
|
|
75
|
+
# Shared with Otto::CaddyTLS::LocalhostGuard so the guard and the
|
|
76
|
+
# router cannot normalize a path differently (which would be a guard
|
|
77
|
+
# bypass). See Otto::Utils.normalize_path.
|
|
78
|
+
path_info_clean = Otto::Utils.normalize_path(env['PATH_INFO'])
|
|
83
79
|
rescue ArgumentError => e
|
|
84
80
|
# Log the error but don't expose details
|
|
85
81
|
Otto.logger.error '[Otto.handle_request] Path encoding error'
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -37,6 +37,18 @@ class Otto
|
|
|
37
37
|
hop count, not both.
|
|
38
38
|
MSG
|
|
39
39
|
|
|
40
|
+
# Forwarded-header sources depth mode (#trusted_proxy_depth) can count
|
|
41
|
+
# hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
|
|
42
|
+
# Both (Forwarded when present, else X-Forwarded-For). Mirrors
|
|
43
|
+
# OneTimeSecret's site.network.trusted_proxy.header. Only consulted in
|
|
44
|
+
# depth mode; CIDR-walk is unaffected.
|
|
45
|
+
TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
|
|
46
|
+
|
|
47
|
+
# Endpoint group name shared by the CSP `report-to` directive and the
|
|
48
|
+
# `Reporting-Endpoints` response header (modern Reporting API). Browsers
|
|
49
|
+
# match the directive's group to the header's key, so both must agree.
|
|
50
|
+
CSP_REPORTING_GROUP = 'otto-csp'
|
|
51
|
+
|
|
40
52
|
# Error raised when CSRF protection is enabled in production without an
|
|
41
53
|
# explicitly configured secret. A randomly-generated per-process secret
|
|
42
54
|
# silently breaks token verification across workers and restarts, so we
|
|
@@ -56,7 +68,8 @@ class Otto
|
|
|
56
68
|
:trusted_proxies, :require_secure_cookies,
|
|
57
69
|
:security_headers,
|
|
58
70
|
:csp_nonce_enabled, :debug_csp, :mcp_auth,
|
|
59
|
-
:ip_privacy_config, :trusted_proxy_depth
|
|
71
|
+
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
|
|
72
|
+
:csp_report_uri, :csp_report_to_url, :csp_violation_callback
|
|
60
73
|
|
|
61
74
|
# Initialize security configuration with safe defaults
|
|
62
75
|
#
|
|
@@ -73,11 +86,16 @@ class Otto
|
|
|
73
86
|
@trusted_proxies = []
|
|
74
87
|
@trusted_proxy_matchers = []
|
|
75
88
|
@trusted_proxy_depth = nil
|
|
89
|
+
@trusted_proxy_header = 'X-Forwarded-For'
|
|
76
90
|
@require_secure_cookies = false
|
|
77
91
|
@security_headers = default_security_headers
|
|
78
92
|
@input_validation = true
|
|
79
93
|
@csp_nonce_enabled = false
|
|
80
94
|
@debug_csp = false
|
|
95
|
+
@csp_policy = nil
|
|
96
|
+
@csp_report_uri = nil
|
|
97
|
+
@csp_report_to_url = nil
|
|
98
|
+
@csp_violation_callback = nil
|
|
81
99
|
@rate_limiting_config = { custom_rules: {} }
|
|
82
100
|
@ip_privacy_config = Otto::Privacy::Config.new
|
|
83
101
|
|
|
@@ -225,6 +243,27 @@ class Otto
|
|
|
225
243
|
@trusted_proxy_depth = depth
|
|
226
244
|
end
|
|
227
245
|
|
|
246
|
+
# Select which forwarded header depth mode counts hops from:
|
|
247
|
+
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
|
|
248
|
+
# consulted when depth mode is active (#trusted_proxy_depth_mode?);
|
|
249
|
+
# CIDR-walk always uses X-Forwarded-For / X-Real-IP / X-Client-IP.
|
|
250
|
+
#
|
|
251
|
+
# The value is matched case-insensitively (surrounding whitespace ignored)
|
|
252
|
+
# and stored in its canonical spelling, so a hand-edited config can write
|
|
253
|
+
# `forwarded` or `both` without surprise. A genuinely unrecognized value
|
|
254
|
+
# fails loud at assignment (rather than silently resolving from the wrong
|
|
255
|
+
# header, the way a permissive default would), so a typo surfaces at config
|
|
256
|
+
# time instead of as subtly-wrong client IPs at request time.
|
|
257
|
+
#
|
|
258
|
+
# @param header [String] one of TRUSTED_PROXY_HEADERS (case-insensitive)
|
|
259
|
+
# @raise [FrozenError] if configuration is frozen
|
|
260
|
+
# @raise [ArgumentError] if header is not a recognized value
|
|
261
|
+
def trusted_proxy_header=(header)
|
|
262
|
+
ensure_not_frozen!
|
|
263
|
+
|
|
264
|
+
@trusted_proxy_header = canonicalize_trusted_proxy_header(header)
|
|
265
|
+
end
|
|
266
|
+
|
|
228
267
|
# Validate that a request size is within acceptable limits
|
|
229
268
|
#
|
|
230
269
|
# @param content_length [String, Integer, nil] Content-Length header value
|
|
@@ -314,7 +353,8 @@ class Otto
|
|
|
314
353
|
def enable_csp!(policy = "default-src 'self'")
|
|
315
354
|
ensure_not_frozen!
|
|
316
355
|
|
|
317
|
-
@
|
|
356
|
+
@csp_policy = policy
|
|
357
|
+
@security_headers['content-security-policy'] = build_static_csp(policy)
|
|
318
358
|
end
|
|
319
359
|
|
|
320
360
|
# Enable Content Security Policy (CSP) with nonce support
|
|
@@ -360,6 +400,110 @@ class Otto
|
|
|
360
400
|
@debug_csp
|
|
361
401
|
end
|
|
362
402
|
|
|
403
|
+
# Configure the path browsers should POST CSP violation reports to.
|
|
404
|
+
#
|
|
405
|
+
# Setting this does two things:
|
|
406
|
+
# 1. A `report-uri <path>` directive is appended to every emitted CSP
|
|
407
|
+
# policy — both the static policy from {#enable_csp!} and the per-request
|
|
408
|
+
# nonce policy from {#generate_nonce_csp} — so browsers know where to
|
|
409
|
+
# send violations.
|
|
410
|
+
# 2. {Otto::Security::CSP::ReportMiddleware} activates for that path (it is
|
|
411
|
+
# inert until a report URI is set).
|
|
412
|
+
#
|
|
413
|
+
# When nil/empty (the default), NO reporting directive is emitted and the
|
|
414
|
+
# policy output is byte-identical to Otto's historical output.
|
|
415
|
+
#
|
|
416
|
+
# For the turnkey setup that also injects the receiving middleware, prefer
|
|
417
|
+
# {Otto::Security::Core#enable_csp_reporting!} on the Otto instance.
|
|
418
|
+
#
|
|
419
|
+
# @param uri [String, nil] path browsers POST reports to (matched against
|
|
420
|
+
# `PATH_INFO`, e.g. `/_/csp-report`), or nil to disable reporting. A
|
|
421
|
+
# value without a leading slash is coerced to an absolute path so it
|
|
422
|
+
# matches the slash-prefixed `PATH_INFO` the middleware compares against.
|
|
423
|
+
# @return [void]
|
|
424
|
+
# @raise [FrozenError] if configuration is frozen
|
|
425
|
+
def csp_report_uri=(uri)
|
|
426
|
+
ensure_not_frozen!
|
|
427
|
+
|
|
428
|
+
@csp_report_uri = normalize_report_path(uri)
|
|
429
|
+
rebuild_static_csp_with_reporting!
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Configure the absolute URL browsers should POST CSP violation reports to
|
|
433
|
+
# via the modern Reporting API (Reporting-Endpoints header + `report-to`
|
|
434
|
+
# directive), complementing the legacy path-based {#csp_report_uri=}.
|
|
435
|
+
#
|
|
436
|
+
# Setting this does two things:
|
|
437
|
+
# 1. A `report-to #{CSP_REPORTING_GROUP}` directive is appended to every
|
|
438
|
+
# emitted CSP policy (static and per-request nonce alike), and a
|
|
439
|
+
# `Reporting-Endpoints` response header maps that group to this URL.
|
|
440
|
+
# 2. Modern browsers (which have deprecated `report-uri`) deliver reports
|
|
441
|
+
# as `application/reports+json` to this endpoint — already parsed by
|
|
442
|
+
# {Otto::Security::CSP::Parser}.
|
|
443
|
+
#
|
|
444
|
+
# The value MUST be an ABSOLUTE URL (Reporting-Endpoints does not accept a
|
|
445
|
+
# bare path). Point it at the same receiver as {#csp_report_uri=}: its path
|
|
446
|
+
# component should equal the report URI so {Otto::Security::CSP::ReportMiddleware}
|
|
447
|
+
# (which matches on PATH_INFO) intercepts modern reports too.
|
|
448
|
+
#
|
|
449
|
+
# When nil/empty (the default), NO `report-to` directive or
|
|
450
|
+
# `Reporting-Endpoints` header is emitted and policy output is
|
|
451
|
+
# byte-identical to Otto's historical output.
|
|
452
|
+
#
|
|
453
|
+
# @param url [String, nil] absolute URL for the Reporting API endpoint, or
|
|
454
|
+
# nil to disable modern reporting.
|
|
455
|
+
# @return [void]
|
|
456
|
+
# @raise [FrozenError] if configuration is frozen
|
|
457
|
+
def csp_report_to_url=(url)
|
|
458
|
+
ensure_not_frozen!
|
|
459
|
+
|
|
460
|
+
@csp_report_to_url = normalize_report_uri(url)
|
|
461
|
+
if @csp_report_to_url
|
|
462
|
+
@security_headers['reporting-endpoints'] = reporting_endpoints_header
|
|
463
|
+
else
|
|
464
|
+
@security_headers.delete('reporting-endpoints')
|
|
465
|
+
end
|
|
466
|
+
rebuild_static_csp_with_reporting!
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Register the callback invoked once per parsed CSP violation report.
|
|
470
|
+
#
|
|
471
|
+
# The block receives an {Otto::Security::CSP::Report}. Your application
|
|
472
|
+
# decides what to do — log, emit a metric, store, forward, or ignore. Otto
|
|
473
|
+
# adds no storage or database coupling.
|
|
474
|
+
#
|
|
475
|
+
# Registering a second callback REPLACES the first (last registration
|
|
476
|
+
# wins), matching the singular `on_csp_violation` semantics. Calling this
|
|
477
|
+
# with NO block clears (unregisters) any previously-set callback.
|
|
478
|
+
#
|
|
479
|
+
# SECURITY NOTE: report URL fields may carry sensitive path/query data in
|
|
480
|
+
# some applications. Redact them in your callback before logging if needed;
|
|
481
|
+
# Otto passes them through un-redacted (see {Otto::Security::CSP::Report}).
|
|
482
|
+
#
|
|
483
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized report
|
|
484
|
+
# @return [void]
|
|
485
|
+
# @raise [FrozenError] if configuration is frozen
|
|
486
|
+
def on_csp_violation(&block)
|
|
487
|
+
ensure_not_frozen!
|
|
488
|
+
|
|
489
|
+
@csp_violation_callback = block
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Invoke the registered violation callback for a report, isolating any
|
|
493
|
+
# error it raises. A misbehaving application callback must never break the
|
|
494
|
+
# report receiver (which always answers 204).
|
|
495
|
+
#
|
|
496
|
+
# @param report [Otto::Security::CSP::Report]
|
|
497
|
+
# @return [void]
|
|
498
|
+
def dispatch_csp_violation(report)
|
|
499
|
+
callback = @csp_violation_callback
|
|
500
|
+
return if callback.nil?
|
|
501
|
+
|
|
502
|
+
callback.call(report)
|
|
503
|
+
rescue StandardError => e
|
|
504
|
+
Otto.logger.error("[Otto::CSP] violation callback raised #{e.class}: #{e.message}")
|
|
505
|
+
end
|
|
506
|
+
|
|
363
507
|
# Generate a CSP policy string with the provided nonce
|
|
364
508
|
#
|
|
365
509
|
# @param nonce [String] The nonce value to include in the CSP
|
|
@@ -367,6 +511,10 @@ class Otto
|
|
|
367
511
|
# @return [String] Complete CSP policy string
|
|
368
512
|
def generate_nonce_csp(nonce, development_mode: false)
|
|
369
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
|
|
370
518
|
directives.join(' ')
|
|
371
519
|
end
|
|
372
520
|
|
|
@@ -453,6 +601,42 @@ class Otto
|
|
|
453
601
|
raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
|
|
454
602
|
end
|
|
455
603
|
|
|
604
|
+
# Canonicalize a candidate trusted_proxy_header value: match it
|
|
605
|
+
# case-insensitively (ignoring surrounding whitespace) against the
|
|
606
|
+
# recognized set and return the canonical spelling. Liberal in the spelling
|
|
607
|
+
# it accepts (e.g. 'forwarded' => 'Forwarded') but fail-loud on a genuinely
|
|
608
|
+
# unrecognized value, so a typo is caught at config time rather than
|
|
609
|
+
# silently resolving the client IP from the wrong header.
|
|
610
|
+
#
|
|
611
|
+
# @param header [Object] candidate value
|
|
612
|
+
# @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
|
|
613
|
+
# @return [String] the canonical header value
|
|
614
|
+
def canonicalize_trusted_proxy_header(header)
|
|
615
|
+
candidate = header.to_s.strip
|
|
616
|
+
canonical = TRUSTED_PROXY_HEADERS.find { |allowed| allowed.casecmp?(candidate) }
|
|
617
|
+
return canonical if canonical
|
|
618
|
+
|
|
619
|
+
raise ArgumentError,
|
|
620
|
+
"trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Strictly validate a stored trusted_proxy_header value against the allowed
|
|
624
|
+
# set. The eager #trusted_proxy_header= setter already canonicalizes, so by
|
|
625
|
+
# freeze time the value is canonical; this freeze-time backstop catches a
|
|
626
|
+
# value smuggled in through a direct-ivar path that bypassed the setter,
|
|
627
|
+
# failing loud rather than silently mis-resolving the client IP at request
|
|
628
|
+
# time.
|
|
629
|
+
#
|
|
630
|
+
# @param header [Object] candidate value
|
|
631
|
+
# @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
|
|
632
|
+
# @return [void]
|
|
633
|
+
def validate_trusted_proxy_header!(header)
|
|
634
|
+
return if TRUSTED_PROXY_HEADERS.include?(header)
|
|
635
|
+
|
|
636
|
+
raise ArgumentError,
|
|
637
|
+
"trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
|
|
638
|
+
end
|
|
639
|
+
|
|
456
640
|
# Validate trusted-proxy configuration coherence at freeze time.
|
|
457
641
|
#
|
|
458
642
|
# The eager setters (#trusted_proxy_depth=, #add_trusted_proxy) already
|
|
@@ -464,6 +648,7 @@ class Otto
|
|
|
464
648
|
# trusted_proxies and a depth >= 1 are configured
|
|
465
649
|
# @return [void]
|
|
466
650
|
def validate_trusted_proxy_config!
|
|
651
|
+
validate_trusted_proxy_header!(@trusted_proxy_header)
|
|
467
652
|
validate_trusted_proxy_depth!(@trusted_proxy_depth)
|
|
468
653
|
return if @trusted_proxy_depth.nil?
|
|
469
654
|
|
|
@@ -639,6 +824,112 @@ class Otto
|
|
|
639
824
|
defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
|
|
640
825
|
end
|
|
641
826
|
|
|
827
|
+
# Normalize a configured report URI: strip surrounding whitespace and
|
|
828
|
+
# treat a blank string as "not configured" (nil).
|
|
829
|
+
#
|
|
830
|
+
# @param uri [String, nil]
|
|
831
|
+
# @return [String, nil]
|
|
832
|
+
def normalize_report_uri(uri)
|
|
833
|
+
return nil if uri.nil?
|
|
834
|
+
|
|
835
|
+
stripped = uri.to_s.strip
|
|
836
|
+
stripped.empty? ? nil : stripped
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Normalize a configured report PATH: the local endpoint the receiver
|
|
840
|
+
# matches on `PATH_INFO`. Same strip/blank-to-nil handling as
|
|
841
|
+
# {#normalize_report_uri}, but a bare relative value is coerced to an
|
|
842
|
+
# absolute path — a value like `"csp-report"` would otherwise (a) never
|
|
843
|
+
# equal the slash-prefixed `PATH_INFO` the middleware compares against, and
|
|
844
|
+
# (b) be resolved by browsers relative to the document URL. An absolute URL
|
|
845
|
+
# (contains a scheme) is left untouched.
|
|
846
|
+
#
|
|
847
|
+
# @param uri [String, nil]
|
|
848
|
+
# @return [String, nil]
|
|
849
|
+
def normalize_report_path(uri)
|
|
850
|
+
normalized = normalize_report_uri(uri)
|
|
851
|
+
return nil if normalized.nil?
|
|
852
|
+
return normalized if normalized.start_with?('/') || normalized.include?('://')
|
|
853
|
+
|
|
854
|
+
"/#{normalized}"
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Recompute the stored static CSP header so the report-uri / report-to
|
|
858
|
+
# directives track the current settings, independent of the order in which
|
|
859
|
+
# {#enable_csp!} and the report setters were called.
|
|
860
|
+
#
|
|
861
|
+
# The base is normally @csp_policy (set by {#enable_csp!}). When a static
|
|
862
|
+
# CSP was instead injected directly through {#set_security_headers} (so
|
|
863
|
+
# @csp_policy is nil), adopt that header as the base the first time a report
|
|
864
|
+
# directive is configured — so reporting augments it too. Capturing it as
|
|
865
|
+
# the pristine base keeps later rebuilds idempotent. A static header set
|
|
866
|
+
# directly AFTER reporting is configured bypasses this and remains the
|
|
867
|
+
# application's to manage.
|
|
868
|
+
#
|
|
869
|
+
# @return [void]
|
|
870
|
+
def rebuild_static_csp_with_reporting!
|
|
871
|
+
@csp_policy ||= adoptable_static_csp_base
|
|
872
|
+
return if @csp_policy.nil?
|
|
873
|
+
|
|
874
|
+
@security_headers['content-security-policy'] = build_static_csp(@csp_policy)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# The current static CSP header when it can serve as a pristine base policy
|
|
878
|
+
# for reporting augmentation: present and not already carrying a report
|
|
879
|
+
# directive (adopting one that does would double-append). Otherwise nil —
|
|
880
|
+
# notably, the nonce path sets no static header, so nothing is adopted.
|
|
881
|
+
#
|
|
882
|
+
# @return [String, nil]
|
|
883
|
+
def adoptable_static_csp_base
|
|
884
|
+
existing = @security_headers['content-security-policy']
|
|
885
|
+
return nil if existing.nil? || existing.empty?
|
|
886
|
+
return nil if existing.include?('report-uri') || existing.include?('report-to')
|
|
887
|
+
|
|
888
|
+
existing
|
|
889
|
+
end
|
|
890
|
+
|
|
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
|
+
# The `Reporting-Endpoints` response header value mapping the CSP reporting
|
|
914
|
+
# group to the configured absolute endpoint URL, e.g.
|
|
915
|
+
# `otto-csp="https://example.com/_/csp-report"`.
|
|
916
|
+
#
|
|
917
|
+
# @return [String]
|
|
918
|
+
def reporting_endpoints_header
|
|
919
|
+
%(#{CSP_REPORTING_GROUP}="#{@csp_report_to_url}")
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# 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.
|
|
926
|
+
#
|
|
927
|
+
# @param policy [String] the base policy passed to {#enable_csp!}
|
|
928
|
+
# @return [String]
|
|
929
|
+
def build_static_csp(policy)
|
|
930
|
+
[policy, csp_report_directive, csp_report_to_directive].compact.join('; ')
|
|
931
|
+
end
|
|
932
|
+
|
|
642
933
|
# Generate CSP directives for development environment
|
|
643
934
|
#
|
|
644
935
|
# Development mode allows inline scripts/styles and hot reloading connections
|
|
@@ -39,6 +39,9 @@ class Otto
|
|
|
39
39
|
# @param trusted_proxy_depth [Integer, nil] Count-based proxy depth ("trust
|
|
40
40
|
# the last N hops") for non-enumerable proxy tiers; mutually exclusive
|
|
41
41
|
# with trusted_proxies (validated at configuration freeze)
|
|
42
|
+
# @param trusted_proxy_header [String, nil] Forwarded header depth mode
|
|
43
|
+
# counts hops from: 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239),
|
|
44
|
+
# or 'Both'. Only consulted in depth mode.
|
|
42
45
|
# @param security_headers [Hash] Custom security headers to merge with defaults
|
|
43
46
|
# @param hsts [Boolean] Enable HTTP Strict Transport Security
|
|
44
47
|
# @param csp [Boolean, String] Enable Content Security Policy
|
|
@@ -62,6 +65,7 @@ class Otto
|
|
|
62
65
|
rate_limiting: false,
|
|
63
66
|
trusted_proxies: [],
|
|
64
67
|
trusted_proxy_depth: nil,
|
|
68
|
+
trusted_proxy_header: nil,
|
|
65
69
|
security_headers: {},
|
|
66
70
|
hsts: false,
|
|
67
71
|
csp: false,
|
|
@@ -74,6 +78,7 @@ class Otto
|
|
|
74
78
|
|
|
75
79
|
Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
|
|
76
80
|
self.trusted_proxy_depth = trusted_proxy_depth unless trusted_proxy_depth.nil?
|
|
81
|
+
self.trusted_proxy_header = trusted_proxy_header unless trusted_proxy_header.nil?
|
|
77
82
|
self.security_headers = security_headers unless security_headers.empty?
|
|
78
83
|
|
|
79
84
|
enable_hsts! if hsts
|
|
@@ -142,6 +147,16 @@ class Otto
|
|
|
142
147
|
@security_config.trusted_proxy_depth = depth
|
|
143
148
|
end
|
|
144
149
|
|
|
150
|
+
# Select which forwarded header depth mode counts hops from:
|
|
151
|
+
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
|
|
152
|
+
# consulted when depth mode is active. Mirrors OneTimeSecret's
|
|
153
|
+
# site.network.trusted_proxy.header.
|
|
154
|
+
#
|
|
155
|
+
# @param header [String] one of Otto::Security::Config::TRUSTED_PROXY_HEADERS
|
|
156
|
+
def trusted_proxy_header=(header)
|
|
157
|
+
@security_config.trusted_proxy_header = header
|
|
158
|
+
end
|
|
159
|
+
|
|
145
160
|
# Set custom security headers that will be added to all responses.
|
|
146
161
|
# These merge with the default security headers.
|
|
147
162
|
#
|
|
@@ -183,6 +198,44 @@ class Otto
|
|
|
183
198
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
184
199
|
end
|
|
185
200
|
|
|
201
|
+
# Enable turnkey CSP violation reporting: set the report URI (appends a
|
|
202
|
+
# `report-uri` directive to emitted policies), register the callback, and
|
|
203
|
+
# inject {Otto::Security::CSP::ReportMiddleware} pinned OUTERMOST so it
|
|
204
|
+
# intercepts report POSTs ahead of CSRF regardless of enable order.
|
|
205
|
+
#
|
|
206
|
+
# @param report_uri [String] path browsers POST reports to (matched against PATH_INFO)
|
|
207
|
+
# @param endpoint_url [String, nil] absolute URL for the modern Reporting
|
|
208
|
+
# API endpoint (emits `report-to` + `Reporting-Endpoints`); nil emits
|
|
209
|
+
# only the legacy `report-uri`
|
|
210
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized violation report
|
|
211
|
+
def enable_csp_reporting!(report_uri, endpoint_url: nil, &block)
|
|
212
|
+
@security_config.csp_report_uri = report_uri
|
|
213
|
+
@security_config.csp_report_to_url = endpoint_url unless endpoint_url.nil?
|
|
214
|
+
@security_config.on_csp_violation(&block) if block
|
|
215
|
+
|
|
216
|
+
return if middleware_enabled?(Otto::Security::CSP::ReportMiddleware)
|
|
217
|
+
|
|
218
|
+
@middleware_stack.add_with_position(Otto::Security::CSP::ReportMiddleware, position: :outermost)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Configure the CSP violation report path without injecting middleware.
|
|
222
|
+
# Prefer {#enable_csp_reporting!} for the full turnkey setup.
|
|
223
|
+
#
|
|
224
|
+
# @param uri [String, nil] report path (matched against PATH_INFO), or nil to disable
|
|
225
|
+
def csp_report_uri=(uri)
|
|
226
|
+
@security_config.csp_report_uri = uri
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Configure the absolute URL for the modern Reporting API endpoint
|
|
230
|
+
# (`report-to` directive + `Reporting-Endpoints` header) without injecting
|
|
231
|
+
# middleware. Prefer {#enable_csp_reporting!} with `endpoint_url:` for the
|
|
232
|
+
# full turnkey setup.
|
|
233
|
+
#
|
|
234
|
+
# @param url [String, nil] absolute endpoint URL, or nil to disable modern reporting
|
|
235
|
+
def csp_report_to_url=(url)
|
|
236
|
+
@security_config.csp_report_to_url = url
|
|
237
|
+
end
|
|
238
|
+
|
|
186
239
|
# Add a single authentication strategy
|
|
187
240
|
#
|
|
188
241
|
# Part of the Security::Configurator facade for consolidated configuration.
|
data/lib/otto/security/core.rb
CHANGED
|
@@ -135,6 +135,68 @@ class Otto
|
|
|
135
135
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# Enable turnkey Content Security Policy violation reporting.
|
|
139
|
+
#
|
|
140
|
+
# This is the receiving half of Otto's CSP support. It:
|
|
141
|
+
# 1. Configures the report path (`config.csp_report_uri = report_uri`), so a
|
|
142
|
+
# `report-uri` directive is appended to every emitted CSP policy (static
|
|
143
|
+
# {#enable_csp!} and nonce {#enable_csp_with_nonce!} alike).
|
|
144
|
+
# 2. Registers your violation callback (if a block is given).
|
|
145
|
+
# 3. Injects {Otto::Security::CSP::ReportMiddleware} so browser POSTs to the
|
|
146
|
+
# report path are received, parsed, and dispatched to the callback —
|
|
147
|
+
# always answered with 204 and never touching your routes.
|
|
148
|
+
#
|
|
149
|
+
# The middleware is pinned to run OUTERMOST (ahead of CSRF and every other
|
|
150
|
+
# middleware), so it short-circuits report POSTs before CSRF validation —
|
|
151
|
+
# browsers can post reports without a CSRF token. This holds regardless of
|
|
152
|
+
# the order in which you enable security features.
|
|
153
|
+
#
|
|
154
|
+
# SECURITY / DoS: running outermost also means the receiver sits ahead of
|
|
155
|
+
# rate limiting (rate limiting is inner middleware). This is intentional —
|
|
156
|
+
# a public, unauthenticated report endpoint cannot depend on CSRF, session,
|
|
157
|
+
# or per-client throttling state — but it means a client can POST reports
|
|
158
|
+
# up to the 64 KiB body cap and invoke your callback on each one. Keep the
|
|
159
|
+
# callback cheap and bounded (sample or aggregate; avoid unbounded
|
|
160
|
+
# synchronous I/O), and put request-rate control for this path at the edge
|
|
161
|
+
# (reverse proxy / CDN / WAF) rather than expecting Otto to throttle it.
|
|
162
|
+
#
|
|
163
|
+
# To (re)assign the callback later without touching the wiring, use the
|
|
164
|
+
# config primitive directly: `otto.security_config.on_csp_violation { ... }`.
|
|
165
|
+
#
|
|
166
|
+
# For modern browsers (which have deprecated `report-uri`), also pass
|
|
167
|
+
# `endpoint_url:` — an ABSOLUTE URL whose path is `report_uri`. Otto then
|
|
168
|
+
# emits a `report-to` directive plus a `Reporting-Endpoints` header so those
|
|
169
|
+
# browsers deliver `application/reports+json` to the same receiver.
|
|
170
|
+
#
|
|
171
|
+
# @param report_uri [String] path browsers POST reports to (matched against
|
|
172
|
+
# `PATH_INFO`, e.g. `/_/csp-report`).
|
|
173
|
+
# @param endpoint_url [String, nil] absolute URL for the modern Reporting
|
|
174
|
+
# API endpoint (e.g. `https://example.com/_/csp-report`); nil emits only
|
|
175
|
+
# the legacy `report-uri`.
|
|
176
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized violation report
|
|
177
|
+
# @return [void]
|
|
178
|
+
# @example
|
|
179
|
+
# otto.enable_csp_with_nonce!
|
|
180
|
+
# otto.enable_csp_reporting!('/_/csp-report',
|
|
181
|
+
# endpoint_url: 'https://example.com/_/csp-report') do |report|
|
|
182
|
+
# Otto.logger.warn("CSP violation: #{report.to_h}")
|
|
183
|
+
# end
|
|
184
|
+
def enable_csp_reporting!(report_uri, endpoint_url: nil, &block)
|
|
185
|
+
ensure_not_frozen!
|
|
186
|
+
|
|
187
|
+
@security_config.csp_report_uri = report_uri
|
|
188
|
+
@security_config.csp_report_to_url = endpoint_url unless endpoint_url.nil?
|
|
189
|
+
@security_config.on_csp_violation(&block) if block
|
|
190
|
+
|
|
191
|
+
return if @middleware.includes?(Otto::Security::CSP::ReportMiddleware)
|
|
192
|
+
|
|
193
|
+
# Pin OUTERMOST so it intercepts report POSTs ahead of CSRF regardless of
|
|
194
|
+
# the order security features are enabled in. add_with_position fires the
|
|
195
|
+
# stack's on_change callback, which rebuilds @app (wired in
|
|
196
|
+
# Otto#initialize_core_state) — no explicit build_app! needed.
|
|
197
|
+
@middleware.add_with_position(Otto::Security::CSP::ReportMiddleware, position: :outermost)
|
|
198
|
+
end
|
|
199
|
+
|
|
138
200
|
# Add an authentication strategy with a registered name
|
|
139
201
|
#
|
|
140
202
|
# This is the primary public API for registering authentication strategies.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# lib/otto/security/csp/parser.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
require_relative 'report'
|
|
8
|
+
|
|
9
|
+
class Otto
|
|
10
|
+
module Security
|
|
11
|
+
module CSP
|
|
12
|
+
# Parses inbound Content-Security-Policy violation report bodies into a
|
|
13
|
+
# list of normalized {Otto::Security::CSP::Report} objects.
|
|
14
|
+
#
|
|
15
|
+
# Handles BOTH standardized wire formats:
|
|
16
|
+
#
|
|
17
|
+
# - Legacy `application/csp-report` — a single JSON object
|
|
18
|
+
# `{"csp-report": { ... }}`.
|
|
19
|
+
# - Reporting API `application/reports+json` — a JSON ARRAY of
|
|
20
|
+
# `{"type": "csp-violation", "body": { ... }}` entries (a single
|
|
21
|
+
# un-wrapped object is tolerated too).
|
|
22
|
+
#
|
|
23
|
+
# The parser keys off the JSON SHAPE rather than trusting the declared
|
|
24
|
+
# `Content-Type`, because browsers and intermediaries are inconsistent
|
|
25
|
+
# about the header. The `content_type` argument is accepted for future use
|
|
26
|
+
# and symmetry with the middleware but is not currently required to
|
|
27
|
+
# disambiguate.
|
|
28
|
+
#
|
|
29
|
+
# It is intentionally TOTAL: malformed JSON, an unexpected top-level type,
|
|
30
|
+
# or entries that are not CSP violations yield an empty array rather than
|
|
31
|
+
# raising. A violation-report receiver must never fail on hostile input.
|
|
32
|
+
module Parser
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Parse a raw report body into normalized reports.
|
|
36
|
+
#
|
|
37
|
+
# @param body [String, nil] the raw request body (JSON).
|
|
38
|
+
# @param content_type [String, nil] the request Content-Type (hint only).
|
|
39
|
+
# @return [Array<Otto::Security::CSP::Report>] zero or more normalized
|
|
40
|
+
# reports. Empty when the body is nil/blank/malformed or contains no
|
|
41
|
+
# recognizable CSP violations.
|
|
42
|
+
def parse(body, content_type = nil)
|
|
43
|
+
return [] if body.nil? || body.empty?
|
|
44
|
+
|
|
45
|
+
data = safe_json_parse(body)
|
|
46
|
+
return [] if data.nil?
|
|
47
|
+
|
|
48
|
+
extract_raw_reports(data, content_type).filter_map do |raw|
|
|
49
|
+
Report.from_raw(raw)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse JSON, swallowing the errors a hostile/garbled body can throw.
|
|
54
|
+
#
|
|
55
|
+
# @param body [String]
|
|
56
|
+
# @return [Object, nil] the parsed structure, or nil on any parse error.
|
|
57
|
+
def safe_json_parse(body)
|
|
58
|
+
JSON.parse(body)
|
|
59
|
+
rescue JSON::ParserError, EncodingError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Pull the per-violation field hashes out of either wire format.
|
|
64
|
+
#
|
|
65
|
+
# @param data [Object] the parsed JSON structure.
|
|
66
|
+
# @param _content_type [String, nil] unused (shape drives extraction).
|
|
67
|
+
# @return [Array<Hash>] raw, un-normalized per-violation field hashes.
|
|
68
|
+
def extract_raw_reports(data, _content_type = nil)
|
|
69
|
+
case data
|
|
70
|
+
when Array
|
|
71
|
+
extract_from_reporting_api(data)
|
|
72
|
+
when Hash
|
|
73
|
+
extract_from_object(data)
|
|
74
|
+
else
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reporting API batch: an array of report envelopes. Keep entries that
|
|
80
|
+
# are (or are untyped but shaped like) CSP violations and carry a body.
|
|
81
|
+
#
|
|
82
|
+
# @param entries [Array]
|
|
83
|
+
# @return [Array<Hash>]
|
|
84
|
+
def extract_from_reporting_api(entries)
|
|
85
|
+
entries.filter_map do |entry|
|
|
86
|
+
next unless entry.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
body = entry['body']
|
|
89
|
+
next unless body.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
type = entry['type']
|
|
92
|
+
# Accept entries explicitly typed csp-violation, or untyped bodies.
|
|
93
|
+
# Skip other report types (deprecation, intervention, ...).
|
|
94
|
+
next unless type.nil? || type == 'csp-violation'
|
|
95
|
+
|
|
96
|
+
body
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A single top-level object in either the legacy `{"csp-report": {...}}`
|
|
101
|
+
# envelope or a lone Reporting API `{"type":..., "body": {...}}` object.
|
|
102
|
+
#
|
|
103
|
+
# @param data [Hash]
|
|
104
|
+
# @return [Array<Hash>]
|
|
105
|
+
def extract_from_object(data)
|
|
106
|
+
if data['csp-report'].is_a?(Hash)
|
|
107
|
+
[data['csp-report']]
|
|
108
|
+
elsif data['body'].is_a?(Hash) && (data['type'].nil? || data['type'] == 'csp-violation')
|
|
109
|
+
# Mirror extract_from_reporting_api: accept a lone csp-violation (or
|
|
110
|
+
# untyped) envelope, but skip other single-object report types
|
|
111
|
+
# (deprecation, intervention, ...).
|
|
112
|
+
[data['body']]
|
|
113
|
+
else
|
|
114
|
+
[]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|