otto 2.3.1 → 2.5.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 +32 -9
- data/.github/workflows/claude.yml +7 -5
- 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 +129 -0
- data/Gemfile +4 -2
- data/Gemfile.lock +23 -17
- data/README.md +150 -0
- data/docs/.gitignore +1 -0
- 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/middleware_management.rb +7 -7
- data/lib/otto/core/middleware_stack.rb +40 -5
- data/lib/otto/core/router.rb +4 -8
- data/lib/otto/env_keys.rb +10 -0
- data/lib/otto/request.rb +14 -0
- data/lib/otto/response.rb +80 -43
- data/lib/otto/security/config.rb +234 -51
- data/lib/otto/security/configurator.rb +54 -0
- data/lib/otto/security/core.rb +94 -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/parser.rb +120 -0
- data/lib/otto/security/csp/policy.rb +141 -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/writer.rb +197 -0
- data/lib/otto/security/csp.rb +31 -0
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +36 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -3
- metadata +27 -3
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# lib/otto/security/csp/policy.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module CSP
|
|
8
|
+
# Assembles Content-Security-Policy strings from Otto's directive sets and
|
|
9
|
+
# the optional reporting directives.
|
|
10
|
+
#
|
|
11
|
+
# This is the policy-BUILDING half of Otto's CSP support, extracted from
|
|
12
|
+
# {Otto::Security::Config} so the domain (directive sets, report-uri /
|
|
13
|
+
# report-to assembly) lives beside the parser and the middlewares under
|
|
14
|
+
# {Otto::Security::CSP}. {Otto::Security::Config} keeps thin delegating
|
|
15
|
+
# facades ({Otto::Security::Config#generate_nonce_csp} and its static
|
|
16
|
+
# counterpart), so callers and output are unchanged — the assembly logic
|
|
17
|
+
# simply has a home of its own now.
|
|
18
|
+
#
|
|
19
|
+
# All methods are pure functions of their arguments (the report URI/URL are
|
|
20
|
+
# passed in, not read from global state), so the same policy string can be
|
|
21
|
+
# produced from any surface without a Config in hand.
|
|
22
|
+
module Policy
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Endpoint group name shared by the CSP `report-to` directive and the
|
|
26
|
+
# `Reporting-Endpoints` response header (modern Reporting API). Browsers
|
|
27
|
+
# match the directive's group to the header's key, so both must agree.
|
|
28
|
+
# {Otto::Security::Config::CSP_REPORTING_GROUP} aliases this so the two
|
|
29
|
+
# can never drift.
|
|
30
|
+
REPORTING_GROUP = 'otto-csp'
|
|
31
|
+
|
|
32
|
+
# Build the per-request nonce CSP policy string.
|
|
33
|
+
#
|
|
34
|
+
# Byte-identical to Otto's historical {Otto::Security::Config#generate_nonce_csp}
|
|
35
|
+
# output: the base directive set (development or production) followed by
|
|
36
|
+
# the optional `report-uri` and `report-to` directives, each terminated
|
|
37
|
+
# with `;` and joined by a single space.
|
|
38
|
+
#
|
|
39
|
+
# @param nonce [String] nonce value injected into `script-src`
|
|
40
|
+
# @param development_mode [Boolean] use the development directive set
|
|
41
|
+
# @param report_uri [String, nil] path for the `report-uri` directive
|
|
42
|
+
# (omitted when nil/empty)
|
|
43
|
+
# @param report_to_url [String, nil] absolute URL configured for the
|
|
44
|
+
# modern Reporting API; its presence (not its value) toggles the
|
|
45
|
+
# `report-to <group>` directive (omitted when nil/empty)
|
|
46
|
+
# @return [String] complete CSP policy string
|
|
47
|
+
def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil)
|
|
48
|
+
directives = development_mode ? development_directives(nonce) : production_directives(nonce)
|
|
49
|
+
uri_directive = report_uri_directive(report_uri)
|
|
50
|
+
to_directive = report_to_directive(report_to_url)
|
|
51
|
+
directives += ["#{uri_directive};"] if uri_directive
|
|
52
|
+
directives += ["#{to_directive};"] if to_directive
|
|
53
|
+
directives.join(' ')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build a static CSP header value: a base policy plus the optional
|
|
57
|
+
# reporting directives, joined `'; '`. Byte-identical to the bare policy
|
|
58
|
+
# when no reporting is configured.
|
|
59
|
+
#
|
|
60
|
+
# @param base [String] the base policy (e.g. from {Otto::Security::Config#enable_csp!})
|
|
61
|
+
# @param report_uri [String, nil] path for the `report-uri` directive
|
|
62
|
+
# @param report_to_url [String, nil] absolute URL toggling `report-to`
|
|
63
|
+
# @return [String]
|
|
64
|
+
def static_policy(base, report_uri: nil, report_to_url: nil)
|
|
65
|
+
[base, report_uri_directive(report_uri), report_to_directive(report_to_url)].compact.join('; ')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The `report-uri` directive, or nil when no report URI is configured.
|
|
69
|
+
# No trailing semicolon (callers add their own separator).
|
|
70
|
+
#
|
|
71
|
+
# @param uri [String, nil]
|
|
72
|
+
# @return [String, nil]
|
|
73
|
+
def report_uri_directive(uri)
|
|
74
|
+
return nil if uri.nil? || uri.empty?
|
|
75
|
+
|
|
76
|
+
"report-uri #{uri}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The `report-to` directive (modern Reporting API), or nil when no
|
|
80
|
+
# reporting endpoint URL is configured. Its group name matches the
|
|
81
|
+
# Reporting-Endpoints header. No trailing semicolon.
|
|
82
|
+
#
|
|
83
|
+
# @param url [String, nil]
|
|
84
|
+
# @return [String, nil]
|
|
85
|
+
def report_to_directive(url)
|
|
86
|
+
return nil if url.nil? || url.empty?
|
|
87
|
+
|
|
88
|
+
"report-to #{REPORTING_GROUP}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# CSP directives for the development environment.
|
|
92
|
+
#
|
|
93
|
+
# Development mode allows inline scripts/styles and hot reloading
|
|
94
|
+
# connections for better developer experience with build tools like Vite.
|
|
95
|
+
#
|
|
96
|
+
# @param nonce [String] nonce value injected into `script-src`
|
|
97
|
+
# @return [Array<String>] directive strings, each terminated with `;`
|
|
98
|
+
def development_directives(nonce)
|
|
99
|
+
[
|
|
100
|
+
"default-src 'none';",
|
|
101
|
+
"script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
|
|
102
|
+
"style-src 'self' 'unsafe-inline';",
|
|
103
|
+
"connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
|
|
104
|
+
"img-src 'self' data:;",
|
|
105
|
+
"font-src 'self';",
|
|
106
|
+
"object-src 'none';",
|
|
107
|
+
"base-uri 'self';",
|
|
108
|
+
"form-action 'self';",
|
|
109
|
+
"frame-ancestors 'none';",
|
|
110
|
+
"manifest-src 'self';",
|
|
111
|
+
"worker-src 'self' data:;",
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# CSP directives for the production environment.
|
|
116
|
+
#
|
|
117
|
+
# Production mode is more restrictive, only allowing HTTPS connections
|
|
118
|
+
# and nonce-only scripts for enhanced XSS protection.
|
|
119
|
+
#
|
|
120
|
+
# @param nonce [String] nonce value injected into `script-src`
|
|
121
|
+
# @return [Array<String>] directive strings, each terminated with `;`
|
|
122
|
+
def production_directives(nonce)
|
|
123
|
+
[
|
|
124
|
+
"default-src 'none';", # Restrict to same origin by default
|
|
125
|
+
"script-src 'nonce-#{nonce}';", # Only allow scripts with valid nonce
|
|
126
|
+
"style-src 'self' 'unsafe-inline';", # Allow inline styles and same-origin stylesheets
|
|
127
|
+
"connect-src 'self' wss: https:;", # Only HTTPS and secure WebSockets
|
|
128
|
+
"img-src 'self' data:;", # Allow images from same origin and data URIs
|
|
129
|
+
"font-src 'self';", # Allow fonts from same origin only
|
|
130
|
+
"object-src 'none';", # Block <object>, <embed>, and <applet> elements
|
|
131
|
+
"base-uri 'self';", # Restrict <base> tag targets to same origin
|
|
132
|
+
"form-action 'self';", # Restrict form submissions to same origin
|
|
133
|
+
"frame-ancestors 'none';", # Prevent site from being embedded in frames
|
|
134
|
+
"manifest-src 'self';", # Allow web app manifests from same origin
|
|
135
|
+
"worker-src 'self' data:;", # Allow Workers from same origin and data blobs
|
|
136
|
+
]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# lib/otto/security/csp/report.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module CSP
|
|
8
|
+
# A single, normalized Content-Security-Policy violation report.
|
|
9
|
+
#
|
|
10
|
+
# Browsers emit violation reports in two different wire formats with two
|
|
11
|
+
# different field-naming conventions:
|
|
12
|
+
#
|
|
13
|
+
# - Legacy `application/csp-report` (CSP Level 2/3): a JSON object under a
|
|
14
|
+
# `"csp-report"` key using kebab-case fields (`blocked-uri`,
|
|
15
|
+
# `violated-directive`, `line-number`, ...).
|
|
16
|
+
# - Reporting API `application/reports+json` (Reporting API v1): a JSON
|
|
17
|
+
# ARRAY of `{"type": "csp-violation", "body": {...}}` entries whose body
|
|
18
|
+
# uses camelCase fields (`blockedURL`, `effectiveDirective`,
|
|
19
|
+
# `lineNumber`, ...).
|
|
20
|
+
#
|
|
21
|
+
# This Struct is the single normalized shape both formats collapse into, so
|
|
22
|
+
# application callbacks registered via
|
|
23
|
+
# {Otto::Security::Config#on_csp_violation} never have to care which format
|
|
24
|
+
# the browser used. Build instances with {.from_raw}; use {#to_h} to
|
|
25
|
+
# serialize.
|
|
26
|
+
#
|
|
27
|
+
# SECURITY NOTE: the URL-ish fields (`document_uri`, `blocked_uri`,
|
|
28
|
+
# `referrer`, `source_file`) reflect the page the browser was on and the
|
|
29
|
+
# resource it tried to load. In some applications these can carry sensitive
|
|
30
|
+
# path/query data (tokens, secret links). Otto does NOT redact these — it
|
|
31
|
+
# normalizes and hands them to your callback verbatim. If your application
|
|
32
|
+
# logs or forwards reports, redact these fields in your callback per your
|
|
33
|
+
# own privacy policy before they reach a log sink.
|
|
34
|
+
#
|
|
35
|
+
# @example Reading fields in a callback
|
|
36
|
+
# config.on_csp_violation do |report|
|
|
37
|
+
# logger.warn("CSP violation: #{report.violated_directive} " \
|
|
38
|
+
# "blocked #{report.blocked_uri}")
|
|
39
|
+
# end
|
|
40
|
+
Report = Struct.new(
|
|
41
|
+
:document_uri,
|
|
42
|
+
:referrer,
|
|
43
|
+
:blocked_uri,
|
|
44
|
+
:violated_directive,
|
|
45
|
+
:effective_directive,
|
|
46
|
+
:original_policy,
|
|
47
|
+
:disposition,
|
|
48
|
+
:source_file,
|
|
49
|
+
:status_code,
|
|
50
|
+
:script_sample,
|
|
51
|
+
:line_number,
|
|
52
|
+
:column_number,
|
|
53
|
+
keyword_init: true
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Normalization behavior for {Report}. Reopened (rather than defined inside
|
|
57
|
+
# the Struct.new block) so the constants below are plain class constants,
|
|
58
|
+
# not constants-defined-in-a-block.
|
|
59
|
+
class Report
|
|
60
|
+
# Map from a normalized field to the list of raw keys (in priority order)
|
|
61
|
+
# that may hold its value across both wire formats. Legacy kebab-case
|
|
62
|
+
# keys are listed alongside their Reporting API camelCase equivalents.
|
|
63
|
+
FIELD_ALIASES = {
|
|
64
|
+
document_uri: %w[document-uri documentURL],
|
|
65
|
+
referrer: %w[referrer referer],
|
|
66
|
+
blocked_uri: %w[blocked-uri blockedURL],
|
|
67
|
+
violated_directive: %w[violated-directive violatedDirective],
|
|
68
|
+
effective_directive: %w[effective-directive effectiveDirective],
|
|
69
|
+
original_policy: %w[original-policy originalPolicy],
|
|
70
|
+
disposition: %w[disposition],
|
|
71
|
+
source_file: %w[source-file sourceFile],
|
|
72
|
+
status_code: %w[status-code statusCode],
|
|
73
|
+
script_sample: %w[script-sample sample],
|
|
74
|
+
line_number: %w[line-number lineNumber],
|
|
75
|
+
column_number: %w[column-number columnNumber],
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
# Fields coerced to an Integer (or nil) rather than kept as whatever
|
|
79
|
+
# scalar the browser sent.
|
|
80
|
+
NUMERIC_FIELDS = %i[status_code line_number column_number].freeze
|
|
81
|
+
|
|
82
|
+
# Build a normalized Report from a single raw per-violation field hash.
|
|
83
|
+
#
|
|
84
|
+
# Accepts either wire format's field naming. `violated_directive` and
|
|
85
|
+
# `effective_directive` are cross-filled from each other when only one is
|
|
86
|
+
# present, because the two formats disagree on which they send (legacy
|
|
87
|
+
# favors `violated-directive`; the Reporting API favors
|
|
88
|
+
# `effectiveDirective`).
|
|
89
|
+
#
|
|
90
|
+
# @param raw [Hash] a single violation's field hash (already unwrapped
|
|
91
|
+
# from any `csp-report`/`body` envelope by the parser).
|
|
92
|
+
# @return [Report, nil] the normalized report, or nil when `raw` is not a
|
|
93
|
+
# usable Hash.
|
|
94
|
+
def self.from_raw(raw)
|
|
95
|
+
return nil unless raw.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
attrs = FIELD_ALIASES.each_with_object({}) do |(field, keys), acc|
|
|
98
|
+
value = first_present(raw, keys)
|
|
99
|
+
acc[field] = NUMERIC_FIELDS.include?(field) ? coerce_int(value) : value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
cross_fill_directives!(attrs)
|
|
103
|
+
new(**attrs)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# First non-nil value among the given keys, in priority order.
|
|
107
|
+
#
|
|
108
|
+
# @param raw [Hash]
|
|
109
|
+
# @param keys [Array<String>]
|
|
110
|
+
# @return [Object, nil]
|
|
111
|
+
def self.first_present(raw, keys)
|
|
112
|
+
keys.each do |key|
|
|
113
|
+
value = raw[key]
|
|
114
|
+
return value unless value.nil?
|
|
115
|
+
end
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Coerce a raw value to an Integer, or nil when it is not a clean
|
|
120
|
+
# integer. Guards against a browser sending a huge or non-numeric value.
|
|
121
|
+
#
|
|
122
|
+
# @param value [Object]
|
|
123
|
+
# @return [Integer, nil]
|
|
124
|
+
def self.coerce_int(value)
|
|
125
|
+
return nil if value.nil?
|
|
126
|
+
return value if value.is_a?(Integer)
|
|
127
|
+
|
|
128
|
+
str = value.to_s
|
|
129
|
+
str.match?(/\A-?\d{1,18}\z/) ? str.to_i : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Populate a missing directive from its sibling so callbacks can rely on
|
|
133
|
+
# both `violated_directive` and `effective_directive` being present when
|
|
134
|
+
# the browser sent at least one.
|
|
135
|
+
#
|
|
136
|
+
# @param attrs [Hash]
|
|
137
|
+
# @return [void]
|
|
138
|
+
def self.cross_fill_directives!(attrs)
|
|
139
|
+
attrs[:violated_directive] ||= attrs[:effective_directive]
|
|
140
|
+
attrs[:effective_directive] ||= attrs[:violated_directive]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class_method :first_present, :coerce_int, :cross_fill_directives!
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|