otto 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +1 -1
- data/.github/workflows/ci.yml +7 -1
- data/.github/workflows/claude-code-review.yml +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 +59 -0
- data/Gemfile +4 -2
- data/Gemfile.lock +23 -17
- data/README.md +96 -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 +39 -5
- data/lib/otto/core/router.rb +4 -8
- data/lib/otto/security/config.rb +227 -2
- data/lib/otto/security/configurator.rb +38 -0
- data/lib/otto/security/core.rb +62 -0
- data/lib/otto/security/csp/parser.rb +120 -0
- data/lib/otto/security/csp/report.rb +147 -0
- data/lib/otto/security/csp/report_middleware.rb +120 -0
- data/lib/otto/security/csp.rb +19 -0
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +36 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -3
- metadata +23 -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,19 @@
|
|
|
1
|
+
# lib/otto/security/csp.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# Index file for Content-Security-Policy violation reporting components.
|
|
7
|
+
#
|
|
8
|
+
# Otto emits CSP headers via Otto::Security::Config (static #enable_csp! and
|
|
9
|
+
# nonce-based #generate_nonce_csp / Otto::Response#send_csp_headers). This
|
|
10
|
+
# module adds the receiving half: a turnkey violation-report endpoint plus a
|
|
11
|
+
# callback API, so an application can collect CSP reports with a few lines of
|
|
12
|
+
# config instead of hand-rolling a parser, size cap, and CSRF exemption.
|
|
13
|
+
#
|
|
14
|
+
# See Otto::Security::Core#enable_csp_reporting! for the primary entry point and
|
|
15
|
+
# delano/otto#174 for the design.
|
|
16
|
+
|
|
17
|
+
require_relative 'csp/report'
|
|
18
|
+
require_relative 'csp/parser'
|
|
19
|
+
require_relative 'csp/report_middleware'
|
|
@@ -85,6 +85,27 @@ class Otto
|
|
|
85
85
|
|
|
86
86
|
Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
|
|
87
87
|
|
|
88
|
+
# No resolvable client IP (REMOTE_ADDR absent or blank, and no trusted
|
|
89
|
+
# forwarded value). There is nothing to mask, and masking would derive
|
|
90
|
+
# a nil masked IP (IPPrivacy.mask_ip returns nil for nil/empty input).
|
|
91
|
+
# Writing that nil back to REMOTE_ADDR / forwarded headers would leave
|
|
92
|
+
# present-but-nil CGI keys, which violate the Rack SPEC and trip
|
|
93
|
+
# Rack::Lint — the same class of bug as the User-Agent/Referer case
|
|
94
|
+
# below (issue #167). Skip the IP-masking work, leaving REMOTE_ADDR
|
|
95
|
+
# untouched (an absent key stays absent; an empty string stays an
|
|
96
|
+
# empty string).
|
|
97
|
+
#
|
|
98
|
+
# The User-Agent/Referer redaction, however, is independent of the
|
|
99
|
+
# client IP, and this middleware's contract is to ALWAYS clear the
|
|
100
|
+
# original sensitive data. So still scrub those headers before
|
|
101
|
+
# bailing — a request with no resolvable IP must not leak an
|
|
102
|
+
# un-anonymized User-Agent or Referer.
|
|
103
|
+
if client_ip.to_s.empty?
|
|
104
|
+
Otto.logger.debug '[IPPrivacyMiddleware] No resolvable client IP; skipping IP masking' if Otto.debug
|
|
105
|
+
scrub_sensitive_headers(env, Otto::Privacy::RedactedFingerprint.new(env, @config))
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
88
109
|
# Skip masking for private/localhost IPs unless explicitly configured to mask them
|
|
89
110
|
# This provides better DX for development while still protecting public IPs
|
|
90
111
|
unless @config.mask_private_ips
|
|
@@ -122,13 +143,10 @@ class Otto
|
|
|
122
143
|
# Privacy-safe: holds the masked value, never the original public IP.
|
|
123
144
|
env['otto.client_ip'] = fingerprint.masked_ip
|
|
124
145
|
|
|
125
|
-
# Replace User-Agent with anonymized
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
# Replace Referer with anonymized version (query params stripped)
|
|
130
|
-
# CRITICAL: Always replace, even if nil, to clear original sensitive data
|
|
131
|
-
env['HTTP_REFERER'] = fingerprint.referer
|
|
146
|
+
# Replace User-Agent / Referer with anonymized versions (consistent
|
|
147
|
+
# with IP masking). See scrub_sensitive_headers — also reached by the
|
|
148
|
+
# no-resolvable-IP path so these headers are always cleared.
|
|
149
|
+
scrub_sensitive_headers(env, fingerprint)
|
|
132
150
|
|
|
133
151
|
# Mask X-Forwarded-For headers to prevent leakage
|
|
134
152
|
# Replace with masked IP so proxy resolution logic finds the masked IP
|
|
@@ -141,6 +159,45 @@ class Otto
|
|
|
141
159
|
end
|
|
142
160
|
|
|
143
161
|
|
|
162
|
+
# Set or clear a Rack env header in a SPEC-compliant way.
|
|
163
|
+
#
|
|
164
|
+
# CGI-style keys (those without a period) must hold String values per
|
|
165
|
+
# the Rack SPEC; a present-but-nil value trips Rack::Lint. So when the
|
|
166
|
+
# anonymized replacement is nil, delete the key entirely instead of
|
|
167
|
+
# assigning nil — semantically identical to "cleared" for downstream
|
|
168
|
+
# readers, and SPEC-compliant.
|
|
169
|
+
#
|
|
170
|
+
# @param env [Hash] Rack environment
|
|
171
|
+
# @param key [String] Env key to set or delete
|
|
172
|
+
# @param value [String, nil] Replacement value, or nil to clear the key
|
|
173
|
+
def replace_or_delete(env, key, value)
|
|
174
|
+
if value.nil?
|
|
175
|
+
env.delete(key)
|
|
176
|
+
else
|
|
177
|
+
env[key] = value
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Redact the request's sensitive non-IP headers in place.
|
|
182
|
+
#
|
|
183
|
+
# User-Agent and Referer carry identifying information independent of
|
|
184
|
+
# the client IP, so they are scrubbed on every privacy-enabled request
|
|
185
|
+
# — including ones with no resolvable IP, where IP masking is skipped.
|
|
186
|
+
# Each header is replaced with the fingerprint's anonymized value, or
|
|
187
|
+
# DELETED when that value is nil (no/empty header): CGI-style keys must
|
|
188
|
+
# hold String values per the Rack SPEC, so a present-but-nil
|
|
189
|
+
# HTTP_USER_AGENT/HTTP_REFERER would trip Rack::Lint (issue #167).
|
|
190
|
+
# Deleting is also marginally more private — an absent header is
|
|
191
|
+
# indistinguishable from one that was never sent.
|
|
192
|
+
#
|
|
193
|
+
# @param env [Hash] Rack environment
|
|
194
|
+
# @param fingerprint [Otto::Privacy::RedactedFingerprint] source of the
|
|
195
|
+
# anonymized header values
|
|
196
|
+
def scrub_sensitive_headers(env, fingerprint)
|
|
197
|
+
replace_or_delete(env, 'HTTP_USER_AGENT', fingerprint.anonymized_ua)
|
|
198
|
+
replace_or_delete(env, 'HTTP_REFERER', fingerprint.referer)
|
|
199
|
+
end
|
|
200
|
+
|
|
144
201
|
# Resolve the actual client IP address from the request.
|
|
145
202
|
#
|
|
146
203
|
# Delegates to the shared Otto::Utils.resolve_client_ip so the
|
|
@@ -162,6 +219,14 @@ class Otto
|
|
|
162
219
|
# @param env [Hash] Rack environment
|
|
163
220
|
# @param masked_ip [String] The masked IP to use as replacement
|
|
164
221
|
def mask_forwarded_headers(env, masked_ip)
|
|
222
|
+
# Defensive: never write a nil replacement into these CGI-style headers
|
|
223
|
+
# (the Rack SPEC requires String values; a nil trips Rack::Lint — see
|
|
224
|
+
# issue #167). apply_privacy's early "no client IP" guard already
|
|
225
|
+
# guarantees a non-nil masked_ip here, but keep this method
|
|
226
|
+
# self-contained so a future caller change can't reintroduce a
|
|
227
|
+
# present-but-nil HTTP_X_FORWARDED_FOR.
|
|
228
|
+
return if masked_ip.nil?
|
|
229
|
+
|
|
165
230
|
# Replace X-Forwarded-For with masked IP
|
|
166
231
|
# This prevents Rack::Request#ip from finding the real IP
|
|
167
232
|
env['HTTP_X_FORWARDED_FOR'] = masked_ip if env['HTTP_X_FORWARDED_FOR']
|
data/lib/otto/security.rb
CHANGED
|
@@ -12,3 +12,4 @@ require_relative 'security/middleware/csrf_middleware'
|
|
|
12
12
|
require_relative 'security/middleware/validation_middleware'
|
|
13
13
|
require_relative 'security/middleware/rate_limit_middleware'
|
|
14
14
|
require_relative 'security/middleware/ip_privacy_middleware'
|
|
15
|
+
require_relative 'security/csp'
|
data/lib/otto/utils.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'ipaddr'
|
|
6
|
+
require 'rack/utils'
|
|
6
7
|
|
|
7
8
|
class Otto
|
|
8
9
|
# Utility methods for common operations and helpers
|
|
@@ -57,6 +58,41 @@ class Otto
|
|
|
57
58
|
!value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
# Canonical path normalization for literal route matching: URL-unescape,
|
|
62
|
+
# scrub invalid/undefined bytes, and strip a single trailing slash.
|
|
63
|
+
#
|
|
64
|
+
# This is the SINGLE SOURCE OF TRUTH shared by the router
|
|
65
|
+
# (Otto::Core::Router#handle_request, which compares the result against its
|
|
66
|
+
# literal-route table) and Otto::CaddyTLS::LocalhostGuard (which compares it
|
|
67
|
+
# against the guarded endpoint). The two MUST normalize identically: if a
|
|
68
|
+
# crafted path — a trailing slash, a percent-encoded byte, an invalid UTF-8
|
|
69
|
+
# byte — normalized differently in the guard than in the router, the router
|
|
70
|
+
# could dispatch a request the guard let through, bypassing the loopback
|
|
71
|
+
# check. One implementation makes that drift impossible.
|
|
72
|
+
#
|
|
73
|
+
# Mirrors the empty-path handling and :replace scrubbing the router applies.
|
|
74
|
+
# Robust to invalid input: Rack::Utils.unescape raises ArgumentError on an
|
|
75
|
+
# already-invalid byte sequence (a raw \xFF in the path), so that is caught
|
|
76
|
+
# and the raw string is scrubbed instead — a percent-encoded invalid byte
|
|
77
|
+
# (%FF) decodes to the same invalid byte and is scrubbed identically, so the
|
|
78
|
+
# two crafted forms normalize alike. The method itself does not raise.
|
|
79
|
+
#
|
|
80
|
+
# @param raw_path [String, nil] a raw PATH_INFO or a configured endpoint
|
|
81
|
+
# @return [String] normalized path suitable for exact literal comparison
|
|
82
|
+
def normalize_path(raw_path)
|
|
83
|
+
raw = raw_path.to_s
|
|
84
|
+
decoded =
|
|
85
|
+
begin
|
|
86
|
+
Rack::Utils.unescape(raw)
|
|
87
|
+
rescue ArgumentError
|
|
88
|
+
raw
|
|
89
|
+
end
|
|
90
|
+
decoded = '/' if decoded.empty?
|
|
91
|
+
decoded
|
|
92
|
+
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
93
|
+
.gsub(%r{/$}, '')
|
|
94
|
+
end
|
|
95
|
+
|
|
60
96
|
# Validate and normalize an IP address (IPv4 and IPv6).
|
|
61
97
|
#
|
|
62
98
|
# Strips an optional port (IPv6-safe), validates with IPAddr, and returns
|
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.4.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-01 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,10 @@ 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/parser.rb
|
|
298
|
+
- lib/otto/security/csp/report.rb
|
|
299
|
+
- lib/otto/security/csp/report_middleware.rb
|
|
282
300
|
- lib/otto/security/csrf.rb
|
|
283
301
|
- lib/otto/security/middleware/csrf_middleware.rb
|
|
284
302
|
- lib/otto/security/middleware/ip_privacy_middleware.rb
|
|
@@ -298,6 +316,7 @@ licenses:
|
|
|
298
316
|
- MIT
|
|
299
317
|
metadata:
|
|
300
318
|
rubygems_mfa_required: 'true'
|
|
319
|
+
post_install_message:
|
|
301
320
|
rdoc_options: []
|
|
302
321
|
require_paths:
|
|
303
322
|
- lib
|
|
@@ -315,7 +334,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
315
334
|
- !ruby/object:Gem::Version
|
|
316
335
|
version: '0'
|
|
317
336
|
requirements: []
|
|
318
|
-
rubygems_version: 3.
|
|
337
|
+
rubygems_version: 3.5.22
|
|
338
|
+
signing_key:
|
|
319
339
|
specification_version: 4
|
|
320
340
|
summary: Define your rack-apps in plaintext.
|
|
321
341
|
test_files: []
|