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,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 version (consistent with IP masking)
126
- # CRITICAL: Always replace, even if nil, to clear original sensitive data
127
- env['HTTP_USER_AGENT'] = fingerprint.anonymized_ua
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
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.3.1'
6
+ VERSION = '2.5.0'
7
7
  end
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
- # Skip in test environment to allow test flexibility
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.3.1
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: 1980-01-02 00:00:00.000000000 Z
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.6.9
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: []