otto 2.2.0 → 2.3.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 +2 -2
- data/.github/workflows/claude-code-review.yml +6 -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/CHANGELOG.rst +164 -0
- data/Gemfile.lock +1 -3
- data/docs/migrating/v2.3.0.md +241 -0
- data/lib/otto/core/configuration.rb +4 -0
- data/lib/otto/env_keys.rb +21 -0
- data/lib/otto/helpers/validation.rb +3 -5
- data/lib/otto/logging_helpers.rb +2 -2
- data/lib/otto/mcp/auth/token.rb +12 -1
- data/lib/otto/mcp/registry.rb +3 -1
- data/lib/otto/mcp/server.rb +2 -1
- data/lib/otto/request.rb +64 -64
- data/lib/otto/route.rb +3 -43
- data/lib/otto/route_handlers/base.rb +3 -14
- data/lib/otto/security/authentication/route_auth_wrapper.rb +2 -2
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +13 -1
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -3
- data/lib/otto/security/config.rb +285 -32
- data/lib/otto/security/configurator.rb +15 -0
- data/lib/otto/security/constant_resolver.rb +73 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +3 -2
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +36 -52
- data/lib/otto/security/middleware/validation_middleware.rb +6 -15
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +170 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -1
- data/otto.gemspec +8 -1
- metadata +3 -21
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50aa75316f4a3f73a0784cf78fb29e05c030901fa75ac1b8285a6ec0ad89fc3b
|
|
4
|
+
data.tar.gz: 38c18d2dc357589e1377a5df0cecd0de26370b002061d9bde6d66994b4990610
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5f9c0693f83c423a77b20ac88c6af74db6c3d4a9ea345304085b31249a1d20537d64d5c091c5ffb5f6382bfd3fe291d4bcc85f5949928ef09ab8ae74fe09c3b
|
|
7
|
+
data.tar.gz: 29fb704139356b0e0df805567e761593b10faf519f7977087425f9752711dfe4315b41266d864c071779f1e37b55e1a9d40c454c74f5973f728f3f1e1ff1bb97
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -63,7 +63,7 @@ jobs:
|
|
|
63
63
|
lockfile: "unlocked"
|
|
64
64
|
|
|
65
65
|
steps:
|
|
66
|
-
- uses: actions/checkout@v6
|
|
66
|
+
- uses: actions/checkout@v6.0.3
|
|
67
67
|
- name: Set up Ruby
|
|
68
68
|
uses: ruby/setup-ruby@v1
|
|
69
69
|
continue-on-error: ${{ matrix.experimental }}
|
|
@@ -74,7 +74,7 @@ jobs:
|
|
|
74
74
|
bundler-cache: ${{ !matrix.experimental && matrix.lockfile == 'locked' }}
|
|
75
75
|
|
|
76
76
|
- name: Setup tmate session
|
|
77
|
-
uses: mxschmitt/action-tmate@
|
|
77
|
+
uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3
|
|
78
78
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
|
|
79
79
|
with:
|
|
80
80
|
detached: true
|
|
@@ -27,7 +27,7 @@ jobs:
|
|
|
27
27
|
|
|
28
28
|
steps:
|
|
29
29
|
- name: Checkout repository
|
|
30
|
-
uses: actions/checkout@v6
|
|
30
|
+
uses: actions/checkout@v6.0.3
|
|
31
31
|
with:
|
|
32
32
|
fetch-depth: 1
|
|
33
33
|
|
|
@@ -37,6 +37,11 @@ jobs:
|
|
|
37
37
|
with:
|
|
38
38
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
39
39
|
|
|
40
|
+
# Fall back to Sonnet when the primary model is unavailable/overloaded.
|
|
41
|
+
# Without this, an unavailable primary surfaces as "Claude encountered
|
|
42
|
+
# an error" and fails the claude-review check.
|
|
43
|
+
fallback_model: "claude-sonnet-4-6"
|
|
44
|
+
|
|
40
45
|
# Direct prompt for automated review (no @claude mention needed)
|
|
41
46
|
direct_prompt: |
|
|
42
47
|
Please review this pull request and provide feedback on:
|
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
|
|
22
22
|
steps:
|
|
23
23
|
- name: Checkout code
|
|
24
|
-
uses: actions/checkout@v6
|
|
24
|
+
uses: actions/checkout@v6.0.3
|
|
25
25
|
|
|
26
26
|
- name: Set up Ruby
|
|
27
27
|
uses: ruby/setup-ruby@v1
|
|
@@ -88,7 +88,7 @@ jobs:
|
|
|
88
88
|
|
|
89
89
|
steps:
|
|
90
90
|
- name: Checkout code
|
|
91
|
-
uses: actions/checkout@v6
|
|
91
|
+
uses: actions/checkout@v6.0.3
|
|
92
92
|
|
|
93
93
|
- name: Set up Ruby
|
|
94
94
|
uses: ruby/setup-ruby@v1
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,170 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
|
|
|
7
7
|
|
|
8
8
|
<!--scriv-insert-here-->
|
|
9
9
|
|
|
10
|
+
.. _changelog-2.3.0:
|
|
11
|
+
|
|
12
|
+
2.3.0 — 2026-06-21
|
|
13
|
+
==================
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- Count-based trusted-proxy resolution ("trust the last N hops"), the Express
|
|
19
|
+
``trust proxy = N`` primitive, via a new
|
|
20
|
+
``Otto::Security::Config#trusted_proxy_depth`` accessor (Integer, default
|
|
21
|
+
``nil``). ``nil`` / ``0`` keeps the existing CIDR-walk; ``>= 1`` enables depth
|
|
22
|
+
mode. This is the sound model for non-enumerable proxy tiers (Fly, cloud load
|
|
23
|
+
balancers, dynamic reverse proxies) whose addresses cannot be listed as CIDRs.
|
|
24
|
+
Resolution flows through the shared ``Otto::Utils.resolve_client_ip``, so the
|
|
25
|
+
canonical ``env['otto.client_ip']`` (masking, idempotency, "read everywhere")
|
|
26
|
+
and the standalone ``Request#client_ipaddress`` fallback both honor depth with
|
|
27
|
+
no further wiring. Settable through ``Otto::Security::Configurator#configure``
|
|
28
|
+
(``trusted_proxy_depth:``) and the ``trusted_proxy_depth`` option of
|
|
29
|
+
``configure_security``. Depth resolves the client *IP* only; it is decoupled
|
|
30
|
+
from proxy proto-trust — ``env['otto.via_trusted_proxy']`` (and therefore
|
|
31
|
+
``Otto::Request#secure?`` honoring ``X-Forwarded-Proto`` / ``X-Scheme``) remains
|
|
32
|
+
the trusted-proxy *identity* check and is never derived from hop depth,
|
|
33
|
+
matching the downstream OneTimeSecret behavior. (onetimesecret#3436,
|
|
34
|
+
onetimesecret#3116)
|
|
35
|
+
|
|
36
|
+
Changed
|
|
37
|
+
-------
|
|
38
|
+
|
|
39
|
+
- ``Otto::Security::Config#trusted_proxy?`` now matches string entries with
|
|
40
|
+
proper ``IPAddr`` CIDR containment for both IPv4 and IPv6, replacing the
|
|
41
|
+
previous ``==`` / ``start_with?`` text matching. Bare hosts (e.g.
|
|
42
|
+
``192.168.1.1``) match only exactly, and CIDR ranges (e.g. ``10.0.0.0/8``)
|
|
43
|
+
now actually match contained addresses. Non-IP entries (e.g. ``172.16.``)
|
|
44
|
+
still fall back to the legacy prefix match, and ``Regexp`` entries are
|
|
45
|
+
unchanged. This is a behavior change: addresses that were previously
|
|
46
|
+
matched only because they shared a textual prefix are no longer treated as
|
|
47
|
+
trusted. (otto#58, onetimesecret#3436)
|
|
48
|
+
- ``IPPrivacyMiddleware`` now resolves the client IP once into a canonical
|
|
49
|
+
``env['otto.client_ip']`` ("resolve once, read everywhere") and is
|
|
50
|
+
idempotent: a second pass (e.g. when the middleware is mounted both at the
|
|
51
|
+
app and router levels) yields instead of re-resolving and double-masking.
|
|
52
|
+
- ``Otto::Request#ip`` and ``#client_ipaddress`` now prefer
|
|
53
|
+
``env['otto.client_ip']`` when present, falling back to Rack's native
|
|
54
|
+
resolution when the middleware has not run. Downstream code no longer
|
|
55
|
+
depends on ``REMOTE_ADDR`` / ``X-Forwarded-For`` rewriting being
|
|
56
|
+
load-bearing.
|
|
57
|
+
- ``Otto::Request#secure?`` now authorizes ``X-Forwarded-Proto`` /
|
|
58
|
+
``X-Scheme`` from a canonical, leak-free ``env['otto.via_trusted_proxy']``
|
|
59
|
+
flag recorded by ``IPPrivacyMiddleware`` before masking, instead of
|
|
60
|
+
re-deriving trust from the (now masked) ``REMOTE_ADDR``. It falls back to
|
|
61
|
+
the previous behavior when the middleware has not run.
|
|
62
|
+
- ``add_trusted_proxy`` now logs a warning when given a string that is not a
|
|
63
|
+
valid IP or CIDR (e.g. ``'172.16.'``), since such entries use legacy
|
|
64
|
+
string-prefix matching; prefer a CIDR range.
|
|
65
|
+
- IP validation and port-stripping were consolidated into
|
|
66
|
+
``Otto::Utils.normalize_ip`` / ``strip_ip_port`` (previously duplicated in
|
|
67
|
+
``IPPrivacyMiddleware`` and ``Otto::Request``).
|
|
68
|
+
- Trusted-proxy string entries are now parsed to ``IPAddr`` once at
|
|
69
|
+
registration (in ``add_trusted_proxy``) and cached, so ``trusted_proxy?``
|
|
70
|
+
no longer re-parses each entry on every request.
|
|
71
|
+
- Client-IP resolution from forwarded headers is now a single shared
|
|
72
|
+
``Otto::Utils.resolve_client_ip`` used by both ``IPPrivacyMiddleware``
|
|
73
|
+
("resolve once") and ``Otto::Request#client_ipaddress`` (its no-middleware
|
|
74
|
+
fallback), so the two paths can no longer disagree on which headers to trust
|
|
75
|
+
or how to walk a proxy chain. The standalone ``Request`` fallback now walks
|
|
76
|
+
the forwarded chain skipping trusted proxies (matching the middleware) and
|
|
77
|
+
consults ``X-Client-IP`` instead of the legacy ``Client-IP`` header.
|
|
78
|
+
|
|
79
|
+
- ``RouteHandlers::BaseHandler`` raises ``ArgumentError`` (was ``NameError``)
|
|
80
|
+
for an unresolvable handler class name. (otto#147)
|
|
81
|
+
- ``Otto.logger`` never returns ``nil`` (lazy ``$stdout`` default); assign
|
|
82
|
+
``Otto.logger=`` to override or silence. (otto#147)
|
|
83
|
+
|
|
84
|
+
Removed
|
|
85
|
+
-------
|
|
86
|
+
|
|
87
|
+
- SQL-injection pattern matching from input validation
|
|
88
|
+
(``ValidationMiddleware::SQL_INJECTION_PATTERNS`` and related checks). It
|
|
89
|
+
produced false positives and was trivially bypassable; defend against SQL
|
|
90
|
+
injection with parameterized queries at the data-access layer. (otto#147)
|
|
91
|
+
|
|
92
|
+
Fixed
|
|
93
|
+
-----
|
|
94
|
+
|
|
95
|
+
- IPv6 addresses are no longer truncated during proxy resolution.
|
|
96
|
+
``validate_ip_address`` previously did ``ip.split(':').first``, collapsing
|
|
97
|
+
an IPv6 address to its first hextet; it now uses ``IPAddr`` validation with
|
|
98
|
+
IPv6-safe port stripping (bracketed ``[2001:db8::1]:443`` and IPv4
|
|
99
|
+
``host:port``). IPv6 clients behind trusted proxies now resolve and mask
|
|
100
|
+
correctly. (onetimesecret#3436)
|
|
101
|
+
- ``Otto::Request#redacted_fingerprint``, ``#geo_country``, ``#hashed_ip``
|
|
102
|
+
and ``#masked_ip`` (plus ``NoAuthStrategy`` metadata and
|
|
103
|
+
``LoggingHelpers`` country) read the canonical ``otto.privacy.*`` env keys
|
|
104
|
+
the middleware actually writes; they previously read un-namespaced keys
|
|
105
|
+
that were never set and so always returned ``nil``.
|
|
106
|
+
- ``Otto::Request#private_ip?`` (and therefore ``#local_or_private_ip?`` /
|
|
107
|
+
``#local?``) is now IPv4- **and** IPv6-aware via ``Otto::Utils.private_ip?``.
|
|
108
|
+
It recognizes IPv6 loopback (``::1``), unique-local (``fc00::/7``),
|
|
109
|
+
link-local (``fe80::/10``), multicast and unspecified addresses; the previous
|
|
110
|
+
IPv4-only regex silently classified every IPv6 address as public.
|
|
111
|
+
- Anonymous and auth-failure metadata (``NoAuthStrategy``,
|
|
112
|
+
``RouteAuthWrapper``) and ``LoggingHelpers.request_context`` now record the
|
|
113
|
+
canonical ``otto.client_ip`` (falling back to ``REMOTE_ADDR``), so the real
|
|
114
|
+
client — not the connecting proxy — is logged when IP privacy is disabled
|
|
115
|
+
behind a trusted proxy.
|
|
116
|
+
|
|
117
|
+
- The CSRF ``<meta>`` tag is now injected into ``<head>`` tags that carry
|
|
118
|
+
attributes, not only a bare ``<head>``. (otto#147)
|
|
119
|
+
|
|
120
|
+
Security
|
|
121
|
+
--------
|
|
122
|
+
|
|
123
|
+
- Trusted-proxy matching is now correct CIDR containment rather than text
|
|
124
|
+
prefix matching, removing both false positives (e.g. ``192.168.1.100``
|
|
125
|
+
matching the host ``192.168.1.1``) and false negatives (CIDR ranges that
|
|
126
|
+
never matched). ``secure?`` no longer silently fails to trust
|
|
127
|
+
``X-Forwarded-Proto`` behind a TLS-terminating trusted proxy when IP
|
|
128
|
+
privacy is enabled. (onetimesecret#3436)
|
|
129
|
+
|
|
130
|
+
- CSRF tokens are now signed with HMAC-SHA256 keyed by a server-side secret and
|
|
131
|
+
bound to the session id, so they can no longer be self-minted or replayed
|
|
132
|
+
across sessions. Set the secret via ``OTTO_CSRF_SECRET`` or
|
|
133
|
+
``Otto::Security::Config#csrf_secret=``; enabling CSRF in production without
|
|
134
|
+
one now raises instead of silently using a per-process secret. (otto#147)
|
|
135
|
+
- All route/handler class-name resolution goes through
|
|
136
|
+
``Otto::Security::ConstantResolver``, extending the existing format check and
|
|
137
|
+
forbidden-class blocklist to ``RouteHandlers::BaseHandler`` and the MCP
|
|
138
|
+
registry/server (previously unguarded). Forbidden classes reached via a
|
|
139
|
+
namespace prefix or constant inheritance (e.g. ``Object::Kernel``) are now
|
|
140
|
+
rejected as well. (otto#147)
|
|
141
|
+
- MCP bearer tokens and API keys are compared in constant time. (otto#147)
|
|
142
|
+
|
|
143
|
+
- Depth resolution trusts exactly N hops counted from the right of
|
|
144
|
+
``X-Forwarded-For`` plus ``REMOTE_ADDR``, so a forged leftmost forwarded entry
|
|
145
|
+
is never reached. Positions are counted raw (never dropped) so junk padding
|
|
146
|
+
cannot shift the index, and only the selected entry is validated. A chain
|
|
147
|
+
shorter than ``N + 1`` (a request that may have bypassed the proxy tier) or an
|
|
148
|
+
invalid target entry falls back to ``REMOTE_ADDR`` rather than a spoofable
|
|
149
|
+
forwarded value. Depth mode is XFF-only (single-value ``X-Real-IP`` /
|
|
150
|
+
``X-Client-IP`` cannot express a hop chain) and **assumes origin lockdown** —
|
|
151
|
+
the app must be unreachable except through the proxy tier. CIDR-walk and depth
|
|
152
|
+
are mutually exclusive, and ``trusted_proxy_depth`` must be a non-negative
|
|
153
|
+
Integer or ``nil``; both are validated immediately at configuration time (with
|
|
154
|
+
a freeze-time backstop), so an invalid or contradictory setup fails fast.
|
|
155
|
+
|
|
156
|
+
Documentation
|
|
157
|
+
-------------
|
|
158
|
+
|
|
159
|
+
- Extended ``docs/migrating/v2.3.0.md`` with a count-based depth section covering
|
|
160
|
+
when to use depth vs CIDR-walk, the origin-lockdown prerequisite, configuration
|
|
161
|
+
examples, Express parity, and the XFF-only / short-chain / mutual-exclusivity
|
|
162
|
+
semantics.
|
|
163
|
+
|
|
164
|
+
AI Assistance
|
|
165
|
+
-------------
|
|
166
|
+
|
|
167
|
+
- Issue #147 findings triaged, fixed, and verified with AI assistance, including
|
|
168
|
+
an adversarial review that surfaced the namespace-prefix blocklist bypass.
|
|
169
|
+
|
|
170
|
+
- Trusted-proxy depth design review, threat-model analysis (origin-lockdown
|
|
171
|
+
trade vs CIDR enumerability, raw-position counting to defeat XFF padding),
|
|
172
|
+
implementation and test coverage developed with AI pair programming.
|
|
173
|
+
|
|
10
174
|
.. _changelog-2.2.0:
|
|
11
175
|
|
|
12
176
|
2.2.0 — 2026-06-09
|
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
otto (2.
|
|
4
|
+
otto (2.3.0)
|
|
5
5
|
concurrent-ruby (~> 1.3, < 2.0)
|
|
6
|
-
ipaddr (~> 1, < 2.0)
|
|
7
6
|
logger (~> 1, < 2.0)
|
|
8
7
|
loofah (~> 2.20)
|
|
9
8
|
rack (~> 3.1, < 4.0)
|
|
@@ -55,7 +54,6 @@ GEM
|
|
|
55
54
|
erb (6.0.4)
|
|
56
55
|
hana (1.3.7)
|
|
57
56
|
io-console (0.8.2)
|
|
58
|
-
ipaddr (1.2.9)
|
|
59
57
|
irb (1.18.0)
|
|
60
58
|
pp (>= 0.6.0)
|
|
61
59
|
prism (>= 1.3.0)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Otto v2.3.0 Migration Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This release harmonizes IP address and trusted-proxy resolution (the upstream
|
|
6
|
+
work for [onetimesecret#3436](https://github.com/onetimesecret/onetimesecret/issues/3436)).
|
|
7
|
+
The client IP is now resolved **once** into a canonical `env['otto.client_ip']`
|
|
8
|
+
that downstream code reads, trusted-proxy matching uses real CIDR containment,
|
|
9
|
+
and several privacy helpers that silently returned `nil` now work. It also adds
|
|
10
|
+
an additive, opt-in **count-based trusted-proxy depth** mode for proxy tiers
|
|
11
|
+
whose addresses cannot be enumerated as CIDRs (see the *New feature* section
|
|
12
|
+
below). Most apps need no changes; the one behavior change to review is
|
|
13
|
+
trusted-proxy matching.
|
|
14
|
+
|
|
15
|
+
## Breaking / behavior changes
|
|
16
|
+
|
|
17
|
+
### 1. Trusted-proxy matching is now real CIDR containment
|
|
18
|
+
|
|
19
|
+
`Otto::Security::Config#trusted_proxy?` previously compared entries with
|
|
20
|
+
`==` / `String#start_with?`. It now parses entries with `IPAddr` and matches
|
|
21
|
+
by containment for both IPv4 and IPv6.
|
|
22
|
+
|
|
23
|
+
What changes in practice:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
config.add_trusted_proxy('10.0.0.0/8')
|
|
27
|
+
config.trusted_proxy?('10.1.2.3') # was false (CIDR never matched), now true
|
|
28
|
+
|
|
29
|
+
config.add_trusted_proxy('192.168.1.1')
|
|
30
|
+
config.trusted_proxy?('192.168.1.100') # was true (textual prefix), now false
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **CIDR ranges now actually work.** Previously `add_trusted_proxy('10.0.0.0/8')`
|
|
34
|
+
was effectively a no-op because no real IP literally starts with the string
|
|
35
|
+
`'10.0.0.0/8'`. If you relied on a single-IP entry acting as a prefix to
|
|
36
|
+
cover a range, replace it with an explicit CIDR.
|
|
37
|
+
- **Bare hosts match only themselves.** `192.168.1.1` no longer matches
|
|
38
|
+
`192.168.1.100`, `192.168.1.10`, etc.
|
|
39
|
+
- **Non-IP strings still work via the legacy path.** An entry like `'172.16.'`
|
|
40
|
+
that does not parse as an IP/CIDR falls back to the old prefix match, and
|
|
41
|
+
`add_trusted_proxy` now logs a warning suggesting a CIDR (`172.16.0.0/12`).
|
|
42
|
+
- **`Regexp` entries are unchanged.**
|
|
43
|
+
|
|
44
|
+
**Action:** audit your `trusted_proxies` configuration. Convert any single-IP
|
|
45
|
+
or bare-prefix entries that were meant to cover a range into proper CIDR
|
|
46
|
+
notation.
|
|
47
|
+
|
|
48
|
+
### 2. `secure?` trusts forwarded proto via a canonical flag
|
|
49
|
+
|
|
50
|
+
`Otto::Request#secure?` previously decided whether to honor `X-Forwarded-Proto`
|
|
51
|
+
/ `X-Scheme` by checking `trusted_proxy?(REMOTE_ADDR)`. With IP privacy enabled,
|
|
52
|
+
`IPPrivacyMiddleware` rewrites `REMOTE_ADDR` to the masked client IP, so that
|
|
53
|
+
check could no longer see the connecting proxy and `secure?` could wrongly
|
|
54
|
+
return `false` behind a TLS-terminating trusted proxy.
|
|
55
|
+
|
|
56
|
+
The middleware now records the peer-trust decision once (before masking) in a
|
|
57
|
+
leak-free boolean `env['otto.via_trusted_proxy']`, and `secure?` reads it.
|
|
58
|
+
When the middleware has not run (standalone request use), `secure?` falls back
|
|
59
|
+
to its previous behavior. No app changes are required.
|
|
60
|
+
|
|
61
|
+
### 3. Privacy helpers now return values (previously `nil`)
|
|
62
|
+
|
|
63
|
+
These helpers read the un-namespaced env keys that the middleware never set,
|
|
64
|
+
so they always returned `nil`. They now read the canonical `otto.privacy.*`
|
|
65
|
+
keys and return real values when IP privacy is enabled:
|
|
66
|
+
|
|
67
|
+
- `Otto::Request#redacted_fingerprint`
|
|
68
|
+
- `Otto::Request#geo_country`
|
|
69
|
+
- `Otto::Request#hashed_ip`
|
|
70
|
+
- `Otto::Request#masked_ip`
|
|
71
|
+
|
|
72
|
+
If you worked around these returning `nil`, you can now use them directly.
|
|
73
|
+
|
|
74
|
+
### 4. `private_ip?` is now IPv6-aware
|
|
75
|
+
|
|
76
|
+
`Otto::Request#private_ip?` (used by `#local_or_private_ip?` and `#local?`) was
|
|
77
|
+
an IPv4-only regex that classified **every** IPv6 address — including `::1` and
|
|
78
|
+
ULA `fc00::/7` — as public. It now delegates to `Otto::Utils.private_ip?`, which
|
|
79
|
+
recognizes IPv6 loopback, unique-local, link-local, multicast and unspecified
|
|
80
|
+
addresses (and folds IPv4-mapped IPv6). IPv4 behavior is preserved for the
|
|
81
|
+
RFC1918, link-local and unspecified ranges; two IPv4 cases are now *also*
|
|
82
|
+
classified non-public that the old regex missed: loopback `127.0.0.0/8` and the
|
|
83
|
+
full multicast block `224.0.0.0/4` (the old regex only matched `224.0.0.0/8`).
|
|
84
|
+
Both are harmless — `#local_or_private_ip?` already special-cased `127.0.0.1`,
|
|
85
|
+
and `private_ip?` no longer participates in client-IP resolution.
|
|
86
|
+
|
|
87
|
+
### 5. Standalone `client_ipaddress` matches the middleware
|
|
88
|
+
|
|
89
|
+
`Otto::Request#client_ipaddress` and `IPPrivacyMiddleware` now share one
|
|
90
|
+
resolver (`Otto::Utils.resolve_client_ip`). With the middleware mounted (the
|
|
91
|
+
normal case) `client_ipaddress` returns the canonical `env['otto.client_ip']`
|
|
92
|
+
unchanged. **Without** the middleware, its fallback now walks the
|
|
93
|
+
`X-Forwarded-For` chain skipping trusted proxies (instead of skipping private
|
|
94
|
+
IPs) and consults `X-Client-IP` rather than the legacy `Client-IP` header —
|
|
95
|
+
making the no-middleware path agree with production. This only affects apps that
|
|
96
|
+
use `Otto::Request` standalone without the Otto middleware stack.
|
|
97
|
+
|
|
98
|
+
## New / canonical env keys
|
|
99
|
+
|
|
100
|
+
| Key | Type | Meaning |
|
|
101
|
+
|-----|------|---------|
|
|
102
|
+
| `otto.client_ip` | String | Canonical client IP, resolved once. Masked when privacy is enabled; resolved real IP when disabled or exempt. Read by `Request#ip` / `#client_ipaddress`. |
|
|
103
|
+
| `otto.via_trusted_proxy` | Boolean | Whether the request arrived via a trusted proxy, decided before masking. Read by `Request#secure?`. |
|
|
104
|
+
|
|
105
|
+
The privacy data keys remain `otto.privacy.{fingerprint,masked_ip,hashed_ip,geo_country}`.
|
|
106
|
+
|
|
107
|
+
## New feature: count-based trusted-proxy depth (additive, opt-in)
|
|
108
|
+
|
|
109
|
+
New in 2.3.0 alongside the harmonization above: count-based trusted-proxy
|
|
110
|
+
resolution ("trust the last N hops"), the Express `trust proxy = N` primitive,
|
|
111
|
+
for proxy tiers whose addresses cannot be enumerated as CIDRs. It is
|
|
112
|
+
**additive** — `trusted_proxy_depth` defaults to `nil`, leaving CIDR-walk
|
|
113
|
+
behavior unchanged. This is the upstream landing point for the depth logic
|
|
114
|
+
previously carried in
|
|
115
|
+
[onetimesecret#3436](https://github.com/onetimesecret/onetimesecret/issues/3436)
|
|
116
|
+
and the `ConfigureTrustedProxy` monkeypatch
|
|
117
|
+
([onetimesecret#3116](https://github.com/onetimesecret/onetimesecret/issues/3116)).
|
|
118
|
+
|
|
119
|
+
### When to use depth vs CIDR-walk
|
|
120
|
+
|
|
121
|
+
| Use… | When… |
|
|
122
|
+
|------|-------|
|
|
123
|
+
| **CIDR-walk** (`add_trusted_proxy`, the default) | Your proxy IPs are **enumerable** — a fixed set of load balancers / reverse proxies you can list as IPs or CIDR ranges. The client is the first address in the forwarded chain that is not itself a trusted proxy. |
|
|
124
|
+
| **Depth** (`trusted_proxy_depth = N`) | Your proxy tier is **non-enumerable** — Fly, cloud load balancers, dynamic/autoscaling reverse proxies whose addresses you cannot pin down. You instead know the **fixed number of hops** between the client and your app. |
|
|
125
|
+
|
|
126
|
+
The two modes are **mutually exclusive**. Configuring both `trusted_proxies` and
|
|
127
|
+
`trusted_proxy_depth >= 1` raises an `ArgumentError` immediately (the moment the
|
|
128
|
+
second of the two is set), with a freeze-time backstop, so a contradictory setup
|
|
129
|
+
fails fast rather than silently picking one.
|
|
130
|
+
|
|
131
|
+
### Configuration
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# Non-enumerable single-proxy edge (e.g. one reverse proxy in front of the app):
|
|
135
|
+
otto.security_config.trusted_proxy_depth = 1
|
|
136
|
+
|
|
137
|
+
# Two hops (e.g. cloud LB -> app proxy -> app):
|
|
138
|
+
otto.security_config.trusted_proxy_depth = 2
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Or via the security facade / options, alongside the other security knobs:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
otto.security.configure(trusted_proxy_depth: 1)
|
|
145
|
+
|
|
146
|
+
# or at construction (a top-level option, like `trusted_proxies`):
|
|
147
|
+
Otto.new(routes, trusted_proxy_depth: 1)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`nil` or `0` disables depth mode (CIDR-walk applies). The accessor validates the
|
|
151
|
+
mutual-exclusion rule on assignment and raises `FrozenError` once the
|
|
152
|
+
configuration is frozen, like the other security scalars.
|
|
153
|
+
|
|
154
|
+
### How depth resolution works
|
|
155
|
+
|
|
156
|
+
The resolver builds a chain of `X-Forwarded-For` (left = client … right =
|
|
157
|
+
nearest proxy) followed by `REMOTE_ADDR` (the direct peer), and trusts exactly
|
|
158
|
+
`N` hops from the **right**:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
chain = X-Forwarded-For (left → right) + [REMOTE_ADDR]
|
|
162
|
+
client = chain[-(N + 1)] # == Express addrs[N]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Express parity.** `trusted_proxy_depth = N` matches Express / `proxy-addr`
|
|
166
|
+
`trust proxy = N`: both trust `N` hops from the connecting peer inward and return
|
|
167
|
+
the next address as the client. When the chain is long enough, Otto's
|
|
168
|
+
`chain[-(N+1)]` is exactly Express's `addrs[N]`.
|
|
169
|
+
|
|
170
|
+
**Robust against `X-Forwarded-For` padding.** Because hops are counted from the
|
|
171
|
+
right, a forged **leftmost** entry is never reached. With `depth = 1`, a request
|
|
172
|
+
arriving as `X-Forwarded-For: 9.9.9.9, 203.0.113.50` (where `9.9.9.9` is
|
|
173
|
+
attacker-supplied and `203.0.113.50` is appended by the proxy) resolves to
|
|
174
|
+
`203.0.113.50`. Positions are counted **raw** — entries are never dropped before
|
|
175
|
+
counting, so an attacker cannot pad the header with junk/invalid entries to shift
|
|
176
|
+
the index. Only the single selected entry is validated.
|
|
177
|
+
|
|
178
|
+
**Short-chain and invalid-target fallback.** If the chain is shorter than
|
|
179
|
+
`N + 1` (a request that may have **bypassed** the proxy tier), or the selected
|
|
180
|
+
entry is not a valid IP, the resolver returns `REMOTE_ADDR` rather than a
|
|
181
|
+
spoofable forwarded value. This is intentionally **stricter than Express**, which
|
|
182
|
+
returns the leftmost (most spoofable) forwarded entry in that case.
|
|
183
|
+
|
|
184
|
+
**`X-Forwarded-For` only.** Depth mode consults **only** `X-Forwarded-For`.
|
|
185
|
+
`X-Real-IP` and `X-Client-IP` are single-value headers that cannot express a hop
|
|
186
|
+
chain, so they are ignored in depth mode (CIDR-walk still consults them).
|
|
187
|
+
|
|
188
|
+
**`secure?` is independent of depth.** Depth mode resolves the client **IP**
|
|
189
|
+
only; it does **not** grant proxy trust for `X-Forwarded-Proto` / `X-Scheme`.
|
|
190
|
+
`env['otto.via_trusted_proxy']` — which `Otto::Request#secure?` consults to honor
|
|
191
|
+
a forwarded proto — is derived solely from the trusted-proxy *identity* check
|
|
192
|
+
(does `REMOTE_ADDR` match a configured `trusted_proxies` CIDR?), never from hop
|
|
193
|
+
depth. Because depth mode and `trusted_proxies` are mutually exclusive, that
|
|
194
|
+
check is `false` under depth, so `secure?` does not honor a forwarded proto and
|
|
195
|
+
reflects only a direct TLS connection (`HTTPS=on` / port 443). This mirrors the
|
|
196
|
+
downstream (OneTimeSecret) behavior: proto-trust is never derived from depth.
|
|
197
|
+
|
|
198
|
+
### Security prerequisite: origin lockdown
|
|
199
|
+
|
|
200
|
+
> **Depth trust assumes your app is unreachable except through the proxy tier.**
|
|
201
|
+
|
|
202
|
+
This is the inherent trade-off versus CIDR-walk: depth relies on a fixed network
|
|
203
|
+
**topology** instead of enumerable proxy **addresses**. If a client can reach
|
|
204
|
+
your app directly (origin not locked down), it can pad `X-Forwarded-For` so that
|
|
205
|
+
a forged value lands at `chain[-(N+1)]`, spoofing the resolved client IP. (Proto
|
|
206
|
+
trust is unaffected — depth never feeds `secure?` — but the resolved IP is only
|
|
207
|
+
as trustworthy as the lockdown.)
|
|
208
|
+
|
|
209
|
+
Before enabling depth, ensure the origin only accepts connections from the proxy
|
|
210
|
+
tier (private networking, security groups, an authenticating header the proxy
|
|
211
|
+
injects, etc.). If you can enumerate your proxies instead, prefer CIDR-walk.
|
|
212
|
+
|
|
213
|
+
### Migrating from OneTimeSecret depth (`ConfigureTrustedProxy`)
|
|
214
|
+
|
|
215
|
+
If you are collapsing OneTimeSecret's `ClientIpHelpers` / `ConfigureTrustedProxy`
|
|
216
|
+
depth logic onto this resolver, note two intentional differences:
|
|
217
|
+
|
|
218
|
+
- **Off-by-one (Otto counts the peer).** Otto's chain is `X-Forwarded-For`
|
|
219
|
+
**plus** `REMOTE_ADDR`, so it is one hop longer than OTS's XFF-only chain. To
|
|
220
|
+
resolve the same client, Otto's depth must be **one higher** than the
|
|
221
|
+
operator's OTS `depth:`. When the YAML→Otto translator is built, map
|
|
222
|
+
**`trusted_proxy_depth = ots_depth + 1`** so existing `depth:` values keep
|
|
223
|
+
their meaning. Keep a parity regression test on the OTS side to lock this
|
|
224
|
+
mapping.
|
|
225
|
+
|
|
226
|
+
- **Stricter short-chain behavior (kept on purpose).** When the chain is shorter
|
|
227
|
+
than `N + 1`, Otto returns `REMOTE_ADDR` (the peer), whereas OTS returned the
|
|
228
|
+
leftmost — spoofable — `X-Forwarded-For` entry. Otto's behavior is the safer
|
|
229
|
+
one and is **not** reconciled down to OTS; expect a short-chain request to
|
|
230
|
+
resolve to the proxy peer rather than a forwarded value, and document this when
|
|
231
|
+
migrating.
|
|
232
|
+
|
|
233
|
+
## No action required for
|
|
234
|
+
|
|
235
|
+
- Apps that pass `trusted_proxies` as CIDR ranges or `Regexp`.
|
|
236
|
+
- Apps that read `req.ip` / `req.client_ipaddress` (now backed by the canonical
|
|
237
|
+
value, same masked result).
|
|
238
|
+
- Apps relying on IPv6 — IPv6 client resolution behind trusted proxies is now
|
|
239
|
+
correct (it was previously truncated to the first hextet).
|
|
240
|
+
- Apps that do not set `trusted_proxy_depth` (default `nil` → unchanged
|
|
241
|
+
CIDR-walk behavior; depth mode is entirely opt-in).
|
|
@@ -57,6 +57,10 @@ class Otto
|
|
|
57
57
|
# Add trusted proxies if provided
|
|
58
58
|
Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) } if opts[:trusted_proxies]
|
|
59
59
|
|
|
60
|
+
# Set count-based trusted-proxy depth if provided (mutually exclusive
|
|
61
|
+
# with trusted_proxies; conflict validated at configuration freeze).
|
|
62
|
+
@security_config.trusted_proxy_depth = opts[:trusted_proxy_depth] if opts[:trusted_proxy_depth]
|
|
63
|
+
|
|
60
64
|
# Set custom security headers
|
|
61
65
|
return unless opts[:security_headers]
|
|
62
66
|
|
data/lib/otto/env_keys.rb
CHANGED
|
@@ -61,6 +61,17 @@ class Otto
|
|
|
61
61
|
# Used by: All security middleware (CSRF, Headers, Validation)
|
|
62
62
|
SECURITY_CONFIG = 'otto.security_config'
|
|
63
63
|
|
|
64
|
+
# Whether the request arrived via a trusted proxy.
|
|
65
|
+
# Type: Boolean
|
|
66
|
+
# Set by: IPPrivacyMiddleware (every request, evaluated on the original
|
|
67
|
+
# peer BEFORE REMOTE_ADDR is masked). This is the trusted-proxy identity
|
|
68
|
+
# check (does REMOTE_ADDR match a configured trusted_proxies CIDR?) — it is
|
|
69
|
+
# independent of count-based depth mode, which resolves the client IP but
|
|
70
|
+
# never grants proxy trust for forwarded proto.
|
|
71
|
+
# Used by: Otto::Request#secure? to authorize X-Forwarded-Proto / X-Scheme
|
|
72
|
+
# without depending on the (masked) REMOTE_ADDR
|
|
73
|
+
VIA_TRUSTED_PROXY = 'otto.via_trusted_proxy'
|
|
74
|
+
|
|
64
75
|
# =========================================================================
|
|
65
76
|
# LOCALIZATION (I18N)
|
|
66
77
|
# =========================================================================
|
|
@@ -103,6 +114,16 @@ class Otto
|
|
|
103
114
|
# PRIVACY (IP MASKING)
|
|
104
115
|
# =========================================================================
|
|
105
116
|
|
|
117
|
+
# Canonical client IP, resolved once early by IPPrivacyMiddleware
|
|
118
|
+
# ("resolve once, read everywhere"). Downstream code (client_ipaddress,
|
|
119
|
+
# Request#ip) reads this instead of re-deriving from REMOTE_ADDR / XFF.
|
|
120
|
+
# Type: String
|
|
121
|
+
# Set by: IPPrivacyMiddleware (every request, all modes)
|
|
122
|
+
# Value: masked IP when privacy enabled; resolved real IP when privacy
|
|
123
|
+
# disabled or the address is exempt (private/localhost)
|
|
124
|
+
# Note: presence also acts as the idempotency guard for the middleware
|
|
125
|
+
CLIENT_IP = 'otto.client_ip'
|
|
126
|
+
|
|
106
127
|
# Privacy-safe masked IP address
|
|
107
128
|
# Type: String (e.g., '192.168.1.0')
|
|
108
129
|
# Set by: IPPrivacyMiddleware
|
|
@@ -38,11 +38,9 @@ class Otto
|
|
|
38
38
|
input_str = sanitized_input
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
#
|
|
42
|
-
ValidationMiddleware
|
|
43
|
-
|
|
44
|
-
end
|
|
45
|
-
|
|
41
|
+
# NOTE: no SQL-injection inspection here on purpose — see
|
|
42
|
+
# ValidationMiddleware. Defend against SQL injection with parameterized
|
|
43
|
+
# queries at the data-access layer, not input blocklists.
|
|
46
44
|
input_str
|
|
47
45
|
end
|
|
48
46
|
|
data/lib/otto/logging_helpers.rb
CHANGED
|
@@ -76,8 +76,8 @@ class Otto
|
|
|
76
76
|
{
|
|
77
77
|
method: env['REQUEST_METHOD'],
|
|
78
78
|
path: env['PATH_INFO'],
|
|
79
|
-
ip: env['REMOTE_ADDR'], #
|
|
80
|
-
country: env['otto.geo_country'],
|
|
79
|
+
ip: env['otto.client_ip'] || env['REMOTE_ADDR'], # Canonical client IP (masked when privacy on)
|
|
80
|
+
country: env['otto.privacy.geo_country'],
|
|
81
81
|
user_agent: env['HTTP_USER_AGENT']&.slice(0, 100), # Already anonymized by IPPrivacyMiddleware
|
|
82
82
|
}.compact
|
|
83
83
|
end
|
data/lib/otto/mcp/auth/token.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
|
+
require 'rack/utils'
|
|
6
7
|
|
|
7
8
|
class Otto
|
|
8
9
|
module MCP
|
|
@@ -17,11 +18,21 @@ class Otto
|
|
|
17
18
|
token = extract_token(env)
|
|
18
19
|
return false unless token
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
valid_token?(token)
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
private
|
|
24
25
|
|
|
26
|
+
def valid_token?(candidate)
|
|
27
|
+
# Constant-time membership: compare against every configured token without
|
|
28
|
+
# short-circuiting on the first match, so neither match position nor
|
|
29
|
+
# membership leaks via timing. Rack::Utils.secure_compare itself is
|
|
30
|
+
# constant-time for equal-length strings.
|
|
31
|
+
@tokens.reduce(false) do |matched, token|
|
|
32
|
+
Rack::Utils.secure_compare(token, candidate) || matched
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
25
36
|
def extract_token(env)
|
|
26
37
|
# Try Authorization header first (Bearer token)
|
|
27
38
|
auth_header = env['HTTP_AUTHORIZATION']
|
data/lib/otto/mcp/registry.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
+
require_relative '../security/constant_resolver'
|
|
6
|
+
|
|
5
7
|
class Otto
|
|
6
8
|
module MCP
|
|
7
9
|
# Registry for managing MCP resources and tools
|
|
@@ -82,7 +84,7 @@ class Otto
|
|
|
82
84
|
klass_name = klass_method[0..-2].join('::')
|
|
83
85
|
method_name = klass_method.last
|
|
84
86
|
|
|
85
|
-
klass =
|
|
87
|
+
klass = Otto::Security::ConstantResolver.safe_const_get(klass_name)
|
|
86
88
|
result = klass.public_send(method_name, arguments, env)
|
|
87
89
|
else
|
|
88
90
|
raise "Invalid tool handler: #{handler}"
|
data/lib/otto/mcp/server.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative 'route_parser'
|
|
|
8
8
|
require_relative 'auth/token'
|
|
9
9
|
require_relative 'schema_validation'
|
|
10
10
|
require_relative 'rate_limiting'
|
|
11
|
+
require_relative '../security/constant_resolver'
|
|
11
12
|
|
|
12
13
|
class Otto
|
|
13
14
|
module MCP
|
|
@@ -124,7 +125,7 @@ class Otto
|
|
|
124
125
|
|
|
125
126
|
# Create resource handler
|
|
126
127
|
handler = lambda do
|
|
127
|
-
klass =
|
|
128
|
+
klass = Otto::Security::ConstantResolver.safe_const_get(klass_name)
|
|
128
129
|
method = klass.method(method_name)
|
|
129
130
|
if method.arity != 0
|
|
130
131
|
raise ArgumentError, "Handler #{klass_name}.#{method_name} must be a zero-arity method for resource #{uri}"
|