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
|
@@ -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
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# lib/otto/security/csp/report_middleware.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'parser'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Security
|
|
9
|
+
module CSP
|
|
10
|
+
# Rack middleware that receives browser-posted Content-Security-Policy
|
|
11
|
+
# violation reports and dispatches them to an application callback.
|
|
12
|
+
#
|
|
13
|
+
# This is the receiving half of Otto's CSP support; the emitting half is
|
|
14
|
+
# {Otto::Security::Config#generate_nonce_csp} / {Otto::Response#send_csp_headers}
|
|
15
|
+
# (and the static {Otto::Security::Config#enable_csp!}). When a report URI
|
|
16
|
+
# is configured, the emitted policy carries a `report-uri` directive
|
|
17
|
+
# pointing here.
|
|
18
|
+
#
|
|
19
|
+
# Behavior (all mandatory for a public, unauthenticated receiver):
|
|
20
|
+
#
|
|
21
|
+
# - INERT unless {Otto::Security::Config#csp_report_uri} is set. When it is
|
|
22
|
+
# not configured the middleware is a transparent pass-through.
|
|
23
|
+
# - Only intercepts a POST whose path matches the configured report URI.
|
|
24
|
+
# Everything else (other paths, other methods) passes through untouched.
|
|
25
|
+
# - Short-circuits BEFORE inner middleware, so CSRF, auth, and rate
|
|
26
|
+
# limiting never see the request. This is why browsers can POST reports
|
|
27
|
+
# with no CSRF token: the report never reaches the CSRF middleware.
|
|
28
|
+
# {Otto::Security::Core#enable_csp_reporting!} pins this middleware
|
|
29
|
+
# OUTERMOST (via the :outermost stack position), so the guarantee holds
|
|
30
|
+
# regardless of the order security features are enabled in. The flip side
|
|
31
|
+
# is that reports also bypass rate limiting — see the DoS note on
|
|
32
|
+
# {Otto::Security::Core#enable_csp_reporting!}; keep callbacks cheap.
|
|
33
|
+
# - Enforces a hard {MAX_BODY_BYTES} body cap. Oversized bodies are
|
|
34
|
+
# detected with a `cap + 1` read and skipped WITHOUT parsing, so a
|
|
35
|
+
# hostile client cannot force large allocations against a public endpoint.
|
|
36
|
+
# - Parses both wire formats via {Otto::Security::CSP::Parser} and invokes
|
|
37
|
+
# the registered callback once per normalized report.
|
|
38
|
+
# - NEVER raises to the client and always responds `204 No Content`
|
|
39
|
+
# (browsers ignore the body). A throwing callback is isolated by
|
|
40
|
+
# {Otto::Security::Config#dispatch_csp_violation}.
|
|
41
|
+
class ReportMiddleware
|
|
42
|
+
# Hard cap on the request body we are willing to read/parse. Browsers
|
|
43
|
+
# send small JSON documents; anything larger is abuse and is dropped.
|
|
44
|
+
MAX_BODY_BYTES = 64 * 1024 # 64 KiB
|
|
45
|
+
|
|
46
|
+
def initialize(app, config = nil)
|
|
47
|
+
@app = app
|
|
48
|
+
@config = config || Otto::Security::Config.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(env)
|
|
52
|
+
return @app.call(env) unless report_request?(env)
|
|
53
|
+
|
|
54
|
+
receive_report(env)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Handle a report POST and always answer 204. A report receiver must
|
|
60
|
+
# never surface an error to the browser, so parse/dispatch failures are
|
|
61
|
+
# contained HERE — deliberately NOT around the #call pass-through, which
|
|
62
|
+
# would swallow unrelated downstream errors (turning every failing
|
|
63
|
+
# request into a silent 204, since this middleware runs outermost).
|
|
64
|
+
def receive_report(env)
|
|
65
|
+
handle_report(env)
|
|
66
|
+
# A fresh header hash + body per call (never a shared/frozen literal)
|
|
67
|
+
# so a downstream server that mutates the response tuple is safe.
|
|
68
|
+
[204, {}, []]
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
Otto.logger.error("[Otto::CSP] report handling failed: #{e.class}: #{e.message}")
|
|
71
|
+
[204, {}, []]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# True only when reporting is configured AND this is a POST to the
|
|
75
|
+
# configured report path.
|
|
76
|
+
#
|
|
77
|
+
# @param env [Hash]
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def report_request?(env)
|
|
80
|
+
report_uri = @config.csp_report_uri
|
|
81
|
+
return false if report_uri.nil? || report_uri.empty?
|
|
82
|
+
return false unless env['REQUEST_METHOD'] == 'POST'
|
|
83
|
+
|
|
84
|
+
env['PATH_INFO'] == report_uri
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Read (capped), parse, and dispatch. Never raises; parse/dispatch
|
|
88
|
+
# failures are contained so the caller can still return 204.
|
|
89
|
+
#
|
|
90
|
+
# @param env [Hash]
|
|
91
|
+
# @return [void]
|
|
92
|
+
def handle_report(env)
|
|
93
|
+
body = read_capped_body(env)
|
|
94
|
+
return if body.nil?
|
|
95
|
+
|
|
96
|
+
reports = Otto::Security::CSP::Parser.parse(body, env['CONTENT_TYPE'])
|
|
97
|
+
reports.each { |report| @config.dispatch_csp_violation(report) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Read at most MAX_BODY_BYTES + 1 bytes so an oversized body is detected
|
|
101
|
+
# without ever materializing more than the cap. Returns nil when there is
|
|
102
|
+
# no readable body or the body exceeds the cap (skip without parsing).
|
|
103
|
+
#
|
|
104
|
+
# @param env [Hash]
|
|
105
|
+
# @return [String, nil]
|
|
106
|
+
def read_capped_body(env)
|
|
107
|
+
input = env['rack.input']
|
|
108
|
+
return nil if input.nil?
|
|
109
|
+
|
|
110
|
+
chunk = input.read(MAX_BODY_BYTES + 1)
|
|
111
|
+
input.rewind if input.respond_to?(:rewind)
|
|
112
|
+
return nil if chunk.nil? || chunk.empty?
|
|
113
|
+
return nil if chunk.bytesize > MAX_BODY_BYTES # oversized: drop unparsed
|
|
114
|
+
|
|
115
|
+
chunk
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# lib/otto/security/csp.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Index file for Content-Security-Policy violation reporting components.
|
|
7
|
+
#
|
|
8
|
+
# Otto emits CSP headers via Otto::Security::Config (static #enable_csp! and
|
|
9
|
+
# nonce-based #generate_nonce_csp / Otto::Response#send_csp_headers). This
|
|
10
|
+
# module adds the receiving half: a turnkey violation-report endpoint plus a
|
|
11
|
+
# callback API, so an application can collect CSP reports with a few lines of
|
|
12
|
+
# config instead of hand-rolling a parser, size cap, and CSRF exemption.
|
|
13
|
+
#
|
|
14
|
+
# See Otto::Security::Core#enable_csp_reporting! for the primary entry point and
|
|
15
|
+
# delano/otto#174 for the design.
|
|
16
|
+
|
|
17
|
+
require_relative 'csp/report'
|
|
18
|
+
require_relative 'csp/parser'
|
|
19
|
+
require_relative 'csp/report_middleware'
|
|
@@ -85,6 +85,27 @@ class Otto
|
|
|
85
85
|
|
|
86
86
|
Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
|
|
87
87
|
|
|
88
|
+
# No resolvable client IP (REMOTE_ADDR absent or blank, and no trusted
|
|
89
|
+
# forwarded value). There is nothing to mask, and masking would derive
|
|
90
|
+
# a nil masked IP (IPPrivacy.mask_ip returns nil for nil/empty input).
|
|
91
|
+
# Writing that nil back to REMOTE_ADDR / forwarded headers would leave
|
|
92
|
+
# present-but-nil CGI keys, which violate the Rack SPEC and trip
|
|
93
|
+
# Rack::Lint — the same class of bug as the User-Agent/Referer case
|
|
94
|
+
# below (issue #167). Skip the IP-masking work, leaving REMOTE_ADDR
|
|
95
|
+
# untouched (an absent key stays absent; an empty string stays an
|
|
96
|
+
# empty string).
|
|
97
|
+
#
|
|
98
|
+
# The User-Agent/Referer redaction, however, is independent of the
|
|
99
|
+
# client IP, and this middleware's contract is to ALWAYS clear the
|
|
100
|
+
# original sensitive data. So still scrub those headers before
|
|
101
|
+
# bailing — a request with no resolvable IP must not leak an
|
|
102
|
+
# un-anonymized User-Agent or Referer.
|
|
103
|
+
if client_ip.to_s.empty?
|
|
104
|
+
Otto.logger.debug '[IPPrivacyMiddleware] No resolvable client IP; skipping IP masking' if Otto.debug
|
|
105
|
+
scrub_sensitive_headers(env, Otto::Privacy::RedactedFingerprint.new(env, @config))
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
88
109
|
# Skip masking for private/localhost IPs unless explicitly configured to mask them
|
|
89
110
|
# This provides better DX for development while still protecting public IPs
|
|
90
111
|
unless @config.mask_private_ips
|
|
@@ -122,13 +143,10 @@ class Otto
|
|
|
122
143
|
# Privacy-safe: holds the masked value, never the original public IP.
|
|
123
144
|
env['otto.client_ip'] = fingerprint.masked_ip
|
|
124
145
|
|
|
125
|
-
# Replace User-Agent with anonymized
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# Replace Referer with anonymized version (query params stripped)
|
|
130
|
-
# CRITICAL: Always replace, even if nil, to clear original sensitive data
|
|
131
|
-
env['HTTP_REFERER'] = fingerprint.referer
|
|
146
|
+
# Replace User-Agent / Referer with anonymized versions (consistent
|
|
147
|
+
# with IP masking). See scrub_sensitive_headers — also reached by the
|
|
148
|
+
# no-resolvable-IP path so these headers are always cleared.
|
|
149
|
+
scrub_sensitive_headers(env, fingerprint)
|
|
132
150
|
|
|
133
151
|
# Mask X-Forwarded-For headers to prevent leakage
|
|
134
152
|
# Replace with masked IP so proxy resolution logic finds the masked IP
|
|
@@ -141,6 +159,45 @@ class Otto
|
|
|
141
159
|
end
|
|
142
160
|
|
|
143
161
|
|
|
162
|
+
# Set or clear a Rack env header in a SPEC-compliant way.
|
|
163
|
+
#
|
|
164
|
+
# CGI-style keys (those without a period) must hold String values per
|
|
165
|
+
# the Rack SPEC; a present-but-nil value trips Rack::Lint. So when the
|
|
166
|
+
# anonymized replacement is nil, delete the key entirely instead of
|
|
167
|
+
# assigning nil — semantically identical to "cleared" for downstream
|
|
168
|
+
# readers, and SPEC-compliant.
|
|
169
|
+
#
|
|
170
|
+
# @param env [Hash] Rack environment
|
|
171
|
+
# @param key [String] Env key to set or delete
|
|
172
|
+
# @param value [String, nil] Replacement value, or nil to clear the key
|
|
173
|
+
def replace_or_delete(env, key, value)
|
|
174
|
+
if value.nil?
|
|
175
|
+
env.delete(key)
|
|
176
|
+
else
|
|
177
|
+
env[key] = value
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Redact the request's sensitive non-IP headers in place.
|
|
182
|
+
#
|
|
183
|
+
# User-Agent and Referer carry identifying information independent of
|
|
184
|
+
# the client IP, so they are scrubbed on every privacy-enabled request
|
|
185
|
+
# — including ones with no resolvable IP, where IP masking is skipped.
|
|
186
|
+
# Each header is replaced with the fingerprint's anonymized value, or
|
|
187
|
+
# DELETED when that value is nil (no/empty header): CGI-style keys must
|
|
188
|
+
# hold String values per the Rack SPEC, so a present-but-nil
|
|
189
|
+
# HTTP_USER_AGENT/HTTP_REFERER would trip Rack::Lint (issue #167).
|
|
190
|
+
# Deleting is also marginally more private — an absent header is
|
|
191
|
+
# indistinguishable from one that was never sent.
|
|
192
|
+
#
|
|
193
|
+
# @param env [Hash] Rack environment
|
|
194
|
+
# @param fingerprint [Otto::Privacy::RedactedFingerprint] source of the
|
|
195
|
+
# anonymized header values
|
|
196
|
+
def scrub_sensitive_headers(env, fingerprint)
|
|
197
|
+
replace_or_delete(env, 'HTTP_USER_AGENT', fingerprint.anonymized_ua)
|
|
198
|
+
replace_or_delete(env, 'HTTP_REFERER', fingerprint.referer)
|
|
199
|
+
end
|
|
200
|
+
|
|
144
201
|
# Resolve the actual client IP address from the request.
|
|
145
202
|
#
|
|
146
203
|
# Delegates to the shared Otto::Utils.resolve_client_ip so the
|
|
@@ -162,6 +219,14 @@ class Otto
|
|
|
162
219
|
# @param env [Hash] Rack environment
|
|
163
220
|
# @param masked_ip [String] The masked IP to use as replacement
|
|
164
221
|
def mask_forwarded_headers(env, masked_ip)
|
|
222
|
+
# Defensive: never write a nil replacement into these CGI-style headers
|
|
223
|
+
# (the Rack SPEC requires String values; a nil trips Rack::Lint — see
|
|
224
|
+
# issue #167). apply_privacy's early "no client IP" guard already
|
|
225
|
+
# guarantees a non-nil masked_ip here, but keep this method
|
|
226
|
+
# self-contained so a future caller change can't reintroduce a
|
|
227
|
+
# present-but-nil HTTP_X_FORWARDED_FOR.
|
|
228
|
+
return if masked_ip.nil?
|
|
229
|
+
|
|
165
230
|
# Replace X-Forwarded-For with masked IP
|
|
166
231
|
# This prevents Rack::Request#ip from finding the real IP
|
|
167
232
|
env['HTTP_X_FORWARDED_FOR'] = masked_ip if env['HTTP_X_FORWARDED_FOR']
|
data/lib/otto/security.rb
CHANGED
|
@@ -12,3 +12,4 @@ require_relative 'security/middleware/csrf_middleware'
|
|
|
12
12
|
require_relative 'security/middleware/validation_middleware'
|
|
13
13
|
require_relative 'security/middleware/rate_limit_middleware'
|
|
14
14
|
require_relative 'security/middleware/ip_privacy_middleware'
|
|
15
|
+
require_relative 'security/csp'
|
data/lib/otto/utils.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'ipaddr'
|
|
6
|
+
require 'rack/utils'
|
|
6
7
|
|
|
7
8
|
class Otto
|
|
8
9
|
# Utility methods for common operations and helpers
|
|
@@ -57,6 +58,41 @@ class Otto
|
|
|
57
58
|
!value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
# Canonical path normalization for literal route matching: URL-unescape,
|
|
62
|
+
# scrub invalid/undefined bytes, and strip a single trailing slash.
|
|
63
|
+
#
|
|
64
|
+
# This is the SINGLE SOURCE OF TRUTH shared by the router
|
|
65
|
+
# (Otto::Core::Router#handle_request, which compares the result against its
|
|
66
|
+
# literal-route table) and Otto::CaddyTLS::LocalhostGuard (which compares it
|
|
67
|
+
# against the guarded endpoint). The two MUST normalize identically: if a
|
|
68
|
+
# crafted path — a trailing slash, a percent-encoded byte, an invalid UTF-8
|
|
69
|
+
# byte — normalized differently in the guard than in the router, the router
|
|
70
|
+
# could dispatch a request the guard let through, bypassing the loopback
|
|
71
|
+
# check. One implementation makes that drift impossible.
|
|
72
|
+
#
|
|
73
|
+
# Mirrors the empty-path handling and :replace scrubbing the router applies.
|
|
74
|
+
# Robust to invalid input: Rack::Utils.unescape raises ArgumentError on an
|
|
75
|
+
# already-invalid byte sequence (a raw \xFF in the path), so that is caught
|
|
76
|
+
# and the raw string is scrubbed instead — a percent-encoded invalid byte
|
|
77
|
+
# (%FF) decodes to the same invalid byte and is scrubbed identically, so the
|
|
78
|
+
# two crafted forms normalize alike. The method itself does not raise.
|
|
79
|
+
#
|
|
80
|
+
# @param raw_path [String, nil] a raw PATH_INFO or a configured endpoint
|
|
81
|
+
# @return [String] normalized path suitable for exact literal comparison
|
|
82
|
+
def normalize_path(raw_path)
|
|
83
|
+
raw = raw_path.to_s
|
|
84
|
+
decoded =
|
|
85
|
+
begin
|
|
86
|
+
Rack::Utils.unescape(raw)
|
|
87
|
+
rescue ArgumentError
|
|
88
|
+
raw
|
|
89
|
+
end
|
|
90
|
+
decoded = '/' if decoded.empty?
|
|
91
|
+
decoded
|
|
92
|
+
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
93
|
+
.gsub(%r{/$}, '')
|
|
94
|
+
end
|
|
95
|
+
|
|
60
96
|
# Validate and normalize an IP address (IPv4 and IPv6).
|
|
61
97
|
#
|
|
62
98
|
# Strips an optional port (IPv6-safe), validates with IPAddr, and returns
|
|
@@ -143,37 +179,41 @@ class Otto
|
|
|
143
179
|
# from the right of the forwarded chain (Express `trust proxy = N`). Used
|
|
144
180
|
# when the proxy tier's addresses cannot be enumerated as CIDRs.
|
|
145
181
|
#
|
|
146
|
-
# The chain is
|
|
147
|
-
# proxy) plus REMOTE_ADDR (the direct peer). With depth
|
|
148
|
-
# chain[-(N+1)] — exactly N trusted hops from the right,
|
|
149
|
-
# Express's addrs[N]. This is robust to
|
|
150
|
-
# leftmost entry is never reached.
|
|
182
|
+
# The chain is the configured forwarded header (leftmost = client ..
|
|
183
|
+
# rightmost = nearest proxy) plus REMOTE_ADDR (the direct peer). With depth
|
|
184
|
+
# N the client is chain[-(N+1)] — exactly N trusted hops from the right,
|
|
185
|
+
# equivalent to Express's addrs[N]. This is robust to forwarded-header
|
|
186
|
+
# padding: a forged leftmost entry is never reached.
|
|
151
187
|
#
|
|
152
188
|
# SECURITY: depth trust ASSUMES ORIGIN LOCKDOWN — the app must be
|
|
153
189
|
# unreachable except through the proxy tier. Without it, a direct client
|
|
154
|
-
# could pad
|
|
190
|
+
# could pad the forwarded header to land a forged value at the target index.
|
|
155
191
|
# This is the inherent trade vs CIDR-walk (a fixed hop count instead of
|
|
156
192
|
# enumerable proxy addresses).
|
|
157
193
|
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
194
|
+
# The forwarded chain is selected by security_config.trusted_proxy_header:
|
|
195
|
+
# 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both' (RFC 7239
|
|
196
|
+
# when it carries a `for=`, otherwise X-Forwarded-For — mirrors
|
|
197
|
+
# OneTimeSecret's site.network.trusted_proxy.header). X-Real-IP / X-Client-IP
|
|
198
|
+
# are single-value and cannot express a hop chain, so they are never
|
|
199
|
+
# consulted in depth mode. Positions are counted raw (never dropped), so junk
|
|
200
|
+
# padding cannot shift the index; only the selected entry is validated. If
|
|
201
|
+
# the chain is shorter than N+1 (a request that may have bypassed the proxy
|
|
202
|
+
# tier) or the selected entry is invalid, REMOTE_ADDR is returned rather than
|
|
203
|
+
# a spoofable forwarded value.
|
|
164
204
|
#
|
|
165
205
|
# @param env [Hash] Rack environment
|
|
166
|
-
# @param security_config [Otto::Security::Config] config exposing #trusted_proxy_depth
|
|
206
|
+
# @param security_config [Otto::Security::Config] config exposing #trusted_proxy_depth and #trusted_proxy_header
|
|
167
207
|
# @return [String, nil] resolved client IP (REMOTE_ADDR on short chain / invalid target)
|
|
168
208
|
def resolve_client_ip_by_depth(env, security_config)
|
|
169
209
|
remote_addr = env['REMOTE_ADDR']
|
|
170
210
|
depth = security_config.trusted_proxy_depth.to_i
|
|
171
211
|
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
forwarded = env
|
|
212
|
+
# Build the positional hop chain from the configured header, keeping every
|
|
213
|
+
# position (junk/empty entries included) so the client can be located by
|
|
214
|
+
# counting from the right; dropping entries would let padding shift the
|
|
215
|
+
# index. REMOTE_ADDR (the direct peer) is the rightmost hop.
|
|
216
|
+
forwarded = forwarded_chain_for_depth(env, security_config.trusted_proxy_header)
|
|
177
217
|
chain = forwarded + [remote_addr]
|
|
178
218
|
|
|
179
219
|
index = chain.length - (depth + 1)
|
|
@@ -182,6 +222,81 @@ class Otto
|
|
|
182
222
|
normalize_ip(chain[index].to_s.strip) || remote_addr
|
|
183
223
|
end
|
|
184
224
|
|
|
225
|
+
# Positional forwarded-hop chain for depth resolution, selected by header
|
|
226
|
+
# mode. Each element is one hop (preserving count); values are raw — only the
|
|
227
|
+
# finally-selected entry is normalized. Mirrors OneTimeSecret's
|
|
228
|
+
# site.network.trusted_proxy.header semantics.
|
|
229
|
+
#
|
|
230
|
+
# @param env [Hash] Rack environment
|
|
231
|
+
# @param header_mode [String] 'X-Forwarded-For', 'Forwarded', or 'Both'
|
|
232
|
+
# @return [Array<String>] one entry per hop (may include blank/invalid entries)
|
|
233
|
+
def forwarded_chain_for_depth(env, header_mode)
|
|
234
|
+
case header_mode
|
|
235
|
+
when 'Forwarded'
|
|
236
|
+
rfc7239_for_chain(env['HTTP_FORWARDED'])
|
|
237
|
+
when 'Both'
|
|
238
|
+
# RFC 7239 wins when it carries at least one `for=`; otherwise fall back
|
|
239
|
+
# to X-Forwarded-For. The chains are NOT merged (matches OTS's
|
|
240
|
+
# `extract_rfc7239_forwarded(env) || extract_x_forwarded_for(env)`).
|
|
241
|
+
forwarded = rfc7239_for_chain(env['HTTP_FORWARDED'])
|
|
242
|
+
forwarded.any? { |entry| !entry.empty? } ? forwarded : xff_chain(env['HTTP_X_FORWARDED_FOR'])
|
|
243
|
+
else
|
|
244
|
+
xff_chain(env['HTTP_X_FORWARDED_FOR'])
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Split X-Forwarded-For into raw positional entries. `-1` keeps trailing
|
|
249
|
+
# empty fields so a malformed/empty hop still counts as a position.
|
|
250
|
+
#
|
|
251
|
+
# @param value [String, nil] raw X-Forwarded-For header value
|
|
252
|
+
# @return [Array<String>]
|
|
253
|
+
def xff_chain(value)
|
|
254
|
+
value.to_s.split(',', -1)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Extract the per-hop `for=` chain from an RFC 7239 Forwarded header,
|
|
258
|
+
# preserving one position per forwarded-element. Elements without a `for=`
|
|
259
|
+
# parameter yield a blank placeholder so they still occupy a hop position
|
|
260
|
+
# (raw position counting). The extracted token is only unquoted here; port
|
|
261
|
+
# and IPv6 brackets are left for normalize_ip when the entry is selected.
|
|
262
|
+
# Obfuscated (`for=_hidden`) and `for=unknown` identifiers are preserved as
|
|
263
|
+
# positions but normalize to nil (→ REMOTE_ADDR fallback if selected).
|
|
264
|
+
# Commas separate forwarded-elements (and join multiple Forwarded headers).
|
|
265
|
+
# A nil/blank header splits to [] (not ['']), so an absent Forwarded header
|
|
266
|
+
# yields an empty chain and depth's explicit short-chain guard returns
|
|
267
|
+
# REMOTE_ADDR — symmetric with xff_chain.
|
|
268
|
+
#
|
|
269
|
+
# @param value [String, nil] raw Forwarded header value
|
|
270
|
+
# @return [Array<String>] one `for=` token per forwarded-element
|
|
271
|
+
def rfc7239_for_chain(value)
|
|
272
|
+
value.to_s.split(',', -1).map { |element| rfc7239_for_value(element) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Pull the `for=` token out of a single RFC 7239 forwarded-element. The value
|
|
276
|
+
# is a quoted-string (which may itself legally contain ';') or an unquoted
|
|
277
|
+
# token ending at the next ';'. The quoted form is matched first so a ';'
|
|
278
|
+
# inside DQUOTEs is NOT treated as a parameter separator — otherwise a value
|
|
279
|
+
# like for="1.2.3.4;junk" would be truncated to a valid-looking IP instead of
|
|
280
|
+
# being rejected. Only DQUOTE wrappers are stripped: RFC 7239 quoted-strings
|
|
281
|
+
# use DQUOTE exclusively, so a value like for='1.2.3.4' keeps its quotes,
|
|
282
|
+
# fails normalize_ip, and safely falls back to REMOTE_ADDR rather than being
|
|
283
|
+
# permissively accepted. This is deliberately stricter than OTS (which strips
|
|
284
|
+
# both ['"]), consistent with depth's other intentionally-not-reconciled-down
|
|
285
|
+
# safety properties. The raw value (port / IPv6 brackets intact) is left for
|
|
286
|
+
# normalize_ip when the entry is selected. Returns '' when the element carries
|
|
287
|
+
# no `for=` parameter, preserving the hop position. The `for=` pair may be the
|
|
288
|
+
# element's first pair or follow a ';'; leading whitespace (e.g. after a comma
|
|
289
|
+
# split) is tolerated.
|
|
290
|
+
#
|
|
291
|
+
# @param element [String] one forwarded-element (e.g. 'for=1.2.3.4;proto=https')
|
|
292
|
+
# @return [String]
|
|
293
|
+
def rfc7239_for_value(element)
|
|
294
|
+
match = element.match(/(?:\A|;)\s*for=(?:"([^"]*)"|([^;]+))/i)
|
|
295
|
+
return '' unless match
|
|
296
|
+
|
|
297
|
+
(match[1] || match[2]).strip
|
|
298
|
+
end
|
|
299
|
+
|
|
185
300
|
# Whether an address is non-public: RFC1918 private, loopback, link-local,
|
|
186
301
|
# multicast, or unspecified — for both IPv4 and IPv6.
|
|
187
302
|
#
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative 'otto/route_handlers'
|
|
|
22
22
|
require_relative 'otto/errors'
|
|
23
23
|
require_relative 'otto/locale'
|
|
24
24
|
require_relative 'otto/mcp'
|
|
25
|
+
require_relative 'otto/caddy_tls'
|
|
25
26
|
require_relative 'otto/core'
|
|
26
27
|
require_relative 'otto/privacy'
|
|
27
28
|
require_relative 'otto/security'
|
|
@@ -62,6 +63,7 @@ class Otto
|
|
|
62
63
|
include Otto::Security::Core
|
|
63
64
|
include Otto::Privacy::Core
|
|
64
65
|
include Otto::MCP::Core
|
|
66
|
+
include Otto::CaddyTLS::Core
|
|
65
67
|
|
|
66
68
|
LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
|
|
67
69
|
|
|
@@ -75,7 +77,7 @@ class Otto
|
|
|
75
77
|
|
|
76
78
|
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
|
|
77
79
|
:static_route, :security_config, :locale_config, :auth_config,
|
|
78
|
-
:route_handler_factory, :mcp_server, :security, :middleware,
|
|
80
|
+
:route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
|
|
79
81
|
:error_handlers, :request_class, :response_class
|
|
80
82
|
attr_accessor :not_found, :server_error
|
|
81
83
|
|
|
@@ -107,8 +109,13 @@ class Otto
|
|
|
107
109
|
|
|
108
110
|
# Main Rack application interface
|
|
109
111
|
def call(env)
|
|
110
|
-
# Freeze configuration on first request (thread-safe)
|
|
111
|
-
#
|
|
112
|
+
# Freeze configuration on first request (thread-safe).
|
|
113
|
+
# Skipped under RSpec so specs can mutate configuration after construction.
|
|
114
|
+
# Because of this skip, behavior that depends on a genuinely frozen config
|
|
115
|
+
# (e.g. CSP violation dispatch through a frozen Config) is NOT exercised by
|
|
116
|
+
# the normal request path in tests — cover it with specs that freeze
|
|
117
|
+
# explicitly (see spec/otto/security/csp_reporting_frozen_spec.rb and
|
|
118
|
+
# spec/otto/configuration_freezing_spec.rb).
|
|
112
119
|
unless defined?(RSpec) || @configuration_frozen
|
|
113
120
|
Otto.logger.debug '[Otto] Lazy freezing check: configuration not yet frozen' if Otto.debug
|
|
114
121
|
|
|
@@ -169,6 +176,22 @@ class Otto
|
|
|
169
176
|
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' }
|
|
170
177
|
@security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
|
|
171
178
|
@app = nil # Pre-built middleware app (built after initialization)
|
|
179
|
+
|
|
180
|
+
# Keep the running Rack app in sync with the middleware stack. This is the
|
|
181
|
+
# SINGLE rebuild trigger: any add/remove/clear — whether via Otto#use,
|
|
182
|
+
# otto.enable_*!, or the otto.security.* Configurator surface — rebuilds @app
|
|
183
|
+
# once it exists. Without it, middleware added through the Configurator after
|
|
184
|
+
# Otto.new would register in the stack but never enter the running request
|
|
185
|
+
# chain, silently disabling CSRF, request validation, rate limiting, and CSP
|
|
186
|
+
# reporting configured that way.
|
|
187
|
+
#
|
|
188
|
+
# The `if @app` guard makes stack mutations during initialization (e.g. the
|
|
189
|
+
# IP-privacy add below) no-ops until the first build_app! runs. A rebuild
|
|
190
|
+
# during a live request could swap @app mid-flight under multi-threaded
|
|
191
|
+
# serving; Otto's contract is to configure before the first request, which
|
|
192
|
+
# the lazy configuration freeze enforces in production.
|
|
193
|
+
@middleware.on_change { build_app! if @app }
|
|
194
|
+
|
|
172
195
|
@request_complete_callbacks = [] # Instance-level request completion callbacks
|
|
173
196
|
@route_matched_callbacks = [] # Instance-level route matched callbacks
|
|
174
197
|
@handler_wrappers = [] # Instance-level handler wrapper factories
|