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,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,197 @@
|
|
|
1
|
+
# lib/otto/security/csp/writer.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module CSP
|
|
8
|
+
# The single structural apply core for nonce-based Content-Security-Policy
|
|
9
|
+
# emission. Every in-framework surface that writes a nonce CSP onto a
|
|
10
|
+
# response — {Otto::Response#apply_csp}, {Otto::Security::CSP::EmitMiddleware},
|
|
11
|
+
# and the deprecated {Otto::Response#send_csp_headers} shim — routes through
|
|
12
|
+
# {.apply}, so the emission invariants are properties of ONE method rather
|
|
13
|
+
# than guard logic re-implemented (and re-reviewed) at each surface:
|
|
14
|
+
#
|
|
15
|
+
# - **Enabled only.** No header unless the security config has nonce-CSP on.
|
|
16
|
+
# - **Nonce present.** A nil/empty nonce never produces a broken
|
|
17
|
+
# `script-src 'nonce-'` policy; it skips.
|
|
18
|
+
# - **HTML only.** Non-HTML responses (JSON, redirects, static assets) are
|
|
19
|
+
# left untouched.
|
|
20
|
+
# - **Passive layers never clobber.** In `:backstop` mode an existing CSP is
|
|
21
|
+
# deferred to; only an explicit `:override` replaces one.
|
|
22
|
+
#
|
|
23
|
+
# Writes are **in-place and key-scoped**: {.apply} finds any case-variant of
|
|
24
|
+
# the CSP key (Rack 3 mandates lowercase response-header keys, but a
|
|
25
|
+
# canonical-/mixed-cased key from a downstream layer is a spec violation this
|
|
26
|
+
# corrects in place), deletes it, and writes the lowercase key into the
|
|
27
|
+
# CALLER'S headers hash. There is no wrapping, no copy, and no
|
|
28
|
+
# "callers-must-use-the-return-value" contract — the `[status, headers,
|
|
29
|
+
# body]` tuple never needs reassignment. A frozen headers hash therefore
|
|
30
|
+
# fails loud (FrozenError) on write, surfacing the downstream SPEC violation
|
|
31
|
+
# rather than silently dropping the policy.
|
|
32
|
+
#
|
|
33
|
+
# The return is a {Result}, not the headers: `result.applied?`,
|
|
34
|
+
# `result.policy`, `result.skip_reason` give uniform observability across
|
|
35
|
+
# every surface (and drive the optional debug log) without any cleverness to
|
|
36
|
+
# detect "did anything happen".
|
|
37
|
+
class Writer
|
|
38
|
+
# Canonical (lowercase, per Rack 3 SPEC) response-header keys.
|
|
39
|
+
CSP_HEADER = 'content-security-policy'
|
|
40
|
+
CONTENT_TYPE_HEADER = 'content-type'
|
|
41
|
+
|
|
42
|
+
# Emission modes. `:override` is a deliberate per-request call that
|
|
43
|
+
# REPLACES any existing CSP (the caller owns this response's policy).
|
|
44
|
+
# `:backstop` is a passive layer that DEFERS to an existing CSP (it only
|
|
45
|
+
# fills the gap, never clobbers).
|
|
46
|
+
MODES = %i[override backstop].freeze
|
|
47
|
+
|
|
48
|
+
# Outcome of an {Writer.apply} call.
|
|
49
|
+
#
|
|
50
|
+
# `applied?` is the single source of truth for "did a header get
|
|
51
|
+
# written". `policy` is the emitted policy on success, or the pre-existing
|
|
52
|
+
# policy when a `:backstop` deferred to one. `skip_reason` is one of
|
|
53
|
+
# `:disabled`, `:blank_nonce`, `:non_html`, `:existing_csp` when skipped,
|
|
54
|
+
# else nil.
|
|
55
|
+
class Result
|
|
56
|
+
# Recognized skip reasons, in the order {Writer.apply} evaluates them.
|
|
57
|
+
SKIP_REASONS = %i[disabled blank_nonce non_html existing_csp].freeze
|
|
58
|
+
|
|
59
|
+
attr_reader :policy, :skip_reason, :mode
|
|
60
|
+
|
|
61
|
+
def initialize(applied:, mode:, policy: nil, skip_reason: nil)
|
|
62
|
+
@applied = applied
|
|
63
|
+
@mode = mode
|
|
64
|
+
@policy = policy
|
|
65
|
+
@skip_reason = skip_reason
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build an "applied" result for a written policy.
|
|
69
|
+
def self.applied(policy, mode:)
|
|
70
|
+
new(applied: true, mode: mode, policy: policy)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Build a "skipped" result. `policy` carries the pre-existing policy for
|
|
74
|
+
# the `:existing_csp` case (observability), nil otherwise.
|
|
75
|
+
def self.skipped(reason, mode:, policy: nil)
|
|
76
|
+
new(applied: false, mode: mode, skip_reason: reason, policy: policy)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean] true when a CSP header was written
|
|
80
|
+
def applied?
|
|
81
|
+
@applied
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean] true when no header was written
|
|
85
|
+
def skipped?
|
|
86
|
+
!@applied
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Apply a nonce-based CSP to the caller's response headers, in place.
|
|
91
|
+
#
|
|
92
|
+
# @param headers [Hash] the Rack response headers hash, mutated in place.
|
|
93
|
+
# MUST be mutable (Rack 3 SPEC); a frozen hash raises FrozenError.
|
|
94
|
+
# @param nonce [String, nil] the per-request nonce.
|
|
95
|
+
# @param config [Otto::Security::Config, nil] source of the enabled gate
|
|
96
|
+
# and the policy string ({Otto::Security::Config#generate_nonce_csp}).
|
|
97
|
+
# @param mode [Symbol] one of {MODES}.
|
|
98
|
+
# @param development_mode [Boolean] use the development directive set.
|
|
99
|
+
# @return [Result]
|
|
100
|
+
# @raise [ArgumentError] if mode is not one of {MODES}
|
|
101
|
+
# @raise [FrozenError] if a write is attempted against a frozen headers hash
|
|
102
|
+
def self.apply(headers, nonce, config:, mode: :override, development_mode: false)
|
|
103
|
+
unless MODES.include?(mode)
|
|
104
|
+
raise ArgumentError, "mode must be one of #{MODES.join(', ')}, got #{mode.inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
result = evaluate(headers, nonce, config, mode, development_mode)
|
|
108
|
+
log_debug(config, result)
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Guarded core: returns a Result and performs the in-place write when it
|
|
113
|
+
# applies. Guards are evaluated most-fundamental first so the reported
|
|
114
|
+
# skip_reason is stable and meaningful.
|
|
115
|
+
def self.evaluate(headers, nonce, config, mode, development_mode)
|
|
116
|
+
return Result.skipped(:disabled, mode: mode) unless enabled?(config)
|
|
117
|
+
return Result.skipped(:blank_nonce, mode: mode) if blank?(nonce)
|
|
118
|
+
return Result.skipped(:non_html, mode: mode) unless html_response?(headers)
|
|
119
|
+
|
|
120
|
+
existing = existing_csp(headers)
|
|
121
|
+
return Result.skipped(:existing_csp, mode: mode, policy: existing) if existing && mode == :backstop
|
|
122
|
+
|
|
123
|
+
policy = config.generate_nonce_csp(nonce, development_mode: development_mode)
|
|
124
|
+
write_csp(headers, policy)
|
|
125
|
+
Result.applied(policy, mode: mode)
|
|
126
|
+
end
|
|
127
|
+
private_class_method :evaluate
|
|
128
|
+
|
|
129
|
+
# In-place, key-scoped write. Delete any case-variant of the CSP key
|
|
130
|
+
# (correcting a downstream SPEC violation), then write the canonical
|
|
131
|
+
# lowercase key into the caller's hash. Variant keys are collected before
|
|
132
|
+
# deleting so we never mutate the hash while iterating it.
|
|
133
|
+
def self.write_csp(headers, policy)
|
|
134
|
+
variant_keys = headers.keys.select { |key| key != CSP_HEADER && key.to_s.casecmp?(CSP_HEADER) }
|
|
135
|
+
variant_keys.each { |key| headers.delete(key) }
|
|
136
|
+
headers[CSP_HEADER] = policy
|
|
137
|
+
end
|
|
138
|
+
private_class_method :write_csp
|
|
139
|
+
|
|
140
|
+
# Whether the config has nonce-CSP enabled (nil/foreign configs are "off").
|
|
141
|
+
def self.enabled?(config)
|
|
142
|
+
config.respond_to?(:csp_nonce_enabled?) && config.csp_nonce_enabled?
|
|
143
|
+
end
|
|
144
|
+
private_class_method :enabled?
|
|
145
|
+
|
|
146
|
+
# Whether the response is HTML, by the leading media type of its
|
|
147
|
+
# Content-Type (case-insensitive; charset and other parameters ignored).
|
|
148
|
+
# The media type must be exactly `text/html` — matched on the token
|
|
149
|
+
# before any `;`, so `text/html; charset=utf-8` is HTML but `text/html5`
|
|
150
|
+
# or `text/html-foo` is not. Absent Content-Type is treated as non-HTML:
|
|
151
|
+
# a nonce-only CSP on a response the templates never stamped would block
|
|
152
|
+
# every script.
|
|
153
|
+
def self.html_response?(headers)
|
|
154
|
+
content_type = lookup(headers, CONTENT_TYPE_HEADER)
|
|
155
|
+
return false if content_type.nil?
|
|
156
|
+
|
|
157
|
+
media_type = content_type.to_s.split(';', 2).first.to_s.strip.downcase
|
|
158
|
+
media_type == 'text/html'
|
|
159
|
+
end
|
|
160
|
+
private_class_method :html_response?
|
|
161
|
+
|
|
162
|
+
# The existing CSP value (any case-variant key), or nil.
|
|
163
|
+
def self.existing_csp(headers)
|
|
164
|
+
lookup(headers, CSP_HEADER)
|
|
165
|
+
end
|
|
166
|
+
private_class_method :existing_csp
|
|
167
|
+
|
|
168
|
+
# Case-insensitive header read: fast path for the canonical lowercase key,
|
|
169
|
+
# else a scan for a case-variant.
|
|
170
|
+
def self.lookup(headers, name)
|
|
171
|
+
return headers[name] if headers.key?(name)
|
|
172
|
+
|
|
173
|
+
headers.each { |key, value| return value if key.to_s.casecmp?(name) }
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
private_class_method :lookup
|
|
177
|
+
|
|
178
|
+
def self.blank?(value)
|
|
179
|
+
value.nil? || value.to_s.empty?
|
|
180
|
+
end
|
|
181
|
+
private_class_method :blank?
|
|
182
|
+
|
|
183
|
+
# Uniform debug observability: when the config opts into CSP debugging,
|
|
184
|
+
# log the outcome — applied policy OR skip reason — so "why didn't my page
|
|
185
|
+
# get a CSP?" no longer needs a debugger.
|
|
186
|
+
def self.log_debug(config, result)
|
|
187
|
+
return unless config.respond_to?(:debug_csp?) && config.debug_csp?
|
|
188
|
+
return unless defined?(Otto.logger) && Otto.logger
|
|
189
|
+
|
|
190
|
+
detail = result.applied? ? "applied (#{result.mode}) #{result.policy}" : "skipped (#{result.skip_reason})"
|
|
191
|
+
Otto.logger.debug("[CSP] #{detail}")
|
|
192
|
+
end
|
|
193
|
+
private_class_method :log_debug
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# lib/otto/security/csp.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Index file for Content-Security-Policy components — both halves of Otto's CSP
|
|
7
|
+
# support live under Otto::Security::CSP.
|
|
8
|
+
#
|
|
9
|
+
# EMISSION (delano/otto#180):
|
|
10
|
+
# - Policy — builds the policy string (directive sets, report-uri/report-to).
|
|
11
|
+
# - Nonce — the framework-owned lazy nonce (Otto::Security::CSP.nonce /
|
|
12
|
+
# Otto::Request#csp_nonce), memoized in env['otto.nonce'].
|
|
13
|
+
# - Writer — the single structural apply core (in-place, key-scoped writes,
|
|
14
|
+
# Result object, :override / :backstop modes) that every surface
|
|
15
|
+
# routes through: Otto::Response#apply_csp, the EmitMiddleware,
|
|
16
|
+
# and the deprecated Otto::Response#send_csp_headers shim.
|
|
17
|
+
# - EmitMiddleware — passive backstop that emits a nonce CSP for responses whose
|
|
18
|
+
# request consumed a nonce (emit-if-consumed). See
|
|
19
|
+
# Otto::Security::Core#enable_csp_emission!.
|
|
20
|
+
#
|
|
21
|
+
# RECEPTION (delano/otto#174):
|
|
22
|
+
# - Report, Parser, ReportMiddleware — a turnkey violation-report endpoint plus a
|
|
23
|
+
# callback API. See Otto::Security::Core#enable_csp_reporting!.
|
|
24
|
+
|
|
25
|
+
require_relative 'csp/policy'
|
|
26
|
+
require_relative 'csp/nonce'
|
|
27
|
+
require_relative 'csp/writer'
|
|
28
|
+
require_relative 'csp/report'
|
|
29
|
+
require_relative 'csp/parser'
|
|
30
|
+
require_relative 'csp/report_middleware'
|
|
31
|
+
require_relative 'csp/emit_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
|
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
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-07-03 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: concurrent-ruby
|
|
@@ -123,12 +124,15 @@ files:
|
|
|
123
124
|
- ".github/workflows/claude.yml"
|
|
124
125
|
- ".github/workflows/code-smells.yml"
|
|
125
126
|
- ".github/workflows/release-gem.yml"
|
|
127
|
+
- ".github/workflows/ruby-lint.yml"
|
|
128
|
+
- ".github/workflows/yardoc.yml"
|
|
126
129
|
- ".gitignore"
|
|
127
130
|
- ".pre-commit-config.yaml"
|
|
128
131
|
- ".pre-push-config.yaml"
|
|
129
132
|
- ".reek.yml"
|
|
130
133
|
- ".rspec"
|
|
131
134
|
- ".rubocop.yml"
|
|
135
|
+
- ".yardopts"
|
|
132
136
|
- AGENTS.md
|
|
133
137
|
- CHANGELOG.rst
|
|
134
138
|
- Gemfile
|
|
@@ -146,6 +150,7 @@ files:
|
|
|
146
150
|
- docs/migrating/v2.3.0.md
|
|
147
151
|
- docs/modern-authentication-authorization-landscape.md
|
|
148
152
|
- docs/multi-strategy-authentication-design.md
|
|
153
|
+
- docs/reverse-proxy-network-services.md
|
|
149
154
|
- examples/.gitignore
|
|
150
155
|
- examples/advanced_routes/README.md
|
|
151
156
|
- examples/advanced_routes/app.rb
|
|
@@ -192,6 +197,11 @@ files:
|
|
|
192
197
|
- examples/basic/app.rb
|
|
193
198
|
- examples/basic/config.ru
|
|
194
199
|
- examples/basic/routes
|
|
200
|
+
- examples/caddy_tls_demo/README.md
|
|
201
|
+
- examples/caddy_tls_demo/app.rb
|
|
202
|
+
- examples/caddy_tls_demo/config.ru
|
|
203
|
+
- examples/caddy_tls_demo/routes
|
|
204
|
+
- examples/caddy_tls_demo/standalone.ru
|
|
195
205
|
- examples/error_handler_registration.rb
|
|
196
206
|
- examples/logging_improvements.rb
|
|
197
207
|
- examples/mcp_demo/README.md
|
|
@@ -204,6 +214,10 @@ files:
|
|
|
204
214
|
- examples/security_features/routes
|
|
205
215
|
- examples/simple_geo_resolver.rb
|
|
206
216
|
- lib/otto.rb
|
|
217
|
+
- lib/otto/caddy_tls.rb
|
|
218
|
+
- lib/otto/caddy_tls/core.rb
|
|
219
|
+
- lib/otto/caddy_tls/localhost_guard.rb
|
|
220
|
+
- lib/otto/caddy_tls/server.rb
|
|
207
221
|
- lib/otto/core.rb
|
|
208
222
|
- lib/otto/core/configuration.rb
|
|
209
223
|
- lib/otto/core/error_handler.rb
|
|
@@ -279,6 +293,14 @@ files:
|
|
|
279
293
|
- lib/otto/security/configurator.rb
|
|
280
294
|
- lib/otto/security/constant_resolver.rb
|
|
281
295
|
- lib/otto/security/core.rb
|
|
296
|
+
- lib/otto/security/csp.rb
|
|
297
|
+
- lib/otto/security/csp/emit_middleware.rb
|
|
298
|
+
- lib/otto/security/csp/nonce.rb
|
|
299
|
+
- lib/otto/security/csp/parser.rb
|
|
300
|
+
- lib/otto/security/csp/policy.rb
|
|
301
|
+
- lib/otto/security/csp/report.rb
|
|
302
|
+
- lib/otto/security/csp/report_middleware.rb
|
|
303
|
+
- lib/otto/security/csp/writer.rb
|
|
282
304
|
- lib/otto/security/csrf.rb
|
|
283
305
|
- lib/otto/security/middleware/csrf_middleware.rb
|
|
284
306
|
- lib/otto/security/middleware/ip_privacy_middleware.rb
|
|
@@ -298,6 +320,7 @@ licenses:
|
|
|
298
320
|
- MIT
|
|
299
321
|
metadata:
|
|
300
322
|
rubygems_mfa_required: 'true'
|
|
323
|
+
post_install_message:
|
|
301
324
|
rdoc_options: []
|
|
302
325
|
require_paths:
|
|
303
326
|
- lib
|
|
@@ -315,7 +338,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
315
338
|
- !ruby/object:Gem::Version
|
|
316
339
|
version: '0'
|
|
317
340
|
requirements: []
|
|
318
|
-
rubygems_version: 3.
|
|
341
|
+
rubygems_version: 3.5.22
|
|
342
|
+
signing_key:
|
|
319
343
|
specification_version: 4
|
|
320
344
|
summary: Define your rack-apps in plaintext.
|
|
321
345
|
test_files: []
|