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.
Files changed (44) 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 +33 -10
  5. data/.github/workflows/claude.yml +9 -2
  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 +84 -0
  12. data/Gemfile +4 -1
  13. data/Gemfile.lock +23 -15
  14. data/README.md +96 -0
  15. data/Rakefile +21 -0
  16. data/docs/.gitignore +1 -0
  17. data/docs/migrating/v2.3.0.md +50 -3
  18. data/docs/reverse-proxy-network-services.md +358 -0
  19. data/examples/caddy_tls_demo/README.md +100 -0
  20. data/examples/caddy_tls_demo/app.rb +41 -0
  21. data/examples/caddy_tls_demo/config.ru +31 -0
  22. data/examples/caddy_tls_demo/routes +9 -0
  23. data/examples/caddy_tls_demo/standalone.ru +38 -0
  24. data/lib/otto/caddy_tls/core.rb +74 -0
  25. data/lib/otto/caddy_tls/localhost_guard.rb +158 -0
  26. data/lib/otto/caddy_tls/server.rb +149 -0
  27. data/lib/otto/caddy_tls.rb +7 -0
  28. data/lib/otto/core/configuration.rb +9 -1
  29. data/lib/otto/core/middleware_management.rb +7 -7
  30. data/lib/otto/core/middleware_stack.rb +39 -5
  31. data/lib/otto/core/router.rb +4 -8
  32. data/lib/otto/security/config.rb +293 -2
  33. data/lib/otto/security/configurator.rb +53 -0
  34. data/lib/otto/security/core.rb +62 -0
  35. data/lib/otto/security/csp/parser.rb +120 -0
  36. data/lib/otto/security/csp/report.rb +147 -0
  37. data/lib/otto/security/csp/report_middleware.rb +120 -0
  38. data/lib/otto/security/csp.rb +19 -0
  39. data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
  40. data/lib/otto/security.rb +1 -0
  41. data/lib/otto/utils.rb +133 -18
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +26 -3
  44. 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 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
@@ -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 X-Forwarded-For (leftmost = client .. rightmost = nearest
147
- # proxy) plus REMOTE_ADDR (the direct peer). With depth N the client is
148
- # chain[-(N+1)] — exactly N trusted hops from the right, equivalent to
149
- # Express's addrs[N]. This is robust to X-Forwarded-For padding: a forged
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 X-Forwarded-For to land a forged value at the target index.
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
- # Only X-Forwarded-For is consulted; X-Real-IP / X-Client-IP are
159
- # single-value and cannot express a hop chain. Positions are counted raw
160
- # (never dropped), so junk padding cannot shift the index; only the
161
- # selected entry is validated. If the chain is shorter than N+1 (a request
162
- # that may have bypassed the proxy tier) or the selected entry is invalid,
163
- # REMOTE_ADDR is returned rather than a spoofable forwarded value.
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
- # Split on commas keeping every position (-1 preserves trailing empty
173
- # fields) so a malformed hop still counts as a position. The client must
174
- # be located by counting from the right; dropping entries here would let
175
- # padding shift the index.
176
- forwarded = env['HTTP_X_FORWARDED_FOR'].to_s.split(',', -1)
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
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.3.0'
6
+ VERSION = '2.4.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