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
data/README.md CHANGED
@@ -84,6 +84,65 @@ app = Otto.new("./routes", {
84
84
 
85
85
  Security features include CSRF protection, input validation, security headers, and trusted proxy configuration.
86
86
 
87
+ ### CSP Violation Reporting
88
+
89
+ Otto can both emit Content-Security-Policy headers and receive the violation
90
+ reports browsers post back. Point a policy at a report path and register a
91
+ callback — Otto handles the HTTP ceremony (parsing both wire formats, the size
92
+ cap, the CSRF bypass) and hands your callback a normalized report:
93
+
94
+ ```ruby
95
+ app = Otto.new("./routes")
96
+ app.enable_csp_with_nonce! # emit a nonce-based CSP (see send_csp_headers)
97
+
98
+ app.enable_csp_reporting!("/_/csp-report") do |report|
99
+ Otto.logger.warn("CSP violation: #{report.violated_directive} " \
100
+ "blocked #{report.blocked_uri}")
101
+ # report also exposes: document_uri, source_file, line_number,
102
+ # column_number, disposition, effective_directive, ... and report.to_h
103
+ end
104
+ ```
105
+
106
+ `enable_csp_reporting!` does three things:
107
+
108
+ 1. Appends a `report-uri /_/csp-report` directive to every emitted CSP policy —
109
+ both the static `enable_csp!` policy and the per-request nonce policy — so
110
+ browsers know where to send violations.
111
+ 2. Registers your callback, invoked once per violation with an
112
+ `Otto::Security::CSP::Report`.
113
+ 3. Injects `Otto::Security::CSP::ReportMiddleware`, pinned **outermost** in the
114
+ stack, which intercepts `POST`s to the report path, parses both the legacy
115
+ `application/csp-report` and the Reporting API `application/reports+json`
116
+ formats, enforces a 64 KiB body cap, and always answers `204 No Content` —
117
+ without touching your routes.
118
+
119
+ Because the middleware is pinned outermost, it short-circuits ahead of the CSRF
120
+ middleware, so browsers can POST reports without a CSRF token — regardless of the
121
+ order you enable security features in. A throwing callback can never break the
122
+ receiver; it still answers `204`.
123
+
124
+ Modern browsers (Chrome) have deprecated `report-uri` in favour of the Reporting
125
+ API. Pass `endpoint_url:` — an **absolute** URL whose path is the report path —
126
+ to also emit a `report-to` directive and a `Reporting-Endpoints` response header,
127
+ so those browsers deliver `application/reports+json` to the same receiver:
128
+
129
+ ```ruby
130
+ app.enable_csp_reporting!("/_/csp-report",
131
+ endpoint_url: "https://example.com/_/csp-report") do |report|
132
+ Otto.logger.warn("CSP violation: #{report.violated_directive}")
133
+ end
134
+ ```
135
+
136
+ The legacy `report-uri` is always kept alongside `report-to`, so older browsers
137
+ (Firefox, Safari) keep working. When `endpoint_url:` is omitted, output is
138
+ byte-identical to `report-uri`-only.
139
+
140
+ > [!IMPORTANT]
141
+ > Report URL fields (`document_uri`, `blocked_uri`, `referrer`, `source_file`)
142
+ > reflect the page the browser was on and may carry sensitive path/query data in
143
+ > some applications. Otto does **not** redact them — normalize/redact in your
144
+ > callback per your own privacy policy before logging or forwarding.
145
+
87
146
  ## Error Handling
88
147
 
89
148
  Otto provides base error classes that automatically return correct HTTP status codes:
@@ -176,6 +235,42 @@ end
176
235
 
177
236
  The locale helper checks multiple sources in order of precedence and validates against your configured locales.
178
237
 
238
+ ## Network Service Integrations
239
+
240
+ Otto ships small, opt-in integrations for endpoints that an external network
241
+ component (a reverse proxy, a TLS layer) calls over a fixed HTTP contract. Each is
242
+ a self-contained, feature-named module — loaded but inert until you enable it, like
243
+ `Otto::MCP`. The app supplies a small decision; Otto owns the routing, the security
244
+ guard, and the fail-safe behavior.
245
+
246
+ The first integration, `Otto::CaddyTLS`, answers **Caddy's on-demand TLS** question — "may I obtain a
247
+ certificate for this domain?":
248
+
249
+ ```ruby
250
+ otto = Otto.new('routes.txt')
251
+
252
+ otto.enable_caddy_tls! do |domain|
253
+ # The only app-specific part. Truthy => 200 (allow), falsy => 403 (deny).
254
+ # Any exception here is caught and denies (fail-closed).
255
+ MyApp::CustomDomain.verified?(domain)
256
+ end
257
+ ```
258
+
259
+ This serves `GET /_caddy/tls-permission?domain=<host>` and covers both Caddy's
260
+ deprecated `ask` directive and its replacement `permission http` module (identical
261
+ HTTP contract, so migration is config-only):
262
+
263
+ ```caddyfile
264
+ on_demand_tls {
265
+ permission http { endpoint http://127.0.0.1:PORT/_caddy/tls-permission }
266
+ }
267
+ ```
268
+
269
+ Secure by default: the endpoint is restricted to the loopback interface (the guard
270
+ authenticates the raw TCP peer, so a spoofed `X-Forwarded-For` cannot help), and
271
+ every layer fails closed. See [docs/reverse-proxy-network-services.md](docs/reverse-proxy-network-services.md)
272
+ for the design and deployment notes.
273
+
179
274
  ## Examples
180
275
 
181
276
  Otto includes comprehensive examples demonstrating different features:
@@ -185,6 +280,7 @@ Otto includes comprehensive examples demonstrating different features:
185
280
  - **[Authentication Strategies](examples/authentication_strategies/)** - Token, API key, and role-based authentication
186
281
  - **[Security Features](examples/security_features/)** - CSRF protection, input validation, file uploads, and security headers
187
282
  - **[MCP Demo](examples/mcp_demo/)** - JSON-RPC 2.0 endpoints for CLI automation and integrations
283
+ - **[Caddy on-demand TLS](examples/caddy_tls_demo/)** - Reverse-proxy permission endpoint via `Otto::CaddyTLS`
188
284
 
189
285
  ### Standalone Tutorials
190
286
 
data/docs/.gitignore CHANGED
@@ -5,3 +5,4 @@
5
5
  !ipaddr-encoding-quirk.md
6
6
  !modern-authentication-authorization-landscape.md
7
7
  !multi-strategy-authentication-design.md
8
+ !reverse-proxy-network-services.md
@@ -0,0 +1,358 @@
1
+ # Reverse-proxy / network-service integrations
2
+
3
+ **Status:** shipped pilot (issue [#175](https://github.com/delano/otto/issues/175))
4
+ **Ships:** `Otto::CaddyTLS` — Caddy on-demand TLS permission endpoint
5
+ **Date:** 2026-07
6
+
7
+ This document captures the design exploration for how Otto absorbs
8
+ *network-service integrations* — small, optional, turnkey endpoints where an
9
+ external network component (a reverse proxy, a TLS layer, a monitoring probe)
10
+ speaks a fixed HTTP contract to the app, and the app supplies a tiny decision or
11
+ handler while Otto owns all the HTTP ceremony.
12
+
13
+ The pilot absorbs OneTimeSecret's "Internal ACME" app — the endpoint Caddy calls
14
+ to decide whether to obtain a certificate on demand — into a reusable Otto
15
+ primitive, decoupled from any application's domain model.
16
+
17
+ > **Namespace note (resolved during review — see §4 and §7).** This work first
18
+ > shipped under an umbrella `Otto::Services` namespace, on the assumption that CSP
19
+ > reporting ([#174](https://github.com/delano/otto/issues/174)) would be its
20
+ > second tenant. When #174 actually landed it chose `Otto::Security::CSP` — it is
21
+ > the *receiving half* of Otto's own CSP support and belongs beside the emitting
22
+ > half (`Otto::Security::Config`), not in a generic bucket. With its presumed
23
+ > second tenant placed elsewhere, the umbrella had exactly one occupant, so it was
24
+ > collapsed to a feature-named `Otto::CaddyTLS`, matching the existing `Otto::MCP`
25
+ > precedent. "Making space" turned out to mean *a documented pattern plus shared
26
+ > primitives in `Otto::Core`* — not a shared namespace module.
27
+
28
+ ---
29
+
30
+ ## 1. Problem & goal
31
+
32
+ Apps that sit behind a reverse proxy repeatedly hand-roll the same small
33
+ endpoints:
34
+
35
+ - **Caddy on-demand TLS** asks the backend "may I get a cert for this host?"
36
+ before issuing. The backend must parse `?domain=`, return `200`/non-`2xx`,
37
+ restrict the caller to localhost, and fail closed on errors.
38
+ - **CSP violation reporting** ([#174](https://github.com/delano/otto/issues/174))
39
+ receives browser-posted violation reports, parsing two formats, bypassing
40
+ CSRF, capping the body, and never raising.
41
+
42
+ Each app rebuilds this ceremony — routing, method/size limits, response
43
+ semantics, security guard, fail-safe behavior — around one app-specific decision.
44
+ That repetition is exactly the kind of thing Otto already removes for MCP.
45
+
46
+ **Goal:** *make space* — establish the shared pattern for these integrations and
47
+ prove it with one concrete, well-decoupled pilot (Caddy on-demand TLS).
48
+ Explicitly **not** a goal: build a framework, a registry, or the CSP endpoint now.
49
+ The pattern should make CSP #174 a straightforward later sibling without being
50
+ designed around it.
51
+
52
+ ## 2. The pattern, and its two instances
53
+
54
+ | Aspect | Caddy on-demand TLS | CSP reporting (#174) |
55
+ |---|---|---|
56
+ | Direction | proxy → app (control plane) | browser → app (telemetry) |
57
+ | Verb / surface | `GET ?domain=` | `POST` JSON body |
58
+ | App's job | decide allow/deny for a domain | receive a normalized report |
59
+ | Otto's job | route, guard, fail-closed, `200`/`403` | intercept, CSRF-bypass, size-cap, parse, `204` |
60
+ | Caller trust | **loopback only** (co-located proxy) | **public** (any browser) |
61
+ | Coupling point | one decision block | one callback |
62
+ | Ties to an Otto subsystem | none (bridge to an external service) | **yes** — it's the receiving half of Otto's CSP header support |
63
+ | Home | `Otto::CaddyTLS` (top-level, like `Otto::MCP`) | `Otto::Security::CSP` (beside CSP emission) |
64
+
65
+ The common shape is *"a fixed external HTTP contract, an app-supplied
66
+ decision/handler, and Otto owning the ceremony."* That shape is the pattern worth
67
+ establishing. But the two instances differ on nearly everything else — trust
68
+ model, verb, consumer-API shape, and, decisively, **their relationship to existing
69
+ Otto subsystems**. Caddy TLS is a bridge to an external service (Caddy) and stands
70
+ alone; CSP reporting is one half of a capability Otto already owns. That is why
71
+ they get *separate feature-named homes* rather than a shared umbrella — and why the
72
+ only thing genuinely worth extracting up front is the reusable *mechanism* each
73
+ needs (the localhost guard here; the `:outermost` middleware position there),
74
+ not a namespace to file them both under.
75
+
76
+ ## 3. Options considered
77
+
78
+ Four architectures were explored (a design panel generated and adversarially
79
+ critiqued each):
80
+
81
+ 1. **MCP-style route-based primitive.** `enable_caddy_tls! { |domain| … }` lazily
82
+ builds a small `Server` that registers a `GET` route programmatically (like
83
+ `/_mcp`) and installs a localhost guard. *Pro:* mirrors the proven MCP
84
+ precedent exactly; lowest review risk; turnkey. *Con:* one new namespace.
85
+ 2. **Callback + config, interceptor middleware (CSP #174 style).** Configuration
86
+ is data (endpoint + callback); a Rack middleware intercepts the path and
87
+ short-circuits with `200`/`403`, no route entry. *Pro:* symmetric with how
88
+ #174 configures. *Con (fatal for this endpoint):* a short-circuiting
89
+ interceptor that sits ahead of the guard in the stack answers **before** the
90
+ guard runs — a fail-**open** exposure of the cert gate to remote clients.
91
+ (This is why CSP, which is *meant* to be public, correctly uses the interceptor
92
+ pattern, and Caddy TLS, which must be gated, correctly does not.)
93
+ 3. **Minimalist provided handler class.** Otto ships a handler you reference in
94
+ `routes.txt` plus a guard you `use` yourself. *Pro:* least magic. *Con:* the
95
+ operator must remember to wire the guard separately — a "forgot the guard"
96
+ footgun on a security-critical endpoint.
97
+ 4. **General network-services registry/base.** A shared `Endpoint` base or
98
+ registry that Caddy and CSP both plug into. *Con:* premature abstraction — only
99
+ one consumer differs (the guard), which doesn't justify a base class (YAGNI).
100
+ (#174 later confirmed this: it shared *no* base with Caddy TLS.)
101
+
102
+ ## 4. Recommendation
103
+
104
+ **Ship option 1 (MCP-style route-based primitive) as a top-level, feature-named
105
+ module `Otto::CaddyTLS`, with the localhost guard authenticating the raw TCP
106
+ peer.**
107
+
108
+ ```
109
+ Otto::CaddyTLS # opt-in integration, sibling to Otto::MCP
110
+ ├── CaddyTLS::Core # mixin on Otto: enable_caddy_tls!, caddy_tls_enabled?
111
+ ├── CaddyTLS::LocalhostGuard # opt-in, raw-peer loopback guard middleware
112
+ ├── CaddyTLS::Server # enable!/enabled?/permit? (fail-closed), route+guard registration
113
+ └── CaddyTLS::PermissionHandler # class-method handler: ?domain= → 400/200/403
114
+ ```
115
+
116
+ Rejected: the interceptor (fail-open risk here), routes.txt-first (guard footgun),
117
+ and the registry/base (premature).
118
+
119
+ **Why a feature-named module, not an `Otto::Services` umbrella.** The umbrella was
120
+ the initial choice, justified by CSP #174 being its second tenant. That premise did
121
+ not hold (§7): #174 shipped as `Otto::Security::CSP`. Three signals in the codebase
122
+ all point to a feature-named home instead:
123
+
124
+ - **`Otto::MCP`** — Otto's existing external-system integration is a *top-level,
125
+ protocol-named* namespace with `enable_mcp!`. Caddy TLS is the same shape.
126
+ - **`Otto::Security::CSP`** — even a security *sub-feature* is named for itself and
127
+ nested under the subsystem it belongs to, not dropped in a generic bucket.
128
+ - **Shared mechanism lives in `Otto::Core`** — the one primitive CSP genuinely
129
+ reused (the `:outermost` middleware position) was added to
130
+ `lib/otto/core/middleware_stack.rb`, not to any "services" module. Otto's
131
+ instinct is: promote shared code to core, name features for themselves.
132
+
133
+ None of these support a generic `Otto::Services` drawer, so it was collapsed to
134
+ `Otto::CaddyTLS`. `LocalhostGuard` lives under `Otto::CaddyTLS` while it has a
135
+ single consumer; if a second internal-only integration ever needs it, promote it to
136
+ a shared home then, shaped by two real examples.
137
+
138
+ ### Public API
139
+
140
+ ```ruby
141
+ otto = Otto.new('routes.txt')
142
+
143
+ otto.enable_caddy_tls! do |domain|
144
+ # The ONLY coupling point. Return truthy => 200 (issue cert), falsy => 403 (deny).
145
+ # `domain` is the only input. Any exception here is caught and denies (fail-closed).
146
+ MyApp::CustomDomain.verified?(domain)
147
+ end
148
+
149
+ otto.caddy_tls_enabled? # => true
150
+ ```
151
+
152
+ Defaults: `endpoint: '/_caddy/tls-permission'`, `localhost_only: true`. The route
153
+ is registered programmatically (like `/_mcp`) — no `routes.txt` entry required.
154
+ Enabling without a block raises `ArgumentError` (there is no allow-all default).
155
+ All setup runs through `ensure_not_frozen!`, so it must happen before the first
156
+ request, and re-enabling is idempotent (the route/guard are never duplicated).
157
+
158
+ ### Caddyfile (config-only; both directives, identical contract)
159
+
160
+ ```caddyfile
161
+ on_demand_tls {
162
+ permission http { endpoint http://127.0.0.1:PORT/_caddy/tls-permission }
163
+ }
164
+
165
+ # Legacy / deprecated — same endpoint, same HTTP contract:
166
+ on_demand_tls { ask http://127.0.0.1:PORT/_caddy/tls-permission }
167
+ ```
168
+
169
+ ## 5. Security model
170
+
171
+ The endpoint gates certificate issuance, so the **allow** path must be
172
+ un-trickable; the **deny** path is naturally safe (Caddy treats any non-`2xx` as
173
+ deny). Everything fails closed.
174
+
175
+ ### 5.1 Authenticate the *raw* peer, not the resolved client IP
176
+
177
+ This is the load-bearing decision, and it corrects the obvious-but-wrong first
178
+ instinct (which every initial design in the panel made).
179
+
180
+ `Otto::CaddyTLS::LocalhostGuard` reads the **original `env['REMOTE_ADDR']`** — the
181
+ TCP socket peer — and runs **before** `IPPrivacyMiddleware` rewrites `REMOTE_ADDR`
182
+ from forwarded headers. Because the guard is installed via `Otto#use` (appended,
183
+ therefore *outermost* in Otto's `reduce`-built stack) and `IPPrivacyMiddleware` is
184
+ pinned *innermost*, the guard provably inspects the true socket peer regardless of
185
+ when `enable_caddy_tls!` is called.
186
+
187
+ Reading Otto's resolved `otto.client_ip` (or the rewritten `REMOTE_ADDR`) would be
188
+ **exploitable**: `Otto::Utils.resolve_client_ip` honors `X-Forwarded-For` when the
189
+ peer is a *trusted proxy*, and a co-located Caddy on loopback is itself a natural
190
+ trusted proxy. An attacker who could route to the endpoint through it and send
191
+ `X-Forwarded-For: 127.0.0.1` would be resolved to loopback and let in.
192
+ Authenticating the raw peer removes forwarded headers from the trust decision
193
+ entirely. (See `spec/otto/caddy_tls/localhost_guard_spec.rb`, "spoofing
194
+ resistance".)
195
+
196
+ ### 5.2 Reject relayed requests (forwarding headers)
197
+
198
+ A direct call is loopback peer **and** *no forwarding headers*. Caddy's on-demand
199
+ permission request is a direct backend call and carries none; a request that was
200
+ **relayed through a reverse proxy** carries `X-Forwarded-For` (or `X-Real-IP`,
201
+ `X-Client-IP`, `Forwarded`). The guard denies any request to the endpoint that
202
+ carries one, even if its socket peer is loopback.
203
+
204
+ This is what makes the endpoint safe against the "accidentally bolted onto an
205
+ existing app" mistake: if the endpoint is mounted inside a public app behind a
206
+ proxy that connects to the backend over loopback, *every* proxied user request has
207
+ a loopback peer — but it also carries a forwarding header, so it is denied. Only
208
+ the proxy's direct control-plane call (loopback peer, no forwarding header) is
209
+ allowed.
210
+
211
+ ### 5.3 Correct loopback detection
212
+
213
+ `IPAddr.new(remote_addr).native.loopback?`, wrapped `rescue … => false`:
214
+
215
+ - `.native` folds IPv4-mapped IPv6 (`::ffff:127.0.0.1`, which dual-stack servers
216
+ commonly present) — plain `#loopback?` returns **false** for the mapped form and
217
+ would wrongly reject legitimate traffic.
218
+ - Blank, malformed, or `:port`-suffixed values (a non-standard `REMOTE_ADDR`) fail
219
+ closed to `401` rather than raising on Caddy's TLS-handshake hot path.
220
+
221
+ ### 5.4 Path-scoped, bypass-resistant
222
+
223
+ The guard only enforces loopback for its own endpoint; every other route passes
224
+ through untouched. It normalizes `PATH_INFO` through the **same
225
+ `Otto::Utils.normalize_path` the router uses for literal matching** (URL-unescape,
226
+ scrub invalid UTF-8 bytes, strip a trailing slash), so a percent-encoded
227
+ (`…permissio%6e`), invalid-byte, or trailing-slash variant that the router would
228
+ still route cannot slip past by normalizing differently in the guard than at
229
+ dispatch. Sharing one implementation makes that agreement structural rather than a
230
+ duplicated invariant two files must remember to keep in sync — if the router's
231
+ normalization ever changes, the guard changes with it.
232
+
233
+ ### 5.5 Fail-closed everywhere
234
+
235
+ - The app block is wrapped by `Server#permit?`: `nil`, `false`, or any
236
+ `StandardError` denies (`403`) and logs.
237
+ - Blank/missing `domain` returns `400` before the block is consulted.
238
+ - Only `?domain=` reaches the decision — no other query parameter. This preserves
239
+ the downstream removal of `check_verification` (local processes must not bypass
240
+ DNS verification via the query string).
241
+ - `enable_caddy_tls!` with no block raises rather than defaulting to allow-all.
242
+
243
+ ### 5.6 Deployment: co-locate the endpoint with Caddy
244
+
245
+ The guard is **loopback-only by design**, and it stays that way even when Caddy
246
+ and the application run on **different hosts**. The recommended topology is to run
247
+ the permission endpoint as a tiny Otto app **on the same host as Caddy** — see
248
+ `examples/caddy_tls_demo/standalone.ru`:
249
+
250
+ ```
251
+ [ Caddy host ] [ App / data host(s) ]
252
+ Caddy ──loopback──▶ tiny Otto app ──(your own authenticated channel)──▶ data
253
+ (127.0.0.1) enable_caddy_tls! { |domain| ... }
254
+ ```
255
+
256
+ The Caddy → endpoint hop is always loopback (secure, unspoofable). The endpoint's
257
+ decision block is app-supplied, so it reaches the real domain data over whatever
258
+ channel the app already trusts (an internal API call, a shared database, a cache).
259
+ This keeps the *authentication* boundary simple and strong (loopback) while the
260
+ *data* lookup crosses hosts however the app likes.
261
+
262
+ Rejected alternative: widening the guard to a configurable trusted-source IP
263
+ allowlist so Caddy could call cross-host directly. It trades an unspoofable
264
+ boundary (loopback) for a spoofable one (source IP on a shared network) and adds
265
+ configuration surface, for no capability the co-location topology doesn't already
266
+ provide. Loopback-only + co-location is the better overall design.
267
+
268
+ Additional layers (defense in depth):
269
+
270
+ - **Dedicated loopback port.** Bind the endpoint app on `127.0.0.1:PORT` serving
271
+ *only* the permission route, so it is unreachable from off-host by construction.
272
+ - **Proxy path block.** If you *do* mount the endpoint inside a proxied app, also
273
+ block the path at the proxy. Caddy's `on_demand_tls` call bypasses Caddy's own
274
+ route rules, so blocking the public path does not affect certificate validation:
275
+
276
+ ```caddyfile
277
+ @tls_permission path /_caddy/tls-permission
278
+ respond @tls_permission 404
279
+ ```
280
+
281
+ ## 6. Absorbing the OneTimeSecret "Internal ACME" app
282
+
283
+ The behavior maps 1:1, decoupled and hardened:
284
+
285
+ | OneTimeSecret | Otto pilot |
286
+ |---|---|
287
+ | `AskHandler.call` (`?domain=` → 400/200/403, `text/plain` `OK`/`Forbidden`) | `CaddyTLS::PermissionHandler.handle` (identical) |
288
+ | `LocalhostOnly` (trusts resolved `REMOTE_ADDR`, plain `#loopback?`) | `CaddyTLS::LocalhostGuard` (raw peer + reject forwarding headers, `.native.loopback?`, fail-closed, router-equivalent path scoping) |
289
+ | `Application.domain_allowed?` → `CustomDomain…ready?` (the coupling) | app-supplied `enable_caddy_tls!` block |
290
+ | `domain_allowed?` `rescue => false` | absorbed into `Server#permit?` so every consumer inherits it |
291
+ | `check_verification` removed from HTTP surface | preserved: only `?domain=` is read |
292
+
293
+ ## 7. How CSP reporting (#174) actually landed — and what it taught us
294
+
295
+ This is the part that validated (and corrected) the design. CSP reporting shipped
296
+ **not** as a second tenant of this namespace but as `Otto::Security::CSP`
297
+ (`Parser`, `Report`, `ReportMiddleware`), enabled via
298
+ `enable_csp_reporting!(report_uri) { |report| … }` on `Otto::Security::Core`. It is
299
+ a public, unauthenticated Rack middleware pinned `:outermost`, always answers
300
+ `204`, size-caps the body, and dispatches each parsed report through a
301
+ fire-and-forget callback held on `Otto::Security::Config`.
302
+
303
+ What that outcome confirms:
304
+
305
+ - **The pattern is real.** CSP is the same *shape* — a fixed external HTTP
306
+ contract, an app-supplied handler, Otto owning the ceremony — so the pilot did
307
+ generalize a genuine recurring need.
308
+ - **The shared-namespace hypothesis was wrong.** CSP belongs beside Otto's CSP
309
+ *emission* (`report-uri` directive, nonce policy) in `Otto::Security`; it shares
310
+ `csp_report_uri` and the violation callback with header generation. Filing it in
311
+ a generic `Otto::Services` would have severed it from the code it is one half of.
312
+ A network-service integration goes wherever its *domain* is — `Otto::Security` for
313
+ CSP, its own module for the Caddy bridge — not into a bucket named after the
314
+ mechanism.
315
+ - **What's actually shared is mechanism, and it lives in core.** The one thing both
316
+ features needed was a way to run a middleware ahead of CSRF/auth. That became the
317
+ generic `:outermost` position in `Otto::Core`'s middleware stack — reusable by
318
+ any integration, owned by none.
319
+ - **The guard is correctly *not* shared.** CSP reports come from browsers, so CSP
320
+ opts out of any localhost guard and instead leans on content-type gating, a size
321
+ cap, and Otto's rate limiting. That opt-out is exactly why `LocalhostGuard` is a
322
+ standalone building block rather than baked into a base class — and it is the
323
+ post-hoc comparison #174 asked for: a modular CSP endpoint differs from a
324
+ hand-built one only in that the enable/callback/route plumbing is conventionalized.
325
+
326
+ ## 8. Decisions
327
+
328
+ Resolved during review:
329
+
330
+ - **Namespace:** `Otto::CaddyTLS` (top-level, feature-named, mirroring
331
+ `Otto::MCP`). The earlier `Otto::Services` umbrella was collapsed once #174
332
+ chose `Otto::Security::CSP`, leaving the umbrella with a single tenant (§4, §7).
333
+ - **Loopback-only, even cross-host.** The endpoint stays loopback-only; when Caddy
334
+ and the app run on different hosts, co-locate the tiny permission app with Caddy
335
+ (§5.6). Chosen over a configurable trusted-source IP allowlist because loopback
336
+ is an unspoofable boundary and co-location needs no extra config or trust.
337
+ - **API shape:** `enable_caddy_tls!` (code-side) is the primary, secure-by-default
338
+ path — it bundles route + guard + decision so the endpoint cannot exist without
339
+ its guard. The handler class *is* resolvable from a routes file for advanced
340
+ users, but that split (route declared without guard) is the exact footgun we
341
+ avoid, so it is not the documented path.
342
+ - **Multi-instance:** the handler resolves its `Server` per-request from the Otto
343
+ instance the dispatcher binds to it (not a class-level global), so multiple Otto
344
+ apps in one process each evaluate their own permission block.
345
+
346
+ Still the maintainer's call:
347
+
348
+ - **Default endpoint path:** `'/_caddy/tls-permission'` (`_`-prefixed like `/_mcp`).
349
+ The absorbed app used `/api/internal/acme/ask`.
350
+ - **Guard denial status:** `401` (parity with the absorbed `LocalhostOnly`) vs
351
+ `403`. Both are non-`2xx`, so Caddy denies either way.
352
+
353
+ ## References
354
+
355
+ - Caddy on-demand TLS / permission module — https://caddyserver.com/docs/json/apps/tls/automation/on_demand/permission/http/
356
+ - Issue #175 (this work) · Issue #174 (CSP reporting, shipped as `Otto::Security::CSP`)
357
+ - Precedent: `lib/otto/mcp/` (modular protocol integration)
358
+ - Code: `lib/otto/caddy_tls/` · Specs: `spec/otto/caddy_tls/` · Example: `examples/caddy_tls_demo/`
@@ -0,0 +1,100 @@
1
+ # Otto — Caddy on-demand TLS demo
2
+
3
+ This example demonstrates `Otto::CaddyTLS`, Otto's integration for the **Caddy
4
+ on-demand TLS permission endpoint**. When Caddy serves TLS for a hostname it has
5
+ no certificate for, it asks the backend "may I obtain a certificate for this
6
+ domain?". This endpoint answers that question.
7
+
8
+ One endpoint serves **both** the deprecated `ask` directive and its replacement,
9
+ the `permission http` module — their HTTP contract is identical, so migration is
10
+ config-only on Caddy's side.
11
+
12
+ ## What you'll learn
13
+
14
+ - How to enable a modular network-service integration with one call
15
+ (`enable_caddy_tls!`)
16
+ - How the app supplies just the domain decision while Otto owns the HTTP ceremony
17
+ - How the localhost guard authenticates the raw socket peer (secure by default)
18
+ - How the permission endpoint coexists with a normal Otto app
19
+
20
+ ## Run it
21
+
22
+ ```sh
23
+ cd examples/caddy_tls_demo
24
+ rackup config.ru # serves on http://localhost:9292
25
+ ```
26
+
27
+ ## Try it
28
+
29
+ The demo allowlists `verified.example.com` and `tenant-a.example.com` (see
30
+ `app.rb`). Requests must come from loopback (rackup binds localhost, so `curl`
31
+ from the same host works).
32
+
33
+ ```sh
34
+ # Allowed domain -> 200 OK
35
+ curl -i "http://127.0.0.1:9292/_caddy/tls-permission?domain=verified.example.com"
36
+
37
+ # Unknown domain -> 403 Forbidden
38
+ curl -i "http://127.0.0.1:9292/_caddy/tls-permission?domain=nope.example.com"
39
+
40
+ # Missing domain -> 400 Bad Request
41
+ curl -i "http://127.0.0.1:9292/_caddy/tls-permission"
42
+
43
+ # The app's own routes are unaffected by the guard
44
+ curl -i "http://127.0.0.1:9292/health"
45
+ ```
46
+
47
+ A non-loopback caller receives `401 Unauthorized` — the guard reads the raw TCP
48
+ peer, so a spoofed `X-Forwarded-For: 127.0.0.1` does **not** help.
49
+
50
+ ## Wire up Caddy
51
+
52
+ ```caddyfile
53
+ {
54
+ on_demand_tls {
55
+ permission http { endpoint http://127.0.0.1:9292/_caddy/tls-permission }
56
+ }
57
+ }
58
+
59
+ https:// {
60
+ tls {
61
+ on_demand
62
+ }
63
+ reverse_proxy 127.0.0.1:9292
64
+ }
65
+ ```
66
+
67
+ Legacy/deprecated form (same endpoint, same contract):
68
+
69
+ ```caddyfile
70
+ on_demand_tls { ask http://127.0.0.1:9292/_caddy/tls-permission }
71
+ ```
72
+
73
+ ## Production deployment (including cross-host)
74
+
75
+ The guard is loopback-only, and stays that way even when Caddy and your app run on
76
+ **different hosts**. Run the permission endpoint as a tiny app **on the same host
77
+ as Caddy** — see [`standalone.ru`](standalone.ru):
78
+
79
+ ```
80
+ [ Caddy host ] [ App / data host(s) ]
81
+ Caddy ──loopback──▶ tiny Otto app ──(your own channel)──▶ real domain data
82
+ (127.0.0.1) enable_caddy_tls! { |domain| ... }
83
+ ```
84
+
85
+ The Caddy → endpoint hop stays loopback (unspoofable); the decision block reaches
86
+ your real data over whatever channel your app already trusts. Two more defenses:
87
+
88
+ - The guard **rejects any request carrying a forwarding header** (`X-Forwarded-For`
89
+ et al.). Caddy's direct permission call has none; a request *relayed through* a
90
+ proxy does — so even if you accidentally mount this inside a public proxied app,
91
+ proxied requests are denied despite arriving from the loopback proxy.
92
+ - If you do mount it inside a proxied app, also block the path at the proxy
93
+ (Caddy's `on_demand_tls` call bypasses its own route rules, so this is safe):
94
+
95
+ ```caddyfile
96
+ @tls_permission path /_caddy/tls-permission
97
+ respond @tls_permission 404
98
+ ```
99
+
100
+ See [`docs/reverse-proxy-network-services.md`](../../docs/reverse-proxy-network-services.md).
@@ -0,0 +1,41 @@
1
+ # examples/caddy_tls_demo/app.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # DemoApp serves the example's own pages. It has nothing to do with the Caddy
6
+ # permission endpoint — it is here to show that the endpoint (and its localhost
7
+ # guard) coexist with a normal Otto application without affecting its routes.
8
+ class DemoApp
9
+ def self.index(_req, res)
10
+ res.headers['content-type'] = 'text/html; charset=utf-8'
11
+ res.body = <<~HTML
12
+ <h1>Otto — Caddy on-demand TLS demo</h1>
13
+ <p>This app exposes a Caddy on-demand TLS permission endpoint at
14
+ <code>GET /_caddy/tls-permission?domain=&lt;host&gt;</code>.</p>
15
+ <p>It answers <code>200 OK</code> for allowed domains and <code>403</code>
16
+ otherwise, and only accepts requests from the loopback interface. See
17
+ <code>README.md</code> for <code>curl</code> commands and the Caddyfile.</p>
18
+ HTML
19
+ end
20
+
21
+ def self.health(_req, res)
22
+ res.headers['content-type'] = 'text/plain'
23
+ res.body = 'OK'
24
+ end
25
+ end
26
+
27
+ # DomainDirectory stands in for whatever an application uses to decide which
28
+ # domains may receive a certificate (a database of verified custom domains, an
29
+ # allowlist, an API call, ...). Otto does not care how the decision is made — it
30
+ # only calls the block you pass to `enable_caddy_tls!`.
31
+ module DomainDirectory
32
+ # In a real app this would check DNS ownership / verification status.
33
+ VERIFIED = %w[
34
+ verified.example.com
35
+ tenant-a.example.com
36
+ ].freeze
37
+
38
+ def self.allowed?(domain)
39
+ VERIFIED.include?(domain)
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ # examples/caddy_tls_demo/config.ru
2
+
3
+ require_relative '../../lib/otto'
4
+ require_relative 'app'
5
+
6
+ # Resolve the routes file relative to this file, not the process CWD, so the
7
+ # documented `rackup examples/caddy_tls_demo/config.ru` works from the repo root.
8
+ app = Otto.new(File.expand_path('routes', __dir__))
9
+
10
+ # Enable the Caddy on-demand TLS permission endpoint. The block is the only
11
+ # application-specific part: it receives the domain Caddy is asking about and
12
+ # returns truthy to allow a certificate (HTTP 200) or falsy to deny (HTTP 403).
13
+ # Any exception raised inside the block is caught and treated as a denial
14
+ # (fail-closed). Requests from non-loopback peers are rejected with 401 by
15
+ # default (localhost_only: true).
16
+ app.enable_caddy_tls! do |domain|
17
+ DomainDirectory.allowed?(domain)
18
+ end
19
+
20
+ # The endpoint is now served at: GET /_caddy/tls-permission?domain=<host>
21
+ #
22
+ # Point Caddy at it (config-only; the deprecated `ask` and the new
23
+ # `permission http` directives speak the identical HTTP contract):
24
+ #
25
+ # on_demand_tls {
26
+ # permission http { endpoint http://127.0.0.1:9292/_caddy/tls-permission }
27
+ # }
28
+ # # legacy / deprecated, same endpoint:
29
+ # on_demand_tls { ask http://127.0.0.1:9292/_caddy/tls-permission }
30
+
31
+ run app
@@ -0,0 +1,9 @@
1
+ # examples/caddy_tls_demo/routes
2
+ #
3
+ # The Caddy on-demand TLS permission endpoint is registered programmatically by
4
+ # `enable_caddy_tls!` (see config.ru); no route entry is needed here. These are
5
+ # just the demo's own pages, shown to prove the permission endpoint coexists with
6
+ # a normal app and that the localhost guard does not affect other routes.
7
+
8
+ GET / DemoApp.index
9
+ GET /health DemoApp.health