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,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
|
|
@@ -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
|
|
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 :
|
|
86
|
+
when :outermost
|
|
74
87
|
@stack << entry
|
|
88
|
+
@outermost.add(middleware_class)
|
|
75
89
|
else
|
|
76
|
-
@stack << entry #
|
|
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
|
-
|
|
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
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -72,14 +72,10 @@ class Otto
|
|
|
72
72
|
path_info = '/' if path_info.to_s.empty?
|
|
73
73
|
|
|
74
74
|
begin
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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'
|