otto 2.5.0 → 2.7.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/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +283 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +14 -12
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +172 -0
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +43 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +281 -3
- data/lib/otto/privacy/core.rb +104 -8
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +4 -1
- data/lib/otto/request.rb +35 -1
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +123 -6
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/policy.rb +135 -3
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +37 -5
- metadata +13 -6
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Otto - Lambda / Inline Route Handlers
|
|
2
|
+
|
|
3
|
+
This example demonstrates Otto's fourth route-handler kind (issue #41):
|
|
4
|
+
**lambda handlers**. A lambda handler is a plain proc, pre-registered by name,
|
|
5
|
+
that a route can target with an `&` prefix.
|
|
6
|
+
|
|
7
|
+
## What You'll Learn
|
|
8
|
+
|
|
9
|
+
- Registering lambda handlers at `Otto.new` construction time
|
|
10
|
+
- The `&handler_name` route syntax
|
|
11
|
+
- Returning strings, Hashes (JSON), and mutating the response directly
|
|
12
|
+
- Applying route options (`response=json`, `csrf=exempt`) to lambdas
|
|
13
|
+
- Why this is safe: no `eval`, no dynamic code from route files
|
|
14
|
+
|
|
15
|
+
## The Four Handler Kinds
|
|
16
|
+
|
|
17
|
+
| Route target | Kind | Resolves to |
|
|
18
|
+
|-----------------|-------------|-------------------------|
|
|
19
|
+
| `App.index` | `:class` | class method |
|
|
20
|
+
| `App#index` | `:instance` | instance method |
|
|
21
|
+
| `BareClass` | `:logic` | Logic object |
|
|
22
|
+
| `&health_check` | `:lambda` | pre-registered proc |
|
|
23
|
+
|
|
24
|
+
## Registration
|
|
25
|
+
|
|
26
|
+
Lambdas are supplied to Otto as a `name => callable` Hash. Otto validates each
|
|
27
|
+
entry (must respond to `#call`, must accept 3 arguments) and freezes the
|
|
28
|
+
registry:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require_relative '../../lib/otto'
|
|
32
|
+
require_relative 'handlers'
|
|
33
|
+
|
|
34
|
+
app = Otto.new('routes', {
|
|
35
|
+
lambda_handlers: LambdaHandlers::REGISTRY,
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each handler receives `(req, res, extra_params)`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
HEALTH_CHECK = lambda do |req, res, extra_params|
|
|
43
|
+
res.headers['content-type'] = 'text/plain; charset=utf-8'
|
|
44
|
+
res.body = 'OK'
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Route Syntax
|
|
49
|
+
|
|
50
|
+
Prefix the target with `&` and give the registered name. Everything after the
|
|
51
|
+
`&` is the exact registry key. The target comes first; route options follow it:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
GET /ping &health_check
|
|
55
|
+
GET /status &status response=json
|
|
56
|
+
GET /greet/:name &greet response=json
|
|
57
|
+
POST /webhook &webhook csrf=exempt response=json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Response Types
|
|
61
|
+
|
|
62
|
+
Lambdas participate in Otto's normal response-type dispatch, the same way a
|
|
63
|
+
class/instance/logic handler does:
|
|
64
|
+
|
|
65
|
+
- **Default** (no `response=`): the handler mutates `res` directly — set
|
|
66
|
+
`res.status` / `res.headers` / `res.body`. The return value is ignored, so
|
|
67
|
+
writing `res.body` is what produces output.
|
|
68
|
+
- **`response=json`**: the returned Hash is serialized as JSON and the JSON
|
|
69
|
+
content-type is set for you.
|
|
70
|
+
|
|
71
|
+
## Route Options
|
|
72
|
+
|
|
73
|
+
Route options apply to lambdas just like any other handler:
|
|
74
|
+
|
|
75
|
+
- `response=json` — serialize the returned Hash as JSON.
|
|
76
|
+
- `csrf=exempt` — parsed and exposed on the route definition (intended to mark
|
|
77
|
+
the webhook, which external callers reach without a browser token). Note:
|
|
78
|
+
`CSRFMiddleware` does not yet consult per-route options, so this option is
|
|
79
|
+
currently recorded but not enforced — see issue #186.
|
|
80
|
+
- `auth=` / `role=` — authentication and authorization (when `auth_config`
|
|
81
|
+
is configured on the Otto instance).
|
|
82
|
+
|
|
83
|
+
## Security
|
|
84
|
+
|
|
85
|
+
The `&` syntax performs an O(1) lookup of a **pre-registered** proc by name.
|
|
86
|
+
Route files never contain Ruby code and are never `eval`'d. A route naming a
|
|
87
|
+
handler that was not registered raises a clear error instead of executing
|
|
88
|
+
anything.
|
|
89
|
+
|
|
90
|
+
## How to Run
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
cd examples/lambda_handlers
|
|
94
|
+
rackup config.ru -p 10780
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then, from another terminal:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
curl localhost:10780/ping
|
|
101
|
+
# OK
|
|
102
|
+
|
|
103
|
+
curl localhost:10780/status
|
|
104
|
+
# {"service":"otto-lambda-demo","status":"healthy","time":"..."}
|
|
105
|
+
|
|
106
|
+
curl localhost:10780/greet/otto
|
|
107
|
+
# {"greeting":"Hello, otto!"}
|
|
108
|
+
|
|
109
|
+
curl -X POST --data 'hello' localhost:10780/webhook
|
|
110
|
+
# {"received":true,"bytes":5}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## File Structure
|
|
114
|
+
|
|
115
|
+
- `README.md`: This file.
|
|
116
|
+
- `handlers.rb`: Defines the lambda procs and the `REGISTRY` Hash.
|
|
117
|
+
- `routes`: Maps URLs to lambdas using the `&` syntax.
|
|
118
|
+
- `config.ru`: Rack config that registers the lambdas with `Otto.new`.
|
|
119
|
+
|
|
120
|
+
## Next Steps
|
|
121
|
+
|
|
122
|
+
- Explore [Advanced Routes](../advanced_routes/) for class/instance/logic
|
|
123
|
+
handlers and response-type negotiation.
|
|
124
|
+
- See [Security Features](../security_features/) for CSRF and input validation.
|
|
125
|
+
|
|
126
|
+
## Further Reading
|
|
127
|
+
|
|
128
|
+
- [docs/ADVANCED_ROUTES.txt](../../docs/ADVANCED_ROUTES.txt)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# examples/lambda_handlers/config.ru
|
|
2
|
+
#
|
|
3
|
+
# Usage:
|
|
4
|
+
#
|
|
5
|
+
# $ rackup config.ru -p 10780
|
|
6
|
+
#
|
|
7
|
+
# then, in another terminal:
|
|
8
|
+
#
|
|
9
|
+
# $ curl localhost:10780/ping
|
|
10
|
+
# $ curl localhost:10780/status
|
|
11
|
+
# $ curl localhost:10780/greet/otto
|
|
12
|
+
# $ curl -X POST --data 'hello' localhost:10780/webhook
|
|
13
|
+
|
|
14
|
+
require_relative '../../lib/otto'
|
|
15
|
+
require_relative 'handlers'
|
|
16
|
+
|
|
17
|
+
# Register the lambda handlers at construction time. Only names present in
|
|
18
|
+
# this Hash can be referenced from the routes file's '&' syntax. Otto
|
|
19
|
+
# validates every entry here (must respond to #call, arity of 3) and freezes
|
|
20
|
+
# the registry — a route naming an unknown handler fails loudly rather than
|
|
21
|
+
# executing anything.
|
|
22
|
+
app = Otto.new('routes', {
|
|
23
|
+
lambda_handlers: LambdaHandlers::REGISTRY,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
run app
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# examples/lambda_handlers/handlers.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Pre-registered lambda route handlers (Otto issue #41).
|
|
6
|
+
#
|
|
7
|
+
# A lambda handler is any object responding to #call that accepts exactly
|
|
8
|
+
# three arguments: (req, res, extra_params).
|
|
9
|
+
#
|
|
10
|
+
# * req - the Rack::Request for this request
|
|
11
|
+
# * res - the Rack::Response to populate
|
|
12
|
+
# * extra_params - a Hash of route/path params merged by Otto
|
|
13
|
+
#
|
|
14
|
+
# These procs are handed to Otto at construction time via the
|
|
15
|
+
# `lambda_handlers:` option (see config.ru). Each is looked up O(1) by name
|
|
16
|
+
# from the route file's `&handler_name` syntax. Nothing in the route file is
|
|
17
|
+
# ever eval'd — only names present in this registry can be invoked, and an
|
|
18
|
+
# unknown name fails loudly instead of executing arbitrary code.
|
|
19
|
+
#
|
|
20
|
+
# How the response is produced depends on the route's `response=` option,
|
|
21
|
+
# exactly like the class/instance/logic handler kinds:
|
|
22
|
+
#
|
|
23
|
+
# * default (no `response=`) -> the handler mutates `res` directly
|
|
24
|
+
# (status/headers/body); the return value is ignored.
|
|
25
|
+
# * `response=json` -> the returned Hash is serialized to JSON.
|
|
26
|
+
module LambdaHandlers
|
|
27
|
+
# A minimal string responder. With no `response=` option the default response
|
|
28
|
+
# handler leaves the body alone, so (like a class handler) we write it here.
|
|
29
|
+
HEALTH_CHECK = lambda do |_req, res, _extra|
|
|
30
|
+
res.headers['content-type'] = 'text/plain; charset=utf-8'
|
|
31
|
+
res.body = 'OK'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A Hash responder. Pair it with `response=json` on the route and Otto's
|
|
35
|
+
# JSON response handler serializes the Hash and sets the JSON content-type.
|
|
36
|
+
STATUS = lambda do |_req, _res, _extra|
|
|
37
|
+
{
|
|
38
|
+
service: 'otto-lambda-demo',
|
|
39
|
+
status: 'healthy',
|
|
40
|
+
time: Time.now.utc.iso8601,
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A handler that reads merged params. Path/query params arrive in
|
|
45
|
+
# `extra_params`; request params are available through `req`.
|
|
46
|
+
GREET = lambda do |req, _res, extra|
|
|
47
|
+
name = extra['name'] || req.params['name'] || 'world'
|
|
48
|
+
{ greeting: "Hello, #{name}!" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A webhook-style POST handler. The matching route declares `csrf=exempt`,
|
|
52
|
+
# intended to let external callers (which cannot present a CSRF token) reach
|
|
53
|
+
# it. Note: CSRFMiddleware does not yet honor per-route csrf=exempt (issue
|
|
54
|
+
# #186), so the option is currently advisory. When enforcement lands, exempt
|
|
55
|
+
# CSRF only for endpoints authenticated another way (signature header, shared
|
|
56
|
+
# secret, etc.).
|
|
57
|
+
WEBHOOK = lambda do |req, _res, _extra|
|
|
58
|
+
# Rewind first: upstream middleware may already have read the input stream.
|
|
59
|
+
req.body.rewind if req.body.respond_to?(:rewind)
|
|
60
|
+
payload = req.body.read.to_s
|
|
61
|
+
# NOTE: this route uses `response=json`, so Otto's JSON response handler
|
|
62
|
+
# owns the status (200) and content-type. Setting them here would be
|
|
63
|
+
# overridden, so we only return the Hash to be serialized.
|
|
64
|
+
{ received: true, bytes: payload.bytesize }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The registry passed to Otto.new(lambda_handlers: ...). Keys are the names
|
|
68
|
+
# referenced after '&' in the routes file.
|
|
69
|
+
REGISTRY = {
|
|
70
|
+
'health_check' => HEALTH_CHECK,
|
|
71
|
+
'status' => STATUS,
|
|
72
|
+
'greet' => GREET,
|
|
73
|
+
'webhook' => WEBHOOK,
|
|
74
|
+
}.freeze
|
|
75
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# examples/lambda_handlers/routes
|
|
2
|
+
|
|
3
|
+
# OTTO - LAMBDA / INLINE ROUTE HANDLERS (issue #41)
|
|
4
|
+
#
|
|
5
|
+
# A route target prefixed with '&' names a pre-registered lambda handler
|
|
6
|
+
# instead of a class/method. The name after '&' is looked up O(1) from the
|
|
7
|
+
# lambda_handlers registry supplied to Otto.new -- never eval'd.
|
|
8
|
+
#
|
|
9
|
+
# The target comes first; route options follow it.
|
|
10
|
+
#
|
|
11
|
+
# Compare the four handler kinds:
|
|
12
|
+
# App.index -> :class (class method)
|
|
13
|
+
# App#index -> :instance (instance method)
|
|
14
|
+
# BareClass -> :logic (Logic object)
|
|
15
|
+
# &health_check -> :lambda (pre-registered proc) <-- this file
|
|
16
|
+
|
|
17
|
+
# Plain-text string responder (default response handler).
|
|
18
|
+
GET /ping &health_check
|
|
19
|
+
|
|
20
|
+
# Hash responder serialized as JSON.
|
|
21
|
+
GET /status &status response=json
|
|
22
|
+
|
|
23
|
+
# Reads a path param, returned as JSON.
|
|
24
|
+
GET /greet/:name &greet response=json
|
|
25
|
+
|
|
26
|
+
# Webhook receiver: marked csrf=exempt (option is parsed/exposed but not yet
|
|
27
|
+
# enforced by CSRFMiddleware -- see issue #186).
|
|
28
|
+
POST /webhook &webhook csrf=exempt response=json
|
|
@@ -3,15 +3,46 @@
|
|
|
3
3
|
|
|
4
4
|
# Otto GeoResolver Extension Guide
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# Otto resolves a country code in this order (first hit wins):
|
|
7
|
+
# 1. App-configured trusted header (configure_ip_privacy(geo_header:))
|
|
8
|
+
# 2. Built-in CDN/provider headers (Cloudflare, AWS, Vercel, ...)
|
|
9
|
+
# 3. Custom resolver hook (GeoResolver.custom_resolver = ...)
|
|
10
|
+
# 4. Local MMDB database (configure_ip_privacy(geo_db_path:/geo_db_reader:))
|
|
11
|
+
# 5. '**' (unknown) Otto does not guess from a hardcoded table
|
|
12
|
+
#
|
|
13
|
+
# This guide shows the extension points:
|
|
14
|
+
# A. Built-in configuration (trusted header + local database) — no code
|
|
15
|
+
# B. Custom resolver hook (inline or a callable object)
|
|
16
|
+
# C. Subclass-based (full control)
|
|
9
17
|
|
|
10
18
|
require 'bundler/setup'
|
|
11
19
|
require 'otto'
|
|
12
20
|
|
|
13
21
|
# =============================================================================
|
|
14
|
-
#
|
|
22
|
+
# A. Built-in configuration: trusted header + local country database
|
|
23
|
+
# =============================================================================
|
|
24
|
+
#
|
|
25
|
+
# No custom code needed — just configure the Otto instance. The database is
|
|
26
|
+
# looked up on the already-MASKED IP, and a bad geo_db_path fails at boot.
|
|
27
|
+
#
|
|
28
|
+
# otto = Otto.new('routes.txt')
|
|
29
|
+
# otto.configure_ip_privacy(
|
|
30
|
+
# geo_header: 'X-Client-Country', # trusted header checked before CDN headers
|
|
31
|
+
# geo_db_path: 'data/country.mmdb' # offline fallback (needs the maxmind-db gem)
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
# Prefer to bring your own reader (any object responding to #get)? Inject it —
|
|
35
|
+
# this keeps the reader/data-source choice independent of Otto:
|
|
36
|
+
#
|
|
37
|
+
# reader = MaxMind::DB.new('data/country.mmdb', mode: MaxMind::DB::MODE_MEMORY)
|
|
38
|
+
# otto.configure_ip_privacy(geo_db_reader: reader)
|
|
39
|
+
#
|
|
40
|
+
# Security note: geo headers are only trusted for requests that arrive via a
|
|
41
|
+
# configured trusted proxy (add_trusted_proxy), since they are client-spoofable
|
|
42
|
+
# otherwise. configure_ip_privacy(geo: false) disables geo entirely.
|
|
43
|
+
|
|
44
|
+
# =============================================================================
|
|
45
|
+
# B. Quick Start: Custom resolver hook
|
|
15
46
|
# =============================================================================
|
|
16
47
|
|
|
17
48
|
puts 'Simple Custom Geo Resolution'
|
|
@@ -31,7 +62,9 @@ Otto::Privacy::GeoResolver.custom_resolver = custom_resolver
|
|
|
31
62
|
|
|
32
63
|
# Step 3: Test it
|
|
33
64
|
puts "1.2.3.4 -> #{Otto::Privacy::GeoResolver.resolve('1.2.3.4', {})}"
|
|
34
|
-
|
|
65
|
+
# Resolver returns nil for 8.8.8.8, and there is no header or database, so the
|
|
66
|
+
# honest answer is '**' (unknown) — Otto does not guess.
|
|
67
|
+
puts "8.8.8.8 -> #{Otto::Privacy::GeoResolver.resolve('8.8.8.8', {})} (unknown)"
|
|
35
68
|
|
|
36
69
|
# Reset for next example
|
|
37
70
|
Otto::Privacy::GeoResolver.custom_resolver = nil
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
require 'ipaddr'
|
|
6
|
-
|
|
7
5
|
require_relative '../utils'
|
|
8
6
|
|
|
9
7
|
class Otto
|
|
@@ -22,11 +20,15 @@ class Otto
|
|
|
22
20
|
#
|
|
23
21
|
# == Security: authenticate the RAW peer, not the resolved client IP
|
|
24
22
|
#
|
|
25
|
-
# The guard
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
23
|
+
# The guard authenticates the TCP socket peer as it arrived, before
|
|
24
|
+
# +IPPrivacyMiddleware+ rewrites +REMOTE_ADDR+ from forwarded headers.
|
|
25
|
+
# +IPPrivacyMiddleware+ is pinned OUTERMOST (issue #219), so it runs ahead
|
|
26
|
+
# of this guard and records its verdict on the untouched peer as
|
|
27
|
+
# +env['otto.peer_loopback']+ — a boolean, never an address. The guard reads
|
|
28
|
+
# that record when present and falls back to evaluating +REMOTE_ADDR+
|
|
29
|
+
# itself when it is not (no Otto privacy middleware in the stack, or the
|
|
30
|
+
# guard mounted outside Otto). Either way the decision is made on the raw
|
|
31
|
+
# peer.
|
|
30
32
|
#
|
|
31
33
|
# Reading Otto's resolved +otto.client_ip+ (or the rewritten +REMOTE_ADDR+)
|
|
32
34
|
# would be exploitable: a co-located reverse proxy on loopback is itself a
|
|
@@ -92,11 +94,28 @@ class Otto
|
|
|
92
94
|
# @param env [Hash] Rack environment
|
|
93
95
|
# @return [Boolean]
|
|
94
96
|
def direct_local_call?(env)
|
|
95
|
-
loopback_peer?(env
|
|
97
|
+
loopback_peer?(env) && !relayed?(env)
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
# Whether any forwarding header is present (request came via a proxy).
|
|
99
101
|
#
|
|
102
|
+
# Unlike the peer check, this reads header STATE, which IPPrivacyMiddleware
|
|
103
|
+
# has already touched by the time the guard runs. That is safe in both of
|
|
104
|
+
# its paths, but only for a reason worth writing down:
|
|
105
|
+
#
|
|
106
|
+
# - Masking REWRITES a forwarded header to the masked IP rather than
|
|
107
|
+
# removing it, so a relayed request still looks relayed. Correct — it was.
|
|
108
|
+
# - The no-resolvable-client-IP path DELETES them, which would make a
|
|
109
|
+
# relayed request look direct. That path is reached only when REMOTE_ADDR
|
|
110
|
+
# is absent or blank, which forces otto.peer_loopback to false, so
|
|
111
|
+
# #direct_local_call? denies on the peer check before this one matters.
|
|
112
|
+
#
|
|
113
|
+
# So header deletion upstream cannot turn a deny into an allow — but that
|
|
114
|
+
# rests on the peer check failing closed for a blank address. Anything that
|
|
115
|
+
# makes an unresolvable-IP request keep a loopback peer verdict would need
|
|
116
|
+
# to record the relay state pre-scrub too (an otto.peer_relayed sibling to
|
|
117
|
+
# otto.peer_loopback).
|
|
118
|
+
#
|
|
100
119
|
# @param env [Hash] Rack environment
|
|
101
120
|
# @return [Boolean]
|
|
102
121
|
def relayed?(env)
|
|
@@ -125,28 +144,27 @@ class Otto
|
|
|
125
144
|
Otto::Utils.normalize_path(path)
|
|
126
145
|
end
|
|
127
146
|
|
|
128
|
-
# Whether the connecting peer is a loopback address.
|
|
129
|
-
# blank or otherwise unparseable value is treated as non-loopback
|
|
130
|
-
# (denied) rather than raising on the hot path.
|
|
147
|
+
# Whether the connecting peer is a loopback address.
|
|
131
148
|
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
149
|
+
# Prefers +env['otto.peer_loopback']+ — IPPrivacyMiddleware's verdict on
|
|
150
|
+
# the ORIGINAL peer, recorded before it rewrites +REMOTE_ADDR+ (it runs
|
|
151
|
+
# outermost, so by the time this guard sees the env the address may
|
|
152
|
+
# already be the resolved-and-masked client IP). Only a real Boolean is
|
|
153
|
+
# honored; anything else falls through to evaluating +REMOTE_ADDR+, which
|
|
154
|
+
# is the correct source when no privacy middleware ran.
|
|
135
155
|
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
156
|
+
# Both paths share +Otto::Utils.loopback_address?+, so the recorded
|
|
157
|
+
# verdict and the fallback cannot disagree. It fails closed: a blank,
|
|
158
|
+
# ported, or unparseable value is treated as non-loopback (denied) rather
|
|
159
|
+
# than raising on the hot path.
|
|
140
160
|
#
|
|
141
|
-
# @param
|
|
161
|
+
# @param env [Hash] Rack environment
|
|
142
162
|
# @return [Boolean]
|
|
143
|
-
def loopback_peer?(
|
|
144
|
-
|
|
145
|
-
return
|
|
163
|
+
def loopback_peer?(env)
|
|
164
|
+
recorded = env['otto.peer_loopback']
|
|
165
|
+
return recorded if [true, false].include?(recorded)
|
|
146
166
|
|
|
147
|
-
|
|
148
|
-
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
149
|
-
false
|
|
167
|
+
Otto::Utils.loopback_address?(env['REMOTE_ADDR'])
|
|
150
168
|
end
|
|
151
169
|
|
|
152
170
|
# @return [Array] 401 Rack response tuple
|
|
@@ -100,6 +100,98 @@ class Otto
|
|
|
100
100
|
@mcp_server.enable!(mcp_options)
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
# Validate and freeze the lambda handler registry supplied at construction
|
|
104
|
+
# (issue #41, AC#3). Security: only pre-registered callables are accepted;
|
|
105
|
+
# nothing from route files reaches here, so no eval / dynamic code (AC#8).
|
|
106
|
+
def configure_lambda_handlers(opts)
|
|
107
|
+
@option[:lambda_handlers] = validate_lambda_handlers!(opts[:lambda_handlers])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @raise [ArgumentError] naming the offending handler on any invalid entry
|
|
111
|
+
# @return [Hash] frozen registry ({}.freeze when none supplied)
|
|
112
|
+
#
|
|
113
|
+
# Keys are normalized to Strings so lookups from +&name+ routes (whose
|
|
114
|
+
# target is always a String parsed from the route file) resolve regardless
|
|
115
|
+
# of whether the caller registered handlers under Symbol or String keys.
|
|
116
|
+
# A fresh Hash is built and frozen so the caller's input object is never
|
|
117
|
+
# mutated in place.
|
|
118
|
+
def validate_lambda_handlers!(handlers)
|
|
119
|
+
return {}.freeze if handlers.nil?
|
|
120
|
+
|
|
121
|
+
unless handlers.is_a?(Hash)
|
|
122
|
+
raise ArgumentError,
|
|
123
|
+
"Otto :lambda_handlers must be a Hash of name => callable, got #{handlers.class}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
registry = {}
|
|
127
|
+
|
|
128
|
+
handlers.each do |name, handler|
|
|
129
|
+
key = name.to_s
|
|
130
|
+
if key.strip.empty?
|
|
131
|
+
raise ArgumentError,
|
|
132
|
+
"Lambda handler name #{name.inspect} is blank " \
|
|
133
|
+
'(expected a non-empty name matching the &handler_name route target)'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if registry.key?(key)
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Lambda handler name #{key.inspect} is registered more than once " \
|
|
139
|
+
'(String and Symbol keys collide once normalized to a String)'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
unless handler.respond_to?(:call)
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"Lambda handler '#{key}' is not callable (expected an object " \
|
|
145
|
+
"responding to #call, got #{handler.class})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
unless lambda_handler_accepts_three?(handler)
|
|
149
|
+
raise ArgumentError,
|
|
150
|
+
"Lambda handler '#{key}' has invalid arity " \
|
|
151
|
+
'(must accept 3 arguments: req, res, extra_params)'
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
registry[key] = handler
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
registry.freeze
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# True if +handler+ can be invoked with exactly three positional arguments.
|
|
161
|
+
#
|
|
162
|
+
# Reflects on the callable's #parameters rather than #arity so that:
|
|
163
|
+
# * non-Proc/Method callables (a plain object with #call) are supported
|
|
164
|
+
# without ever calling #arity, which they need not define (BUG A); and
|
|
165
|
+
# * optional-arg forms that cannot actually take 3 positional args are
|
|
166
|
+
# rejected instead of blanket-accepted by a negative arity (BUG B) --
|
|
167
|
+
# e.g. ->(a=1){} (accepts 0..1) and ->(a,b=1){} (accepts 1..2).
|
|
168
|
+
#
|
|
169
|
+
# Accepts req/opt/rest combinations that admit 3 positionals; rejects
|
|
170
|
+
# anything requiring more than 3 or unable to reach 3.
|
|
171
|
+
#
|
|
172
|
+
# @api private
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def lambda_handler_accepts_three?(handler)
|
|
175
|
+
callable =
|
|
176
|
+
if handler.is_a?(Proc) || handler.is_a?(Method)
|
|
177
|
+
handler
|
|
178
|
+
else
|
|
179
|
+
handler.method(:call)
|
|
180
|
+
end
|
|
181
|
+
params = callable.parameters
|
|
182
|
+
required = params.count { |(type, _)| type == :req }
|
|
183
|
+
optional = params.count { |(type, _)| type == :opt }
|
|
184
|
+
has_rest = params.any? { |(type, _)| type == :rest }
|
|
185
|
+
|
|
186
|
+
return false if required > 3
|
|
187
|
+
|
|
188
|
+
has_rest || (required + optional) >= 3
|
|
189
|
+
rescue NameError, NoMethodError
|
|
190
|
+
# A pathological callable whose #method(:call) reflection blows up still
|
|
191
|
+
# yields a clean, handler-named ArgumentError from the caller.
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
|
|
103
195
|
# Configure locale settings for the application
|
|
104
196
|
#
|
|
105
197
|
# @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
|
|
@@ -190,8 +282,18 @@ class Otto
|
|
|
190
282
|
# Deep freeze route structures (prevent modification of nested hashes/arrays)
|
|
191
283
|
deep_freeze_value(@routes) if @routes
|
|
192
284
|
deep_freeze_value(@routes_literal) if @routes_literal
|
|
193
|
-
|
|
285
|
+
# @routes_static is intentionally NOT deep-frozen: its :GET entry is a
|
|
286
|
+
# Concurrent::Map that lazy static-file discovery writes into at
|
|
287
|
+
# request time (Core::Router#handle_request, Core::FileSafety#add_static_path),
|
|
288
|
+
# after this method has already run. Deep-freezing it would turn the
|
|
289
|
+
# first request for any as-yet-uncached static file into a
|
|
290
|
+
# FrozenError / 500 in production (issue #185). The outer hash is
|
|
291
|
+
# still shallow-frozen so its verb-key structure (currently just
|
|
292
|
+
# :GET) can't be altered post-freeze, while the Concurrent::Map value
|
|
293
|
+
# stays writable.
|
|
294
|
+
@routes_static.freeze if @routes_static && !@routes_static.frozen?
|
|
194
295
|
deep_freeze_value(@route_definitions) if @route_definitions
|
|
296
|
+
deep_freeze_value(@routes_by_definition) if @routes_by_definition
|
|
195
297
|
|
|
196
298
|
@configuration_frozen = true
|
|
197
299
|
|