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.
Files changed (41) 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 +59 -0
  12. data/Gemfile +4 -2
  13. data/Gemfile.lock +23 -17
  14. data/README.md +96 -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 +39 -5
  28. data/lib/otto/core/router.rb +4 -8
  29. data/lib/otto/security/config.rb +227 -2
  30. data/lib/otto/security/configurator.rb +38 -0
  31. data/lib/otto/security/core.rb +62 -0
  32. data/lib/otto/security/csp/parser.rb +120 -0
  33. data/lib/otto/security/csp/report.rb +147 -0
  34. data/lib/otto/security/csp/report_middleware.rb +120 -0
  35. data/lib/otto/security/csp.rb +19 -0
  36. data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
  37. data/lib/otto/security.rb +1 -0
  38. data/lib/otto/utils.rb +36 -0
  39. data/lib/otto/version.rb +1 -1
  40. data/lib/otto.rb +26 -3
  41. metadata +23 -3
@@ -0,0 +1,38 @@
1
+ # examples/caddy_tls_demo/standalone.ru
2
+ #
3
+ # STRONGEST isolation for the Caddy on-demand TLS permission endpoint: run it as
4
+ # its own tiny Otto app, bound to a dedicated loopback-only port, physically
5
+ # separate from your public-facing application. This is the topology used in
6
+ # production (cf. the OneTimeSecret "Internal ACME" app the pilot absorbs).
7
+ #
8
+ # This is ALSO how you support Caddy and the app running on DIFFERENT hosts: run
9
+ # this tiny app on the Caddy host (Caddy -> endpoint stays loopback), and let the
10
+ # permission block below reach your real domain data over your own authenticated
11
+ # channel (internal API, shared DB, cache). The loopback guard never has to trust
12
+ # a cross-host source IP.
13
+ #
14
+ # Because this process listens only on 127.0.0.1 and serves *nothing else*, the
15
+ # endpoint is unreachable from outside the host by construction; the localhost
16
+ # guard is then a second layer, not the only one.
17
+ #
18
+ # Run it on a dedicated loopback port:
19
+ #
20
+ # rackup examples/caddy_tls_demo/standalone.ru -o 127.0.0.1 -p 12020
21
+ #
22
+ # Point Caddy at that port (config-only; `ask` and `permission http` are equivalent):
23
+ #
24
+ # on_demand_tls {
25
+ # permission http { endpoint http://127.0.0.1:12020/_caddy/tls-permission }
26
+ # }
27
+
28
+ require_relative '../../lib/otto'
29
+ require_relative 'app'
30
+
31
+ # No routes file: this app serves ONLY the permission endpoint.
32
+ acme = Otto.new
33
+
34
+ acme.enable_caddy_tls! do |domain|
35
+ DomainDirectory.allowed?(domain)
36
+ end
37
+
38
+ run acme
@@ -0,0 +1,74 @@
1
+ # lib/otto/caddy_tls/core.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ # Otto::CaddyTLS is a modular, opt-in integration for Caddy's on-demand TLS
7
+ # permission endpoint — the HTTP question Caddy asks a backend before it
8
+ # obtains or loads a certificate on demand: "may I serve TLS for this host?".
9
+ # The contract is a single GET endpoint with +?domain=<host>+ appended; HTTP
10
+ # 200 means allow, any non-2xx means deny. One endpoint serves BOTH the
11
+ # deprecated +ask+ directive and its replacement, the +permission http+ module
12
+ # (their HTTP contracts are identical, so migrating is config-only on Caddy's
13
+ # side):
14
+ #
15
+ # on_demand_tls {
16
+ # permission http { endpoint http://127.0.0.1:PORT/_caddy/tls-permission }
17
+ # }
18
+ # # legacy / deprecated, same endpoint:
19
+ # on_demand_tls { ask http://127.0.0.1:PORT/_caddy/tls-permission }
20
+ #
21
+ # Otto owns all the HTTP ceremony (routing, localhost-only guard, blank-domain
22
+ # handling, fail-closed decision, response semantics). The app owns exactly one
23
+ # thing — the domain decision — supplied as a block to +enable_caddy_tls!+.
24
+ #
25
+ # It is structured like Otto::MCP: a self-contained, top-level namespace loaded
26
+ # eagerly but inert until +enable_caddy_tls!+ is called, rather than an
27
+ # always-on concern like Otto::Security / Otto::Privacy. Each such integration
28
+ # gets its own feature-named home (cf. Otto::MCP, Otto::Security::CSP) — Otto
29
+ # deliberately has no generic "services" bucket; genuinely shared mechanism
30
+ # (e.g. the +:outermost+ middleware position) lives in Otto::Core instead.
31
+ module CaddyTLS
32
+ # Public API mixin included into the Otto class. Mirrors Otto::MCP::Core.
33
+ module Core
34
+ # Enable the Caddy on-demand TLS permission endpoint.
35
+ #
36
+ # Registers a GET endpoint that answers Caddy's on-demand certificate
37
+ # question. The block you pass is the ONLY application coupling point: it
38
+ # receives the requested domain and returns truthy to allow a certificate
39
+ # (HTTP 200) or falsy to deny (HTTP 403). Any exception raised inside the
40
+ # block is caught and treated as a denial (fail-closed).
41
+ #
42
+ # @param endpoint [String] path to serve (default '/_caddy/tls-permission')
43
+ # @param localhost_only [Boolean] install the loopback-only guard (default true).
44
+ # Passing false removes the only built-in access control — do so only when the
45
+ # endpoint is isolated at the network layer; a warning is logged when disabled.
46
+ # @yieldparam domain [String] the domain Caddy is asking about
47
+ # @yieldreturn [Boolean] truthy to allow (200), falsy to deny (403)
48
+ # @return [self]
49
+ # @raise [ArgumentError] if no permission block is given (no allow-all default)
50
+ # @raise [FrozenError] if called after configuration is frozen
51
+ #
52
+ # @example
53
+ # otto = Otto.new('routes.txt')
54
+ # otto.enable_caddy_tls! do |domain|
55
+ # MyApp::CustomDomain.verified?(domain)
56
+ # end
57
+ def enable_caddy_tls!(endpoint: '/_caddy/tls-permission', localhost_only: true, &permission)
58
+ ensure_not_frozen!
59
+ raise ArgumentError, 'enable_caddy_tls! requires a permission block' unless block_given?
60
+
61
+ @caddy_tls_server ||= Otto::CaddyTLS::Server.new(self)
62
+ @caddy_tls_server.enable!(endpoint: endpoint, localhost_only: localhost_only, permission: permission)
63
+ Otto.logger.info '[CaddyTLS] Enabled Caddy on-demand TLS permission endpoint' if Otto.debug
64
+
65
+ self
66
+ end
67
+
68
+ # @return [Boolean] whether the Caddy on-demand TLS endpoint is enabled
69
+ def caddy_tls_enabled?
70
+ @caddy_tls_server&.enabled? || false
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,158 @@
1
+ # lib/otto/caddy_tls/localhost_guard.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'ipaddr'
6
+
7
+ require_relative '../utils'
8
+
9
+ class Otto
10
+ module CaddyTLS
11
+ # Path-scoped Rack middleware that only allows requests to a single
12
+ # endpoint when they originate from the loopback interface.
13
+ #
14
+ # Introduced for the Caddy on-demand TLS endpoint but written to be generic:
15
+ # it protects any single endpoint whose only legitimate caller is a
16
+ # co-located process (a reverse proxy's control-plane callback). Pass it the
17
+ # endpoint path to protect, and it 401s any request to that path whose
18
+ # connecting peer is not loopback. Every other path passes straight through,
19
+ # so installing it never affects the rest of the application. (It lives under
20
+ # Otto::CaddyTLS while it has a single consumer; promote it to a shared home
21
+ # if a second internal-only integration ever needs it.)
22
+ #
23
+ # == Security: authenticate the RAW peer, not the resolved client IP
24
+ #
25
+ # The guard reads the ORIGINAL +env['REMOTE_ADDR']+ — the TCP socket peer —
26
+ # and MUST run before +IPPrivacyMiddleware+ rewrites +REMOTE_ADDR+ from
27
+ # forwarded headers. Installed via +Otto#use+ (appended, hence outermost in
28
+ # the reduce-built stack) it always executes ahead of +IPPrivacyMiddleware+
29
+ # (which is pinned innermost), so it inspects the true socket peer.
30
+ #
31
+ # Reading Otto's resolved +otto.client_ip+ (or the rewritten +REMOTE_ADDR+)
32
+ # would be exploitable: a co-located reverse proxy on loopback is itself a
33
+ # natural trusted proxy, so an attacker who could reach the endpoint through
34
+ # it and send +X-Forwarded-For: 127.0.0.1+ would be promoted to "localhost".
35
+ # Authenticating the raw peer removes forwarded headers from the trust
36
+ # decision entirely.
37
+ #
38
+ # == What "a direct local call" means
39
+ #
40
+ # The endpoint's only legitimate caller is the co-located service making a
41
+ # *direct* request over the loopback interface. Two things must both hold:
42
+ #
43
+ # 1. The socket peer (+REMOTE_ADDR+) is loopback.
44
+ # 2. The request carries NO forwarding headers. Caddy's on-demand permission
45
+ # request is a direct backend call and sends none; a request that was
46
+ # *relayed through a reverse proxy* carries +X-Forwarded-For+ (or a
47
+ # sibling). Rejecting those is what makes the guard safe even when the
48
+ # endpoint is accidentally mounted inside a public app behind a proxy that
49
+ # connects to the backend over loopback — there, every proxied request has
50
+ # a loopback peer, but it also carries a forwarding header, so it is
51
+ # denied.
52
+ #
53
+ # == Deployment assumption
54
+ #
55
+ # The guard trusts that +REMOTE_ADDR+ is the real socket peer and that a
56
+ # trusted layer has not stripped forwarding headers before Otto sees them.
57
+ # The strongest isolation is still network-level: bind the endpoint on a
58
+ # dedicated loopback-only port that the proxy reaches directly (see
59
+ # examples/caddy_tls_demo/standalone.ru). Blocking the endpoint path at the
60
+ # proxy is a sound additional layer. See docs/reverse-proxy-network-services.md.
61
+ class LocalhostGuard
62
+ # Forwarding headers whose presence means the request was relayed by a
63
+ # proxy rather than issued directly. Any one present => not a direct local
64
+ # call. Mirrors Otto::Utils::FORWARDED_FOR_HEADERS plus RFC 7239 Forwarded.
65
+ FORWARDED_HEADERS = %w[
66
+ HTTP_X_FORWARDED_FOR
67
+ HTTP_X_REAL_IP
68
+ HTTP_X_CLIENT_IP
69
+ HTTP_FORWARDED
70
+ ].freeze
71
+
72
+ # @param app [#call] the downstream Rack app
73
+ # @param endpoint [String] the path to protect (e.g. '/_caddy/tls-permission')
74
+ def initialize(app, endpoint)
75
+ @app = app
76
+ @endpoint = normalize_path(endpoint)
77
+ end
78
+
79
+ # @param env [Hash] Rack environment
80
+ # @return [Array] Rack response tuple
81
+ def call(env)
82
+ return @app.call(env) unless targets_endpoint?(env)
83
+ return deny unless direct_local_call?(env)
84
+
85
+ @app.call(env)
86
+ end
87
+
88
+ private
89
+
90
+ # A direct local call: loopback socket peer AND no forwarding headers.
91
+ #
92
+ # @param env [Hash] Rack environment
93
+ # @return [Boolean]
94
+ def direct_local_call?(env)
95
+ loopback_peer?(env['REMOTE_ADDR']) && !relayed?(env)
96
+ end
97
+
98
+ # Whether any forwarding header is present (request came via a proxy).
99
+ #
100
+ # @param env [Hash] Rack environment
101
+ # @return [Boolean]
102
+ def relayed?(env)
103
+ FORWARDED_HEADERS.any? { |header| !env[header].to_s.strip.empty? }
104
+ end
105
+
106
+ # Whether this request is for the protected endpoint. Normalizes
107
+ # +PATH_INFO+ through the same +Otto::Utils.normalize_path+ the router
108
+ # uses for literal matching, so a percent-encoded, invalid-byte, or
109
+ # trailing-slash variant the router would still route cannot slip past the
110
+ # guard by normalizing differently here than at dispatch.
111
+ #
112
+ # @param env [Hash] Rack environment
113
+ # @return [Boolean]
114
+ def targets_endpoint?(env)
115
+ normalize_path(env['PATH_INFO']) == @endpoint
116
+ end
117
+
118
+ # Router-equivalent path normalization. Delegates to the single shared
119
+ # implementation so the guard and the router cannot drift (see
120
+ # Otto::Utils.normalize_path).
121
+ #
122
+ # @param path [String, nil]
123
+ # @return [String] normalized path
124
+ def normalize_path(path)
125
+ Otto::Utils.normalize_path(path)
126
+ end
127
+
128
+ # Whether the connecting peer is a loopback address. Fails closed: a
129
+ # blank or otherwise unparseable value is treated as non-loopback
130
+ # (denied) rather than raising on the hot path.
131
+ #
132
+ # +.native+ folds IPv4-mapped IPv6 (+::ffff:127.0.0.1+, which dual-stack
133
+ # servers commonly present) so it is correctly recognized as loopback;
134
+ # plain +IPAddr#loopback?+ returns false for the mapped form.
135
+ #
136
+ # A conforming Rack server sets +REMOTE_ADDR+ to a bare IP (the peer's
137
+ # port lives in +REMOTE_PORT+). We deliberately do NOT strip a +:port+
138
+ # suffix here: an unexpected format is a signal something upstream is
139
+ # non-standard, so denying (fail-closed) is safer than coercing it.
140
+ #
141
+ # @param remote_addr [String, nil] the raw socket peer address
142
+ # @return [Boolean]
143
+ def loopback_peer?(remote_addr)
144
+ addr = remote_addr.to_s.strip
145
+ return false if addr.empty?
146
+
147
+ IPAddr.new(addr).native.loopback?
148
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
149
+ false
150
+ end
151
+
152
+ # @return [Array] 401 Rack response tuple
153
+ def deny
154
+ [401, { 'content-type' => 'text/plain' }, ['Unauthorized']]
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,149 @@
1
+ # lib/otto/caddy_tls/server.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../route'
6
+ require_relative '../utils'
7
+ require_relative 'localhost_guard'
8
+
9
+ class Otto
10
+ module CaddyTLS
11
+ # Registers the permission route and (by default) the localhost guard,
12
+ # and wraps the app-supplied decision block with fail-closed semantics.
13
+ #
14
+ # Mirrors +Otto::MCP::Server+: a small per-integration object that owns
15
+ # its route and middleware registration and is referenced by its handler.
16
+ class Server
17
+ # @return [Otto] the owning Otto instance
18
+ attr_reader :otto
19
+
20
+ # @return [String, nil] the registered endpoint path
21
+ attr_reader :endpoint
22
+
23
+ # @param otto [Otto] the owning Otto instance
24
+ def initialize(otto)
25
+ @otto = otto
26
+ @enabled = false
27
+ end
28
+
29
+ # @return [Boolean]
30
+ def enabled?
31
+ @enabled
32
+ end
33
+
34
+ # Enable the integration. Idempotent: a second call is ignored so the
35
+ # route and guard are never duplicated.
36
+ #
37
+ # @param endpoint [String] path to serve (registered programmatically)
38
+ # @param localhost_only [Boolean] install the loopback guard (default true)
39
+ # @param permission [#call] block receiving the domain, returning truthy to allow
40
+ # @return [void]
41
+ def enable!(endpoint:, localhost_only:, permission:)
42
+ return if @enabled
43
+
44
+ # Normalize once so the guard's endpoint, the router's literal-route
45
+ # key, and @endpoint all agree. Without this, a configured trailing
46
+ # slash (or percent-encoding) registers a literal route the router can
47
+ # never match — requests are normalized before lookup — while the guard
48
+ # still targets it, so Caddy would get a denial instead of a decision.
49
+ endpoint = Otto::Utils.normalize_path(endpoint)
50
+
51
+ @endpoint = endpoint
52
+ @permission = permission
53
+ @localhost_only = localhost_only
54
+ @enabled = true
55
+
56
+ register_route(endpoint)
57
+
58
+ if localhost_only
59
+ # SECURITY: appended (via #use) so it is OUTERMOST in the stack and
60
+ # runs BEFORE IPPrivacyMiddleware — the guard must see the raw socket
61
+ # peer, not the forwarded-header-resolved client IP. See LocalhostGuard.
62
+ @otto.use(Otto::CaddyTLS::LocalhostGuard, endpoint)
63
+ else
64
+ # Explicit opt-out: the endpoint then has no built-in access control,
65
+ # so it must be isolated at the network layer. Surface that at setup.
66
+ Otto.structured_log(:warn,
67
+ '[CaddyTLS] localhost guard disabled (localhost_only: false); endpoint is ' \
68
+ 'reachable by any client that can reach this app — ensure network-level isolation',
69
+ endpoint: endpoint)
70
+ end
71
+
72
+ # structured_log self-skips :debug unless Otto.debug is set.
73
+ Otto.structured_log(:debug, '[CaddyTLS] enabled',
74
+ endpoint: endpoint, localhost_only: localhost_only)
75
+ end
76
+
77
+ # Fail-closed decision wrapper. Any exception, +nil+, or +false+ from the
78
+ # app block denies — a broken decision must never authorize a cert.
79
+ #
80
+ # @param domain [String] the domain Caddy is asking about
81
+ # @return [Boolean] true to allow (200), false to deny (403)
82
+ def permit?(domain)
83
+ !!@permission.call(domain)
84
+ rescue StandardError => e
85
+ Otto.structured_log(:error, '[CaddyTLS] permission callback raised; denying',
86
+ domain: domain, error: e.message, error_class: e.class.name)
87
+ false
88
+ end
89
+
90
+ private
91
+
92
+ # Register the GET route programmatically (like MCP's /_mcp endpoint),
93
+ # so enabling is purely code-side with no routes.txt entry required.
94
+ #
95
+ # @param endpoint [String]
96
+ # @return [void]
97
+ def register_route(endpoint)
98
+ route = Otto::Route.new('GET', endpoint, 'Otto::CaddyTLS::PermissionHandler.handle')
99
+ route.otto = @otto
100
+
101
+ (@otto.routes[:GET] ||= []) << route
102
+ (@otto.routes_literal[:GET] ||= {})[endpoint] = route
103
+ end
104
+ end
105
+
106
+ # Class-method route handler for the permission endpoint.
107
+ #
108
+ # The owning Server is resolved per-request from the Otto instance the
109
+ # dispatcher binds to this class (+Otto::Route::ClassMethods#otto+), NOT a
110
+ # class-level global. That keeps multiple Otto instances in one process
111
+ # isolated: each endpoint consults its own permission block. (This shares
112
+ # the same per-request class-accessor mechanism the rest of Otto uses for
113
+ # class-method handlers.)
114
+ class PermissionHandler
115
+ # Handle a permission request. Only +?domain=+ is consulted — no other
116
+ # query parameter reaches the decision. A non-string +domain+ (e.g.
117
+ # +?domain[]=a+) is treated as missing rather than coerced.
118
+ #
119
+ # @param req [Otto::Request]
120
+ # @param res [Otto::Response]
121
+ # @return [Otto::Response]
122
+ def self.handle(req, res)
123
+ raw = req.params['domain']
124
+ domain = raw.is_a?(String) ? raw.strip : ''
125
+
126
+ return respond(req, res, 400, 'Bad Request - domain parameter required') if domain.empty?
127
+
128
+ server = respond_to?(:otto) ? otto&.caddy_tls_server : nil
129
+ allowed = server ? server.permit?(domain) : false
130
+ Otto.structured_log(:info, '[CaddyTLS] permission decision', domain: domain, allowed: allowed)
131
+
132
+ respond(req, res, allowed ? 200 : 403, allowed ? 'OK' : 'Forbidden')
133
+ end
134
+
135
+ # @param req [Otto::Request]
136
+ # @param res [Otto::Response]
137
+ # @param status [Integer]
138
+ # @param body [String]
139
+ # @return [Otto::Response]
140
+ def self.respond(req, res, status, body)
141
+ res.status = status
142
+ res['content-type'] = 'text/plain'
143
+ # HEAD must carry no body (Rack SPEC / Rack::Lint); headers still apply.
144
+ res.body = req.head? ? [] : [body]
145
+ res
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,7 @@
1
+ # lib/otto/caddy_tls.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'caddy_tls/core'
6
+ require_relative 'caddy_tls/localhost_guard'
7
+ require_relative 'caddy_tls/server'
@@ -35,13 +35,12 @@ class Otto
35
35
  # @param args Additional arguments passed to middleware constructor
36
36
  def use(middleware, ...)
37
37
  ensure_not_frozen!
38
+ # @middleware.add fires the stack's on_change callback, which rebuilds
39
+ # @app when it already exists (wired in Otto#initialize_core_state). A
40
+ # rebuild triggered during a live request could swap @app mid-flight
41
+ # under multi-threaded serving; configure middleware before the first
42
+ # request (the lazy configuration freeze enforces this in production).
38
43
  @middleware.add(middleware, ...)
39
-
40
- # NOTE: If build_app! is triggered during a request (via use() or
41
- # middleware_stack=), the @app instance variable could be swapped
42
- # mid-request in a multi-threaded environment.
43
-
44
- build_app! if @app # Rebuild app if already initialized
45
44
  end
46
45
 
47
46
  # Compatibility method for existing tests
@@ -53,9 +52,10 @@ class Otto
53
52
  # Compatibility method for existing tests
54
53
  # @param stack [Array] Array of middleware classes
55
54
  def middleware_stack=(stack)
55
+ # clear! and each add fire the stack's on_change callback, which keeps
56
+ # @app in sync (wired in Otto#initialize_core_state).
56
57
  @middleware.clear!
57
58
  Array(stack).each { |middleware| @middleware.add(middleware) }
58
- build_app! if @app # Rebuild app if already initialized
59
59
  end
60
60
 
61
61
  # Check if a specific middleware is enabled
@@ -16,6 +16,9 @@ class Otto
16
16
  def initialize
17
17
  @stack = []
18
18
  @middleware_set = Set.new
19
+ # Classes pinned to run OUTERMOST regardless of insertion order (see
20
+ # the :outermost position in #add_with_position and #ordered_stack).
21
+ @outermost = Set.new
19
22
  @on_change_callback = nil
20
23
  end
21
24
 
@@ -49,10 +52,20 @@ class Otto
49
52
 
50
53
  # Add middleware with position hint for optimal ordering
51
54
  #
55
+ # Positions:
56
+ # - :first — innermost (runs last, closest to the app)
57
+ # - :last/nil — append (outermost among currently-registered middleware,
58
+ # but a later append displaces it)
59
+ # - :outermost — pin to run OUTERMOST (first to see the request) and STAY
60
+ # there even if more middleware is appended afterward. Unlike
61
+ # :last, this is order-independent: honored in #ordered_stack
62
+ # at build time. Use for middleware that must short-circuit
63
+ # ahead of everything else (e.g. the CSP report receiver,
64
+ # which must intercept before CSRF).
65
+ #
52
66
  # @param middleware_class [Class] Middleware class
53
67
  # @param args [Array] Middleware arguments
54
- # @param position [Symbol, nil] Position hint (:first, :last, or nil for append)
55
- # @option options [Symbol] :position Position hint (:first or :last)
68
+ # @param position [Symbol, nil] Position hint (:first, :last, :outermost, or nil)
56
69
  def add_with_position(middleware_class, *args, position: nil, **options)
57
70
  raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
58
71
 
@@ -70,10 +83,11 @@ class Otto
70
83
  case position
71
84
  when :first
72
85
  @stack.unshift(entry)
73
- when :last
86
+ when :outermost
74
87
  @stack << entry
88
+ @outermost.add(middleware_class)
75
89
  else
76
- @stack << entry # Default append
90
+ @stack << entry # :last / nil — default append
77
91
  end
78
92
 
79
93
  @middleware_set.add(middleware_class)
@@ -148,6 +162,7 @@ class Otto
148
162
 
149
163
  # Rebuild the set of unique middleware classes
150
164
  @middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
165
+ @outermost.delete(middleware_class)
151
166
  # Notify of change
152
167
  @on_change_callback&.call
153
168
  end
@@ -164,6 +179,7 @@ class Otto
164
179
 
165
180
  @stack.clear
166
181
  @middleware_set.clear
182
+ @outermost.clear
167
183
  # Notify of change
168
184
  @on_change_callback&.call
169
185
  end
@@ -174,8 +190,13 @@ class Otto
174
190
  end
175
191
 
176
192
  # Build Rack application with middleware chain
193
+ #
194
+ # The stack folds via reduce, so the LAST entry becomes the OUTERMOST
195
+ # wrapper (first to see the request). #ordered_stack moves any :outermost-
196
+ # pinned middleware to the end so it stays outermost regardless of the
197
+ # order middleware was registered in.
177
198
  def wrap(base_app, security_config = nil)
178
- @stack.reduce(base_app) do |app, entry|
199
+ ordered_stack.reduce(base_app) do |app, entry|
179
200
  middleware = entry[:middleware]
180
201
  args = entry[:args]
181
202
  options = entry[:options]
@@ -233,6 +254,18 @@ class Otto
233
254
 
234
255
  private
235
256
 
257
+ # The stack ordered for #wrap: identical to @stack unless some middleware
258
+ # is pinned :outermost, in which case pinned entries are moved to the end
259
+ # (outermost) while preserving the relative order of both groups. Returns
260
+ # @stack itself (no copy) in the common no-pin case, so ordinary apps are
261
+ # completely unaffected.
262
+ def ordered_stack
263
+ return @stack if @outermost.empty?
264
+
265
+ pinned, rest = @stack.partition { |entry| @outermost.include?(entry[:middleware]) }
266
+ rest + pinned
267
+ end
268
+
236
269
  def middleware_needs_config?(middleware_class)
237
270
  # Include all Otto security middleware that can accept security_config
238
271
  # Support both new namespaced classes and backward compatibility aliases
@@ -241,6 +274,7 @@ class Otto
241
274
  Otto::Security::Middleware::ValidationMiddleware,
242
275
  Otto::Security::Middleware::RateLimitMiddleware,
243
276
  Otto::Security::Middleware::IPPrivacyMiddleware,
277
+ Otto::Security::CSP::ReportMiddleware,
244
278
  ].include?(middleware_class)
245
279
  end
246
280
  end
@@ -72,14 +72,10 @@ class Otto
72
72
  path_info = '/' if path_info.to_s.empty?
73
73
 
74
74
  begin
75
- path_info_clean = path_info
76
- .encode(
77
- 'UTF-8', # Target encoding
78
- invalid: :replace, # Replace invalid byte sequences
79
- undef: :replace, # Replace characters undefined in UTF-8
80
- replace: '' # Use empty string for replacement
81
- )
82
- .gsub(%r{/$}, '') # Remove trailing slash, if present
75
+ # Shared with Otto::CaddyTLS::LocalhostGuard so the guard and the
76
+ # router cannot normalize a path differently (which would be a guard
77
+ # bypass). See Otto::Utils.normalize_path.
78
+ path_info_clean = Otto::Utils.normalize_path(env['PATH_INFO'])
83
79
  rescue ArgumentError => e
84
80
  # Log the error but don't expose details
85
81
  Otto.logger.error '[Otto.handle_request] Path encoding error'