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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +1 -1
  3. data/.github/workflows/ci.yml +7 -1
  4. data/.github/workflows/claude-code-review.yml +32 -9
  5. data/.github/workflows/claude.yml +7 -5
  6. data/.github/workflows/code-smells.yml +2 -2
  7. data/.github/workflows/release-gem.yml +12 -2
  8. data/.github/workflows/ruby-lint.yml +66 -0
  9. data/.github/workflows/yardoc.yml +117 -0
  10. data/.yardopts +15 -0
  11. data/CHANGELOG.rst +129 -0
  12. data/Gemfile +4 -2
  13. data/Gemfile.lock +23 -17
  14. data/README.md +150 -0
  15. data/docs/.gitignore +1 -0
  16. data/docs/reverse-proxy-network-services.md +358 -0
  17. data/examples/caddy_tls_demo/README.md +100 -0
  18. data/examples/caddy_tls_demo/app.rb +41 -0
  19. data/examples/caddy_tls_demo/config.ru +31 -0
  20. data/examples/caddy_tls_demo/routes +9 -0
  21. data/examples/caddy_tls_demo/standalone.ru +38 -0
  22. data/lib/otto/caddy_tls/core.rb +74 -0
  23. data/lib/otto/caddy_tls/localhost_guard.rb +158 -0
  24. data/lib/otto/caddy_tls/server.rb +149 -0
  25. data/lib/otto/caddy_tls.rb +7 -0
  26. data/lib/otto/core/middleware_management.rb +7 -7
  27. data/lib/otto/core/middleware_stack.rb +40 -5
  28. data/lib/otto/core/router.rb +4 -8
  29. data/lib/otto/env_keys.rb +10 -0
  30. data/lib/otto/request.rb +14 -0
  31. data/lib/otto/response.rb +80 -43
  32. data/lib/otto/security/config.rb +234 -51
  33. data/lib/otto/security/configurator.rb +54 -0
  34. data/lib/otto/security/core.rb +94 -0
  35. data/lib/otto/security/csp/emit_middleware.rb +91 -0
  36. data/lib/otto/security/csp/nonce.rb +78 -0
  37. data/lib/otto/security/csp/parser.rb +120 -0
  38. data/lib/otto/security/csp/policy.rb +141 -0
  39. data/lib/otto/security/csp/report.rb +147 -0
  40. data/lib/otto/security/csp/report_middleware.rb +120 -0
  41. data/lib/otto/security/csp/writer.rb +197 -0
  42. data/lib/otto/security/csp.rb +31 -0
  43. data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
  44. data/lib/otto/security.rb +1 -0
  45. data/lib/otto/utils.rb +36 -0
  46. data/lib/otto/version.rb +1 -1
  47. data/lib/otto.rb +26 -3
  48. 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