otto 2.9.0 → 2.10.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +7 -5
  12. data/AGENTS.md +22 -1
  13. data/CHANGELOG.rst +203 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +146 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +181 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +36 -22
  53. data/lib/otto/core/file_safety.rb +89 -31
  54. data/lib/otto/core/middleware_stack.rb +36 -36
  55. data/lib/otto/core/router.rb +62 -19
  56. data/lib/otto/env_keys.rb +20 -2
  57. data/lib/otto/mcp/auth/token.rb +10 -4
  58. data/lib/otto/mcp/core.rb +23 -5
  59. data/lib/otto/mcp/endpoint.rb +41 -0
  60. data/lib/otto/mcp/errors.rb +15 -0
  61. data/lib/otto/mcp/options.rb +292 -0
  62. data/lib/otto/mcp/protocol.rb +52 -22
  63. data/lib/otto/mcp/rate_limiting.rb +175 -97
  64. data/lib/otto/mcp/registry.rb +14 -14
  65. data/lib/otto/mcp/schema_validation.rb +20 -12
  66. data/lib/otto/mcp/server.rb +131 -32
  67. data/lib/otto/optional_dependency.rb +57 -0
  68. data/lib/otto/privacy/config.rb +10 -8
  69. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  70. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  71. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  72. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  73. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  74. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  75. data/lib/otto/security/config.rb +328 -14
  76. data/lib/otto/security/configurator.rb +36 -6
  77. data/lib/otto/security/core.rb +19 -1
  78. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  79. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  80. data/lib/otto/security/rate_limiter.rb +81 -46
  81. data/lib/otto/utils.rb +50 -0
  82. data/lib/otto/version.rb +1 -1
  83. data/lib/otto.rb +9 -11
  84. data/otto.gemspec +0 -2
  85. metadata +31 -41
  86. data/docs/.gitignore +0 -10
  87. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  88. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  89. data/docs/enrichment.md +0 -128
  90. data/docs/geo-country.md +0 -181
  91. data/docs/ipaddr-encoding-quirk.md +0 -34
  92. data/docs/migrating/v2.0.0-pre1.md +0 -276
  93. data/docs/migrating/v2.0.0-pre2.md +0 -338
  94. data/docs/modern-authentication-authorization-landscape.md +0 -558
  95. data/docs/multi-strategy-authentication-design.md +0 -1401
  96. data/docs/reverse-proxy-network-services.md +0 -371
@@ -0,0 +1,377 @@
1
+ # Authentication and authorization
2
+
3
+ Otto authenticates at the route handler boundary. Authentication is not a
4
+ middleware that runs before routing; only routes with `auth=` requirements are
5
+ wrapped. This lets a route declare its access requirement next to its HTTP
6
+ contract.
7
+
8
+ Authentication answers **who is this request from?** Authorization answers
9
+ **may that subject perform this action?** Keep those questions separate.
10
+
11
+ ## Register strategies before the first request
12
+
13
+ Register named strategy instances during boot:
14
+
15
+ ```ruby
16
+ otto = Otto.new('routes')
17
+ otto.add_auth_strategy(
18
+ 'session',
19
+ Otto::Security::Authentication::Strategies::SessionStrategy.new(
20
+ session_key: 'user_id'
21
+ )
22
+ )
23
+ api_keys = ENV.fetch('API_KEYS').split(',').reject(&:empty?)
24
+ raise 'API_KEYS is empty' if api_keys.empty?
25
+
26
+ otto.add_auth_strategy(
27
+ 'api_key',
28
+ Otto::Security::Authentication::Strategies::APIKeyStrategy.new(api_keys: api_keys)
29
+ )
30
+ ```
31
+
32
+ `APIKeyStrategy` fails closed. Its constructor requires exactly one key source
33
+ (`api_keys:`, `resolver:`, or a block; see below) and raises `ArgumentError`
34
+ when none is given, or when an `api_keys:` list normalizes to empty (`[]` or
35
+ only blank strings), so the strategy enforces the check the example above makes
36
+ explicit. A request that presents a key is accepted only when that key matches a
37
+ configured key under constant-time comparison. Both sides are reduced to
38
+ fixed-width SHA-256 digests before comparing, so the check never short-circuits
39
+ on a length mismatch and configured key lengths are not observable. A blank
40
+ credential (empty or whitespace-only) is rejected as missing.
41
+ A presented-but-invalid key is a terminal failure, so it aborts the strategy
42
+ chain instead of falling through to a later strategy in a multi-strategy `OR`
43
+ route.
44
+
45
+ By default, the strategy reads only the `X-API-Key` header. Use `header_name:`
46
+ to select a different header. The query/form parameter path is opt-in via
47
+ `param_name:`, because a key placed in a URL is captured by access logs,
48
+ proxies, and browser history:
49
+
50
+ ```ruby
51
+ Otto::Security::Authentication::Strategies::APIKeyStrategy.new(
52
+ api_keys: api_keys,
53
+ param_name: 'api_key' # caution: keys in URLs are logged; prefer the header
54
+ )
55
+ ```
56
+
57
+ The strategy never places the raw key in the result. `metadata[:api_key_fingerprint]`
58
+ (and, for the static list, `user[:api_key_fingerprint]`) holds a truncated
59
+ SHA-256 digest of the presented key, so audit logs can correlate requests
60
+ without recording the credential. With a resolver, `user` is whatever the
61
+ resolver returns, so that guarantee covers only the strategy's own fields; see
62
+ the rules below.
63
+
64
+ ### Resolve keys from a database
65
+
66
+ A static list is the simple default. When keys live in a database, a
67
+ repository, or a cache, give the strategy a resolver instead. The resolver
68
+ receives the presented key and returns the account behind it, or `nil` when
69
+ there is none:
70
+
71
+ ```ruby
72
+ APIKeyStrategy = Otto::Security::Authentication::Strategies::APIKeyStrategy
73
+
74
+ otto.add_auth_strategy(
75
+ 'api_key',
76
+ APIKeyStrategy.new do |presented_key|
77
+ ApiKey.find_by(digest: APIKeyStrategy.digest(presented_key))&.account
78
+ end
79
+ )
80
+ ```
81
+
82
+ Anything that responds to `#call` works as well, passed as `resolver:`:
83
+
84
+ ```ruby
85
+ APIKeyStrategy.new(resolver: repo.method(:find_by_key))
86
+ APIKeyStrategy.new(resolver: ->(key) { KeyCache.fetch(APIKeyStrategy.digest(key)) })
87
+ ```
88
+
89
+ The rules are the same in every form:
90
+
91
+ - Exactly one source: `api_keys:`, `resolver:`, or a block. Passing none, or
92
+ more than one, raises `ArgumentError`, as does a `resolver:` that does not
93
+ respond to `#call`.
94
+ - The resolver receives only the presented key, as a non-blank `String`, and
95
+ nothing else. The blank check (empty or whitespace-only) and the non-String
96
+ rejection run before it, so a missing or blank header is still the
97
+ non-terminal `No API key provided` failure and the resolver is never asked
98
+ about it.
99
+ - A `nil` or `false` return is a terminal `Invalid API key` failure, identical
100
+ to a static mismatch. It aborts the strategy chain with a 401. Every other
101
+ return value is a match, including empty containers: a `where(...)` relation,
102
+ an empty Array from `select`, or `{}` from a cache miss is truthy and
103
+ authenticates every presented key. Return one record (`find_by`, `first`) or
104
+ `nil`.
105
+ - Exceptions from the resolver propagate. The strategy does not rescue them,
106
+ so a database outage surfaces as an error rather than a silent 401, and can
107
+ never become a success.
108
+ - Whatever the resolver returns becomes `user` in the result, verbatim, and
109
+ `api_key_fingerprint` is still set in the result metadata. The strategy
110
+ itself never places the raw key in the result, but the result is stored in
111
+ `env['otto.strategy_result']` and exposed to handlers, so anything the
112
+ application serializes or logs from it carries `user`. It is the resolver's
113
+ responsibility not to return an object that carries the raw key. Return the
114
+ account, not the `ApiKey` row that stores the key, and store digests. A
115
+ resolver that returns the presented key string itself as the user raises
116
+ `ArgumentError`.
117
+
118
+ The strategy cannot make a black-box lookup constant-time. Store SHA-256
119
+ digests rather than raw keys and look up by `APIKeyStrategy.digest(key)`, as
120
+ in the example above. This produces a fixed-width lookup value and keeps raw
121
+ keys out of storage, but it does not make the application's database or cache
122
+ lookup constant-time. A database dump of high-entropy keys (generate them with
123
+ `SecureRandom`, 32 bytes or more) does not expose usable credentials through a
124
+ practical brute-force search. Unsalted SHA-256 is not a password hash, so do
125
+ not accept user-chosen keys. `APIKeyStrategy.digest(key)` returns the full hex
126
+ digest; the fingerprint in the result is its first 12 characters.
127
+
128
+ ### What `APIKeyStrategy` does not provide
129
+
130
+ `APIKeyStrategy` is a conventional small static-allowlist authenticator
131
+ included as a low-dependency convenience and reference implementation. The
132
+ fail-closed changes in #256 make that existing convenience safe by default;
133
+ they do not establish that all Otto API-key authentication should use
134
+ boot-loaded lists.
135
+
136
+ The shape is deliberate and has precedent. A key list loaded once at start,
137
+ checked with a constant-time comparison, is what the Rails guides show for
138
+ `authenticate_or_request_with_http_token` (a token from the environment,
139
+ compared with `ActiveSupport::SecurityUtils.secure_compare`), what the
140
+ Kubernetes API server does with its `--token-auth-file` CSV, and what Traefik
141
+ and nginx do with their basic-auth user lists. Warden and Devise token
142
+ examples, and the common Sinatra `before` filter that compares a header
143
+ against `ENV['API_KEY']`, are the same pattern. All of them share the
144
+ properties here: no source configured means no access, the set of keys is
145
+ fixed for the life of the process, and a mismatch is final. The resolver form
146
+ is the escape hatch for the dynamic case those tools also leave to an external
147
+ store.
148
+
149
+ In either form, the strategy has no native support for:
150
+
151
+ - Runtime addition or immediate revocation.
152
+ - Expiration.
153
+ - Per-client roles or scopes.
154
+ - Ownership and descriptive metadata.
155
+ - Usage quotas.
156
+ - A management API.
157
+ - Persisted audit history.
158
+ - Hashed verifier storage.
159
+
160
+ With a static list, none of these exist: changing the set of valid keys means
161
+ restarting the process, and every key is equal. With a resolver, the first
162
+ four and the last become the application's key store's responsibility. The
163
+ resolver decides whether a key is still valid, and whatever it returns is the
164
+ `user` the handler sees, so roles, scopes, ownership, and expiry live on that
165
+ record. Quotas, a management API, and audit history remain outside the
166
+ strategy entirely. An application that needs them should build a custom
167
+ `AuthStrategy` around its own key model, using `APIKeyStrategy` as the
168
+ reference for header handling, terminal failures, and fingerprinting.
169
+
170
+ A strategy implements `authenticate(env, requirement)` and returns a
171
+ `StrategyResult`, `AuthFailure`, or `AuthorizationFailure`. Subclass
172
+ `Otto::Security::Authentication::AuthStrategy` to use its `success`, `failure`,
173
+ and `authorization_failure` helpers.
174
+
175
+ Strategy names must be unique. Registration and other security configuration
176
+ must happen before the first request, when Otto freezes configuration.
177
+
178
+ ## Protect a route
179
+
180
+ Put the strategy name in the route file:
181
+
182
+ ```text
183
+ GET /profile Profile#show auth=session
184
+ GET /api/data Api::Data response=json auth=api_key
185
+ ```
186
+
187
+ The strategy receives the full requirement string. Exact names resolve directly;
188
+ colon-qualified requirements such as `oauth:google` first try an exact match,
189
+ then fall back to the registered `oauth` strategy while preserving the full
190
+ requirement for that strategy.
191
+
192
+ A route without `auth=` receives an anonymous `StrategyResult` and runs without
193
+ an authentication check. `StrategyResult#authenticated?` tells application code
194
+ whether a user is present; `auth_attempt_succeeded?` tells it whether the
195
+ current route's authentication attempt produced an authenticated result.
196
+
197
+ ## Combine strategies with OR logic
198
+
199
+ Use comma-separated requirements when more than one credential mechanism may
200
+ satisfy the same route:
201
+
202
+ ```text
203
+ GET /api/data Api::Data#show auth=session,api_key,oauth response=json
204
+ ```
205
+
206
+ The chain behaves as follows:
207
+
208
+ 1. Strategies run left to right.
209
+ 2. The first authenticated success wins and later strategies do not run.
210
+ 3. A plain failure allows the next strategy to run.
211
+ 4. An anonymous success, such as `noauth`, is held as a fallback while the rest
212
+ of the chain runs.
213
+ 5. If every strategy fails, Otto returns an authentication or authorization
214
+ failure. If an anonymous fallback exists and no terminal failure occurred,
215
+ it wins.
216
+ 6. A terminal authentication failure stops the chain immediately. Strategies
217
+ should mark a failure terminal only when explicit credentials were presented,
218
+ examined, and rejected. This prevents invalid credentials from degrading to
219
+ an anonymous success in a mixed chain such as
220
+ `auth=basicauth,noauth`.
221
+
222
+ Put the common and least expensive strategy first, but do not use ordering to
223
+ make invalid explicit credentials harmless: terminal failures are intentionally
224
+ fail-closed.
225
+
226
+ ## Route-level roles
227
+
228
+ Add `role=` for a broad route-level authorization check:
229
+
230
+ ```text
231
+ GET /admin Admin::Dashboard auth=session role=admin
232
+ GET /edit Editorial#edit auth=session role=admin,editor
233
+ ```
234
+
235
+ `role=` checks the successful strategy result; it does not read
236
+ `env['rack.session']` independently. The built-in `SessionStrategy` returns a
237
+ user containing only `id` and `user_id`, so it is sufficient for authentication
238
+ but not for these role checks. If the application uses role-protected session
239
+ routes, register a role-aware application strategy instead of the built-in
240
+ strategy shown earlier:
241
+
242
+ ```ruby
243
+ class RoleAwareSessionStrategy < Otto::Security::Authentication::AuthStrategy
244
+ def authenticate(env, _requirement)
245
+ session = env['rack.session']
246
+ return failure('No session available') unless session
247
+
248
+ user_id = session['user_id']
249
+ return failure('Not authenticated') unless user_id
250
+
251
+ success(
252
+ user: {
253
+ id: user_id,
254
+ roles: Array(session['user_roles']).map(&:to_s),
255
+ },
256
+ session: session,
257
+ auth_method: 'session'
258
+ )
259
+ end
260
+ end
261
+
262
+ otto.add_auth_strategy('session', RoleAwareSessionStrategy.new)
263
+ ```
264
+
265
+ Multiple route roles use OR logic. The role check reads, in precedence order:
266
+
267
+ 1. `result.user_roles`, if the result exposes it
268
+ 2. `result.user[:roles]` or `result.user['roles']` for a Hash-backed user
269
+ 3. `result.user.roles`, falling back to `result.user.role`, for an object-backed user
270
+ 4. `result.metadata[:user_roles]`
271
+
272
+ Use strings for result-level, Hash-backed, or metadata roles so they match the
273
+ string values parsed from the route. Object-backed `#roles` and `#role` values
274
+ are normalized to strings.
275
+
276
+ Missing authentication returns `401`. A valid authenticated subject without one
277
+ of the required roles returns `403`. `response=json` makes these route errors
278
+ JSON regardless of the request's `Accept` header.
279
+
280
+ ## Resource-level authorization
281
+
282
+ Route-level roles cannot decide ownership or relationship rules without loading
283
+ the resource. Put that decision in a Logic class's `raise_concerns` method:
284
+
285
+ ```ruby
286
+ class Posts::Edit
287
+ def initialize(strategy_result, params, _locale)
288
+ @context = strategy_result
289
+ @params = params
290
+ end
291
+
292
+ def raise_concerns
293
+ @post = Post.find(@params[:id])
294
+ return if @post.user_id == @context.user_id
295
+
296
+ raise Otto::Security::AuthorizationError.new(
297
+ 'Cannot edit another user\'s post',
298
+ resource: 'Post',
299
+ action: 'edit',
300
+ user_id: @context.user_id
301
+ )
302
+ end
303
+
304
+ def process
305
+ # Perform the edit.
306
+ end
307
+ end
308
+ ```
309
+
310
+ `Otto::Security::AuthorizationError` is registered automatically and produces a
311
+ `403` response. It can carry resource, action, and user ID context for structured
312
+ logging. Authenticate first, then perform resource-level checks; do not rely on
313
+ an ownership check as a substitute for authentication.
314
+
315
+ ## Strategy result contract
316
+
317
+ Otto stores the result for the request in `env['otto.strategy_result']` and uses
318
+ it to construct Logic-class context. Useful accessors include:
319
+
320
+ ```ruby
321
+ result.authenticated?
322
+ result.anonymous?
323
+ result.user_id
324
+ result.user_name
325
+ result.has_role?('admin')
326
+ result.has_permission?('write')
327
+ result.session
328
+ result.metadata
329
+ result.strategy_name
330
+ ```
331
+
332
+ Application code should read the result created by Otto rather than constructing
333
+ its own `StrategyResult`. The `Data` record does not allow member reassignment,
334
+ but contained `session`, `user`, and `metadata` objects are not deep-frozen;
335
+ their mutability remains the application's responsibility.
336
+
337
+ ## Failure and response behavior
338
+
339
+ - Unknown strategy names fail before any strategy in the route runs.
340
+ - Authentication failures are `401`; browser-oriented failures may redirect to
341
+ the configured login path, while JSON routes return JSON.
342
+ - Authorization failures are `403` and should not ask an already authenticated
343
+ subject to authenticate again.
344
+ - A strategy should return an `AuthorizationFailure` when credentials are valid
345
+ but the subject is not permitted, and an `AuthFailure` when authentication did
346
+ not succeed.
347
+ - Failure reasons and attempted strategies are included in Otto's structured
348
+ authentication logging; do not put secrets or raw credentials in those
349
+ reasons.
350
+
351
+ ## Built-in strategy starting points
352
+
353
+ Otto includes these strategy classes as implementation starting points:
354
+
355
+ - `SessionStrategy` — reads a configured key from `env['rack.session']`.
356
+ - `APIKeyStrategy` — checks the configured header; the query parameter is opt-in
357
+ via `param_name:`. Keys come from a non-empty `api_keys:` list, a `resolver:`
358
+ callable, or a block that looks the presented key up; a rejected key is a
359
+ terminal failure, and the result exposes only a key fingerprint.
360
+ - `RoleStrategy` — checks session roles against allowed roles or a
361
+ colon-qualified requirement.
362
+ - `PermissionStrategy` — checks application-provided permission data.
363
+ - `NoAuthStrategy` — produces an anonymous success for an explicitly configured
364
+ anonymous fallback.
365
+
366
+ For production credentials, choose the credential storage, rotation, transport,
367
+ and revocation policy in the application. Otto supplies the route boundary and
368
+ result contract; it does not provide a user database or a universal session
369
+ store.
370
+
371
+ ## Related contracts
372
+
373
+ - [Route syntax](../reference/route-syntax.md) — `auth=`, `role=`, and route
374
+ parsing rules.
375
+ - [Routing guide](routing.md) — choosing controller, Logic, and lambda handlers.
376
+ - [Configuration freezing](configuration_freezing.md) — boot-time mutation
377
+ boundary and multi-step setup.
@@ -0,0 +1,205 @@
1
+ # Caddy on-demand TLS
2
+
3
+ `Otto::CaddyTLS` provides the permission endpoint Caddy calls before obtaining
4
+ or loading a certificate for a domain. Otto owns the route, parameter-shape
5
+ checks, access guard, response semantics, and fail-closed behavior. The
6
+ application supplies one decision block: whether the requested domain is
7
+ allowed.
8
+
9
+ This guide covers the shipped integration. [ADR-003: Caddy TLS route-based
10
+ integration](../adr/adr-003-caddy-tls-route-based-integration.md) explains the
11
+ rejected alternatives and the security rationale in more detail.
12
+
13
+ ## Enable the endpoint
14
+
15
+ Configure it before the first request:
16
+
17
+ ```ruby
18
+ require 'otto'
19
+
20
+ otto = Otto.new('routes')
21
+ otto.enable_caddy_tls! do |domain|
22
+ MyApp::CustomDomain.verified?(domain)
23
+ end
24
+
25
+ run otto
26
+ ```
27
+
28
+ The block receives the stripped `domain` string from `?domain=`. Otto checks
29
+ that the parameter is a non-empty string, but does not validate DNS syntax,
30
+ lowercase the value, remove a trailing dot, or convert internationalized names.
31
+ Perform any application-specific hostname normalization and validation in the
32
+ block before querying domain data. A truthy result returns `200 OK`; a falsey
33
+ result returns `403 Forbidden`. An exception raised by the block is logged and
34
+ treated as a denial.
35
+
36
+ The default endpoint is `/_caddy/tls-permission`. No entry in the routes file is
37
+ required. Enabling without a block raises `ArgumentError`, and repeated enable
38
+ calls are idempotent: the first configuration and decision block remain active.
39
+
40
+ ## Configure Caddy
41
+
42
+ `on_demand_tls` is a global Caddy option, and configuring its permission check
43
+ does not enable on-demand TLS for a site. A complete minimal configuration is:
44
+
45
+ ```caddyfile
46
+ {
47
+ on_demand_tls {
48
+ permission http http://127.0.0.1:PORT/_caddy/tls-permission
49
+ }
50
+ }
51
+
52
+ https:// {
53
+ tls {
54
+ on_demand
55
+ }
56
+
57
+ reverse_proxy 127.0.0.1:APP_PORT
58
+ }
59
+ ```
60
+
61
+ Replace `PORT` with the loopback port serving the Otto permission endpoint and
62
+ `APP_PORT` with the application port receiving normal traffic. If the same Otto
63
+ app serves both, the ports may be the same.
64
+
65
+ Caddy also documents `ask` as a backwards-compatible shortcut for the built-in
66
+ HTTP permission module:
67
+
68
+ ```caddyfile
69
+ {
70
+ on_demand_tls {
71
+ ask http://127.0.0.1:PORT/_caddy/tls-permission
72
+ }
73
+ }
74
+ ```
75
+
76
+ Both forms append `?domain=<host>` and treat a non-`2xx` response as a denial.
77
+ Validate the chosen form with the Caddy version you deploy:
78
+
79
+ ```sh
80
+ caddy adapt --config Caddyfile --adapter caddyfile
81
+ ```
82
+
83
+ See Caddy's [`on_demand_tls` documentation](https://caddyserver.com/docs/caddyfile/options#on-demand-tls)
84
+ for the current module syntax and deployment requirements.
85
+
86
+ ## Request contract
87
+
88
+ The endpoint accepts a `GET` request with a string `domain` query parameter:
89
+
90
+ | Request | Result |
91
+ | --- | --- |
92
+ | `GET /_caddy/tls-permission?domain=verified.example` and callback allows | `200`, `text/plain`, `OK` |
93
+ | Domain missing, blank, or array-valued | `400`, callback is not called |
94
+ | Callback returns false or `nil` | `403`, `text/plain`, `Forbidden` |
95
+ | Callback raises | `403`, callback error is logged |
96
+ | `HEAD` with a valid domain | Same status decision, empty response body |
97
+
98
+ Only `domain` reaches the callback. Additional query parameters are ignored;
99
+ there is no verification-bypass parameter.
100
+
101
+ ## Security boundary
102
+
103
+ The localhost guard is enabled by default. For the protected endpoint, both
104
+ conditions must hold:
105
+
106
+ 1. the raw socket peer is loopback; and
107
+ 2. no non-empty forwarding header indicates that the request was relayed
108
+ through another proxy.
109
+
110
+ The guard authenticates the original peer, not the resolved client address in
111
+ `REMOTE_ADDR` or `otto.client_ip`. This matters when a loopback proxy is also a
112
+ trusted proxy: forwarded headers must not be able to turn a remote caller into a
113
+ local one. The guard rejects the endpoint request when any of these headers has a
114
+ non-empty value:
115
+
116
+ - `X-Forwarded-For`
117
+ - `X-Real-IP`
118
+ - `X-Client-IP`
119
+ - `Forwarded`
120
+ - `X-Forwarded-Host`
121
+ - `X-Forwarded-Proto`
122
+ - `X-Forwarded-Scheme`
123
+ - `X-Forwarded-SSL`
124
+ - `X-Forwarded-Port`
125
+
126
+ The list is the full set (`Otto::Utils::RELAY_MARKER_HEADERS`): every forwarding
127
+ carrier Otto knows, including the authority headers, not only the client-IP
128
+ carriers. A front server that adds any of them to the permission request, even
129
+ one that only records the scheme or port, turns the call into a relayed request
130
+ and the guard returns `401`. Configure the permission endpoint so the request
131
+ reaches Otto without forwarding headers.
132
+
133
+ The guard is path-scoped. Other application routes pass through it unchanged,
134
+ and path normalization is shared with the router so encoded or trailing-slash
135
+ variants cannot bypass the check.
136
+
137
+ This protection assumes the Rack server leaves the actual connecting peer in
138
+ `REMOTE_ADDR` and that no earlier proxy or middleware removes relay-marker
139
+ headers before Otto records them. If PROXY protocol or an outer middleware
140
+ rewrites either input, verify that Otto still receives an authentic peer address
141
+ and the original forwarding markers. The strongest boundary is a dedicated
142
+ loopback-only listener.
143
+
144
+ ## Deployment topology
145
+
146
+ The recommended deployment is a small Otto app bound to a loopback-only port on
147
+ the same host as Caddy:
148
+
149
+ ```text
150
+ Caddy -- loopback --> Otto CaddyTLS endpoint -- authenticated app channel --> domain data
151
+ ```
152
+
153
+ Caddy expects this decision in a few milliseconds. Prefer an indexed local
154
+ lookup or in-process cache. If the permission app must call an internal service,
155
+ use a short timeout and cache the result: an exception or timeout that reaches
156
+ the block's fail-closed wrapper becomes a denial and can fail the TLS handshake.
157
+ That data channel is an application responsibility; it does not widen the Caddy
158
+ endpoint's network trust boundary.
159
+
160
+ If the endpoint is mounted in a larger public app, add defense in depth:
161
+
162
+ - bind a dedicated permission app to `127.0.0.1` where possible;
163
+ - block the endpoint path at the public proxy;
164
+ - ensure the proxy's direct Caddy control-plane request is not relayed with
165
+ forwarding headers;
166
+ - keep the guard enabled unless network isolation is independently enforced.
167
+
168
+ ## Disabling the guard
169
+
170
+ `localhost_only: false` removes the built-in access control and logs a warning:
171
+
172
+ ```ruby
173
+ otto.enable_caddy_tls!(localhost_only: false) do |domain|
174
+ MyApp::CustomDomain.verified?(domain)
175
+ end
176
+ ```
177
+
178
+ Use this only when the endpoint is isolated by a stronger network-level control
179
+ that you have verified. It is not the normal deployment path.
180
+
181
+ ## Custom endpoint
182
+
183
+ An application can choose another endpoint path:
184
+
185
+ ```ruby
186
+ otto.enable_caddy_tls!(endpoint: '/internal/acme/permission') do |domain|
187
+ MyApp::CustomDomain.verified?(domain)
188
+ end
189
+ ```
190
+
191
+ Otto normalizes the configured endpoint before registering the route and guard.
192
+ The endpoint must still be configured before the first request; attempting to
193
+ change it after configuration freezes raises `FrozenError`.
194
+
195
+ ## Troubleshooting
196
+
197
+ - **Caddy receives a denial:** check that the request reaches the configured
198
+ loopback port, includes a non-empty `domain`, and that the callback returns
199
+ truthy for that exact stripped domain.
200
+ - **The callback is never called:** a missing/blank domain produces `400`, while
201
+ a non-loopback peer or any non-empty forwarding header produces `401`.
202
+ - **A proxied public request reaches the same app:** keep the forwarding-header
203
+ rejection and add a proxy path block or a dedicated loopback-only endpoint.
204
+ - **A second `enable_caddy_tls!` call does not change behavior:** this is
205
+ intentional idempotency; configure the endpoint and block on the first call.