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
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
|
@@ -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=<host></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
|