otto 2.11.0 → 2.12.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/.rubocop_todo.yml +2 -2
- data/CHANGELOG.rst +100 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +68 -5
- data/docs/guides/testing-guide.md +115 -2
- data/lib/otto/caddy_tls/localhost_guard.rb +6 -6
- data/lib/otto/core/configuration.rb +5 -3
- data/lib/otto/core/router.rb +16 -20
- data/lib/otto/env_keys.rb +2 -1
- data/lib/otto/privacy/config.rb +19 -13
- data/lib/otto/response.rb +5 -2
- data/lib/otto/route.rb +1 -1
- data/lib/otto/route_handlers/base.rb +1 -1
- data/lib/otto/route_handlers/logic_class.rb +86 -34
- data/lib/otto/security/config.rb +349 -303
- data/lib/otto/security/configurator.rb +82 -45
- data/lib/otto/security/core.rb +4 -2
- data/lib/otto/security/csp/report_middleware.rb +17 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +3 -5
- data/lib/otto/security/rate_limiter.rb +8 -5
- data/lib/otto/security/trusted_proxy_config.rb +396 -0
- data/lib/otto/static.rb +7 -6
- data/lib/otto/testing.rb +148 -0
- data/lib/otto/utils.rb +63 -14
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +7 -2
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d60f9cd87b0c935f8c79ecc1aae11f4b9880794d57b0ee5309c53baafcb84dcf
|
|
4
|
+
data.tar.gz: d92225f2ab02c1fbbd9c6ab745c27373aa6f11608f27067ec4485ca4f81878b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf10b7fdf1245e1dd64140ef3a57fc69f2a42197ede66812f02a89f80f291ff3759a7b5245487fd0e7948e4da6534534dbe9d753e0d15cf0ea57366da2c928da
|
|
7
|
+
data.tar.gz: cba8ac893ef636d0e978061304704018907bba315160405561f41592613a250a762ac1a0ebad7de675984101f711f44aabec8693ac3445e4e1e6d9b8f7ffd788
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -74,7 +74,7 @@ jobs:
|
|
|
74
74
|
steps:
|
|
75
75
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
76
76
|
- name: Set up Ruby
|
|
77
|
-
uses: ruby/setup-ruby@
|
|
77
|
+
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
|
78
78
|
continue-on-error: ${{ matrix.experimental }}
|
|
79
79
|
with:
|
|
80
80
|
ruby-version: ${{ matrix.ruby }}
|
|
@@ -61,7 +61,7 @@ jobs:
|
|
|
61
61
|
|
|
62
62
|
- name: Run Claude Code Review
|
|
63
63
|
id: claude-review
|
|
64
|
-
uses: anthropics/claude-code-action@
|
|
64
|
+
uses: anthropics/claude-code-action@7b0b255830a1fab6e602658672acad11c12d841d # v1.0.226
|
|
65
65
|
with:
|
|
66
66
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
67
67
|
|
|
@@ -32,7 +32,7 @@ jobs:
|
|
|
32
32
|
|
|
33
33
|
- name: Run Claude Code
|
|
34
34
|
id: claude
|
|
35
|
-
uses: anthropics/claude-code-action@
|
|
35
|
+
uses: anthropics/claude-code-action@7b0b255830a1fab6e602658672acad11c12d841d # v1.0.226
|
|
36
36
|
with:
|
|
37
37
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
38
38
|
|
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
25
25
|
|
|
26
26
|
- name: Set up Ruby
|
|
27
|
-
uses: ruby/setup-ruby@
|
|
27
|
+
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
|
28
28
|
with:
|
|
29
29
|
ruby-version: 3.4
|
|
30
30
|
|
|
@@ -91,7 +91,7 @@ jobs:
|
|
|
91
91
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
92
92
|
|
|
93
93
|
- name: Set up Ruby
|
|
94
|
-
uses: ruby/setup-ruby@
|
|
94
|
+
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
|
95
95
|
with:
|
|
96
96
|
ruby-version: 3.4
|
|
97
97
|
bundler-cache: true
|
|
@@ -139,7 +139,7 @@ jobs:
|
|
|
139
139
|
persist-credentials: false
|
|
140
140
|
|
|
141
141
|
- name: Set up Ruby
|
|
142
|
-
uses: ruby/setup-ruby@
|
|
142
|
+
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
|
143
143
|
with:
|
|
144
144
|
bundler-cache: true
|
|
145
145
|
# Pinned to 3.3 (oldest non-experimental Ruby in the CI matrix).
|
|
@@ -46,7 +46,7 @@ jobs:
|
|
|
46
46
|
- name: Checkout code
|
|
47
47
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
48
48
|
|
|
49
|
-
- uses: ruby/setup-ruby@
|
|
49
|
+
- uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
|
50
50
|
with:
|
|
51
51
|
ruby-version: ${{ matrix.ruby }}
|
|
52
52
|
bundler-cache: true
|
|
@@ -47,7 +47,7 @@ jobs:
|
|
|
47
47
|
fetch-depth: 0
|
|
48
48
|
|
|
49
49
|
- name: Set up Ruby environment
|
|
50
|
-
uses: ruby/setup-ruby@
|
|
50
|
+
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
|
51
51
|
with:
|
|
52
52
|
ruby-version: '3.4'
|
|
53
53
|
bundler-cache: true
|
data/.rubocop_todo.yml
CHANGED
|
@@ -412,10 +412,10 @@ Metrics/ModuleLength:
|
|
|
412
412
|
Exclude:
|
|
413
413
|
- 'lib/otto/design_system.rb'
|
|
414
414
|
|
|
415
|
-
# Offense count:
|
|
415
|
+
# Offense count: 6
|
|
416
416
|
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
|
|
417
417
|
Metrics/ParameterLists:
|
|
418
|
-
Max:
|
|
418
|
+
Max: 7
|
|
419
419
|
|
|
420
420
|
# Offense count: 46
|
|
421
421
|
# Configuration parameters: AllowedMethods, AllowedPatterns.
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,106 @@ 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.12.0:
|
|
11
|
+
|
|
12
|
+
2.12.0 — 2026-09-26
|
|
13
|
+
===================
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- ``referrer_policy`` sets the Referrer-Policy header on routed responses,
|
|
19
|
+
static files, built-in 404 and 500 responses, and authentication failures.
|
|
20
|
+
Pass one W3C policy token to ``Otto.new`` or assign it before the first
|
|
21
|
+
request. A value a route handler sets on its own response is kept. See
|
|
22
|
+
``docs/guides/testing-guide.md``. (#281)
|
|
23
|
+
|
|
24
|
+
- Logic classes can declare a ``route_params:`` keyword on ``initialize`` to
|
|
25
|
+
receive the router's path captures separately from caller-supplied
|
|
26
|
+
parameters. (#285)
|
|
27
|
+
|
|
28
|
+
- ``Otto::Utils.routing_path(env)`` returns the normalized path the router
|
|
29
|
+
matches, so middleware can judge a request by the same path the router
|
|
30
|
+
dispatches. ``include_mount: true`` includes ``SCRIPT_NAME``. See
|
|
31
|
+
``docs/guides/routing.md``. (#288)
|
|
32
|
+
|
|
33
|
+
- ``require 'otto/testing'`` adds test support for any test framework.
|
|
34
|
+
``Otto::Testing.reset!`` clears Otto's process-global forwarding state
|
|
35
|
+
between tests. ``Otto::Testing.env_for`` and ``resolve_client_ip!`` build a
|
|
36
|
+
Rack env whose ``otto.client_ip`` and ``otto.ip_match`` are resolved by
|
|
37
|
+
``IPPrivacyMiddleware``. See ``docs/guides/testing-guide.md``. (#287)
|
|
38
|
+
|
|
39
|
+
- ``Otto::Security::Config#trusted_proxy_mode`` returns ``:filter``,
|
|
40
|
+
``:depth``, ``:none``, or nil. (#148)
|
|
41
|
+
|
|
42
|
+
Changed
|
|
43
|
+
-------
|
|
44
|
+
|
|
45
|
+
- A ``referrer-policy`` value set through ``security_headers`` is now
|
|
46
|
+
validated like ``referrer_policy``. An unknown token or a comma-separated
|
|
47
|
+
fallback list raises ``ArgumentError`` at configuration time, and the header
|
|
48
|
+
can no longer be removed from ``security_headers``. (#281)
|
|
49
|
+
|
|
50
|
+
- Trusted-proxy state moved into a private
|
|
51
|
+
``Otto::Security::TrustedProxyConfig``, and
|
|
52
|
+
``Security::Configurator#configure`` now takes ``**options``. Public
|
|
53
|
+
methods, accepted keys, and behavior are unchanged. (#148)
|
|
54
|
+
|
|
55
|
+
- The forwarding-family conflict error and the warning for a hand-set
|
|
56
|
+
``otto.client_ip`` now name the ``Otto::Testing`` helper that addresses
|
|
57
|
+
each. ``Otto::Security::Config.reset_rack_forwarding_family_for_testing!``
|
|
58
|
+
remains for existing RSpec callers. (#287)
|
|
59
|
+
|
|
60
|
+
Fixed
|
|
61
|
+
-----
|
|
62
|
+
|
|
63
|
+
- Requests whose ``PATH_INFO`` contains a malformed percent-escape (``%zz``)
|
|
64
|
+
or a raw invalid byte are now routed instead of answering 500. (#288)
|
|
65
|
+
|
|
66
|
+
- The general rate-limit throttle now skips internal paths by the path the
|
|
67
|
+
router dispatches, so ``/%5Fmcp`` is skipped like ``/_mcp``. (#288)
|
|
68
|
+
|
|
69
|
+
- ``Otto::Privacy::Config#profile=`` now sets both ``disabled`` and
|
|
70
|
+
``mask_private_ips`` for every profile. Switching to ``:audit`` previously
|
|
71
|
+
left ``mask_private_ips`` unchanged, so a later ``enable!`` could restore
|
|
72
|
+
``:anonymous`` instead of ``:masked``. (#286)
|
|
73
|
+
|
|
74
|
+
- CSP violation reports now reach the receiver when Otto is mounted under a
|
|
75
|
+
sub-path. The receiver matches ``csp_report_uri`` against ``SCRIPT_NAME`` +
|
|
76
|
+
``PATH_INFO``, so configure the site-absolute path including the mount
|
|
77
|
+
prefix, such as ``/api/csp-report`` under ``map '/api'``. A mount-relative
|
|
78
|
+
value no longer matches. Trailing-slash and percent-encoded spellings of
|
|
79
|
+
the report path are also matched. (#289)
|
|
80
|
+
|
|
81
|
+
Security
|
|
82
|
+
--------
|
|
83
|
+
|
|
84
|
+
- JSON request bodies can no longer override path captures, query
|
|
85
|
+
parameters, or form fields in Logic class parameters; JSON keys now merge
|
|
86
|
+
below them. JSON bodies are no longer parsed for ``GET`` or ``HEAD``
|
|
87
|
+
requests. Before upgrading, check Logic classes that read a JSON body on
|
|
88
|
+
``GET`` or expect a JSON key to replace a query or path value. (#285)
|
|
89
|
+
|
|
90
|
+
Documentation
|
|
91
|
+
-------------
|
|
92
|
+
|
|
93
|
+
- ``docs/guides/routing.md`` covers matching request paths in middleware,
|
|
94
|
+
including apps mounted under a sub-path. ``docs/guides/testing-guide.md``
|
|
95
|
+
covers ``Otto::Testing`` and Referrer-Policy configuration. (#281, #287,
|
|
96
|
+
#288)
|
|
97
|
+
|
|
98
|
+
AI Assistance
|
|
99
|
+
-------------
|
|
100
|
+
|
|
101
|
+
- The ``TrustedProxyConfig`` refactor was implemented with AI assistance and
|
|
102
|
+
checked against the previous release by a differential run of 1,620
|
|
103
|
+
trusted-proxy operation sequences and 29 ``configure`` calls, with no
|
|
104
|
+
difference in outcome. (#148)
|
|
105
|
+
|
|
106
|
+
- ``Otto::Testing`` helpers, specs, and guide updates were written with AI
|
|
107
|
+
assistance. The reset was also exercised without RSpec, under Tryouts and
|
|
108
|
+
Minitest. (#287)
|
|
109
|
+
|
|
10
110
|
.. _changelog-2.11.0:
|
|
11
111
|
|
|
12
112
|
2.11.0 — 2026-09-12
|
data/Gemfile
CHANGED
|
@@ -19,7 +19,7 @@ end
|
|
|
19
19
|
group :development, :test, optional: true do
|
|
20
20
|
# Keep gems that need to be in both environments
|
|
21
21
|
gem 'json_schemer', '~> 2.0'
|
|
22
|
-
gem 'maxmind-db', '~> 1.
|
|
22
|
+
gem 'maxmind-db', '~> 1.5' # Optional geo DB reader; exercised by geo specs
|
|
23
23
|
gem 'rack-attack', '~> 6.7'
|
|
24
24
|
gem 'reek', '~> 6.5'
|
|
25
25
|
end
|
|
@@ -29,7 +29,7 @@ group :development do
|
|
|
29
29
|
gem 'debug'
|
|
30
30
|
gem 'rackup' # Used to boot examples/ apps; not needed by specs
|
|
31
31
|
gem 'rake', '~> 13.4', require: false # Provides `rake release` for release-gem.yml
|
|
32
|
-
gem 'rubocop', '~> 1.
|
|
32
|
+
gem 'rubocop', '~> 1.91.0', require: false
|
|
33
33
|
gem 'rubocop-performance', require: false
|
|
34
34
|
gem 'rubocop-rspec', require: false
|
|
35
35
|
gem 'rubocop-thread_safety', require: false
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
otto (2.
|
|
4
|
+
otto (2.12.0)
|
|
5
5
|
concurrent-ruby (~> 1.3, < 2.0)
|
|
6
6
|
logger (~> 1, < 2.0)
|
|
7
7
|
loofah (~> 2.20)
|
|
@@ -57,7 +57,7 @@ GEM
|
|
|
57
57
|
prism (>= 1.3.0)
|
|
58
58
|
rdoc (>= 4.0.0)
|
|
59
59
|
reline (>= 0.4.2)
|
|
60
|
-
json (
|
|
60
|
+
json (3.0.2)
|
|
61
61
|
json_schemer (2.5.0)
|
|
62
62
|
bigdecimal
|
|
63
63
|
hana (~> 1.3)
|
|
@@ -71,7 +71,7 @@ GEM
|
|
|
71
71
|
loofah (2.25.2)
|
|
72
72
|
crass (~> 1.0.2)
|
|
73
73
|
nokogiri (>= 1.12.0)
|
|
74
|
-
maxmind-db (1.
|
|
74
|
+
maxmind-db (1.5.0)
|
|
75
75
|
minitest (5.26.0)
|
|
76
76
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
|
77
77
|
racc (~> 1.4)
|
|
@@ -144,7 +144,7 @@ GEM
|
|
|
144
144
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
145
145
|
rspec-support (~> 3.13.0)
|
|
146
146
|
rspec-support (3.13.7)
|
|
147
|
-
rubocop (1.
|
|
147
|
+
rubocop (1.91.0)
|
|
148
148
|
json (>= 2.3)
|
|
149
149
|
language_server-protocol (~> 3.17.0.2)
|
|
150
150
|
lint_roller (~> 1.1.0)
|
|
@@ -166,9 +166,9 @@ GEM
|
|
|
166
166
|
lint_roller (~> 1.1)
|
|
167
167
|
regexp_parser (>= 2.0)
|
|
168
168
|
rubocop (~> 1.86, >= 1.86.2)
|
|
169
|
-
rubocop-thread_safety (0.
|
|
169
|
+
rubocop-thread_safety (0.8.0)
|
|
170
170
|
lint_roller (~> 1.1)
|
|
171
|
-
rubocop (~> 1.
|
|
171
|
+
rubocop (~> 1.89)
|
|
172
172
|
rubocop-ast (>= 1.44.0, < 2.0)
|
|
173
173
|
ruby-lsp (0.26.11)
|
|
174
174
|
language_server-protocol (~> 3.17.0)
|
|
@@ -215,7 +215,7 @@ DEPENDENCIES
|
|
|
215
215
|
debug
|
|
216
216
|
json_schemer (~> 2.0)
|
|
217
217
|
kramdown
|
|
218
|
-
maxmind-db (~> 1.
|
|
218
|
+
maxmind-db (~> 1.5)
|
|
219
219
|
otto!
|
|
220
220
|
rack-attack (~> 6.7)
|
|
221
221
|
rack-test
|
|
@@ -223,7 +223,7 @@ DEPENDENCIES
|
|
|
223
223
|
rake (~> 13.4)
|
|
224
224
|
reek (~> 6.5)
|
|
225
225
|
rspec (~> 3.13)
|
|
226
|
-
rubocop (~> 1.
|
|
226
|
+
rubocop (~> 1.91.0)
|
|
227
227
|
rubocop-performance
|
|
228
228
|
rubocop-rspec
|
|
229
229
|
rubocop-thread_safety
|
data/README.md
CHANGED
|
@@ -278,6 +278,12 @@ middleware, so browsers can POST reports without a CSRF token — regardless of
|
|
|
278
278
|
order you enable security features in. A throwing callback can never break the
|
|
279
279
|
receiver; it still answers `204`.
|
|
280
280
|
|
|
281
|
+
The report path is the site-absolute path the browser POSTs to. When Otto is
|
|
282
|
+
mounted under a sub-path (`map '/api' { run otto }`), include the prefix:
|
|
283
|
+
`enable_csp_reporting!("/api/_/csp-report")`. The receiver matches it against
|
|
284
|
+
`SCRIPT_NAME` + `PATH_INFO`, so the same value works in the header and in the
|
|
285
|
+
receiver.
|
|
286
|
+
|
|
281
287
|
Modern browsers (Chrome) have deprecated `report-uri` in favour of the Reporting
|
|
282
288
|
API. Pass `endpoint_url:` — an **absolute** URL whose path is the report path —
|
|
283
289
|
to also emit a `report-to` directive and a `Reporting-Endpoints` response header,
|
|
@@ -190,10 +190,14 @@ process that both resolve proxied requests must agree. The later one raises:
|
|
|
190
190
|
Cannot use forwarding family %s (trusted_proxy_header) because another Otto
|
|
191
191
|
application in this process already uses %s. Rack's forwarded host, port,
|
|
192
192
|
scheme, and IP policy is process-global, so every Otto application in one
|
|
193
|
-
process that resolves proxied requests must use the same forwarding family.
|
|
193
|
+
process that resolves proxied requests must use the same forwarding family. A
|
|
194
|
+
test suite that builds applications with different families must clear this
|
|
195
|
+
between tests: require 'otto/testing' and call Otto::Testing.reset!.
|
|
194
196
|
```
|
|
195
197
|
|
|
196
198
|
The two placeholders are the requested family and the already committed one.
|
|
199
|
+
See [Testing Otto applications](testing-guide.md#reset-ottos-process-global-state-between-tests)
|
|
200
|
+
for the reset.
|
|
197
201
|
|
|
198
202
|
An application that configures no proxy trust, and one that asserts
|
|
199
203
|
`trusted_proxies: :none`, read no forwarded chain and therefore stake no claim
|
data/docs/guides/privacy.md
CHANGED
|
@@ -154,6 +154,11 @@ the closure.
|
|
|
154
154
|
allowed = req.env.fetch('otto.ip_match').call(['192.0.2.0/24', '2001:db8::/32'])
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
In tests, build the env with `Otto::Testing.env_for` rather than writing
|
|
158
|
+
`otto.client_ip` by hand; a hand-written value leaves `otto.ip_match` denying
|
|
159
|
+
every range. See
|
|
160
|
+
[Testing Otto applications](testing-guide.md#hand-a-harness-a-resolved-client-ip).
|
|
161
|
+
|
|
157
162
|
## Middleware placement
|
|
158
163
|
|
|
159
164
|
When building a larger Rack stack, put privacy before components that log or
|
data/docs/guides/routing.md
CHANGED
|
@@ -142,11 +142,31 @@ GET /products/:id Products::Show
|
|
|
142
142
|
|
|
143
143
|
A handler can read `req.params[:id]` or a Logic class can read `params[:id]`.
|
|
144
144
|
Request query and body parameters are merged according to the handler's request
|
|
145
|
-
contract.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
145
|
+
contract. Path captures are merged on top, so a caller cannot replace the value
|
|
146
|
+
the router matched by repeating the key in the query string or body. Between
|
|
147
|
+
the query string and a form body, Rack's own order applies (the form body
|
|
148
|
+
wins). For Logic classes a JSON body sits below all of those.
|
|
149
|
+
|
|
150
|
+
A Logic class that must not confuse a path value with a caller-supplied one
|
|
151
|
+
can take the captures separately by declaring a `route_params:` keyword:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
class Receipts::Show
|
|
155
|
+
def initialize(strategy_result, params, locale, route_params: {})
|
|
156
|
+
@identifier = route_params[:identifier] # only ever the path value
|
|
157
|
+
@params = params
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The keyword is optional; the three-argument constructor keeps working. The same
|
|
163
|
+
values remain in `params` for classes that do not declare it.
|
|
164
|
+
|
|
165
|
+
JSON bodies are parsed for Logic-class parameters when the request method
|
|
166
|
+
carries a body (never `GET` or `HEAD`), the content type is JSON, and the body
|
|
167
|
+
is a JSON object. A valid non-object JSON body is ignored. Malformed JSON is
|
|
168
|
+
logged and the Logic class still runs with its other parameters; perform
|
|
169
|
+
application validation when malformed JSON must return a client error.
|
|
150
170
|
|
|
151
171
|
## Security options in routes
|
|
152
172
|
|
|
@@ -245,6 +265,49 @@ files outside the public directory should replace it with `mount_static` and
|
|
|
245
265
|
an explicit root. There is no compatibility shim: calling the removed method
|
|
246
266
|
raises `NoMethodError` at boot.
|
|
247
267
|
|
|
268
|
+
## Matching request paths before the router
|
|
269
|
+
|
|
270
|
+
The router does not match raw `PATH_INFO`. It percent-decodes it, scrubs
|
|
271
|
+
invalid UTF-8, and strips one trailing slash, so `GET /%63olonel/` reaches the
|
|
272
|
+
`/colonel` route. Middleware that decides on a request by its path (an access
|
|
273
|
+
guard, a throttle, a session skip) must compare the same value. Otherwise a
|
|
274
|
+
request can match a route without matching the guard in front of it, which
|
|
275
|
+
on an access guard is a bypass.
|
|
276
|
+
|
|
277
|
+
`Otto::Utils.routing_path(env)` returns that value. The router gets its path
|
|
278
|
+
from the same method, so the two cannot drift:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
class AdminGuard
|
|
282
|
+
ADMIN = Otto::Utils.normalize_path('/colonel')
|
|
283
|
+
|
|
284
|
+
def initialize(app)
|
|
285
|
+
@app = app
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def call(env)
|
|
289
|
+
path = Otto::Utils.routing_path(env)
|
|
290
|
+
admin_path = path == ADMIN || path.start_with?("#{ADMIN}/")
|
|
291
|
+
return [404, {}, []] if admin_path && !allowed?(env) # allowed? is yours
|
|
292
|
+
|
|
293
|
+
@app.call(env)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The result is mount-relative. When the app is mounted under a sub-path
|
|
299
|
+
(`map '/api' { run otto }`), Rack moves the prefix into `SCRIPT_NAME` and the
|
|
300
|
+
router sees only the rest. Compare that form against paths as written in the
|
|
301
|
+
routes file. For middleware shared by several mounted apps and configured
|
|
302
|
+
with external URLs, pass `include_mount: true` to get `SCRIPT_NAME` and
|
|
303
|
+
`PATH_INFO` joined and normalized as one path: inside an app mounted at
|
|
304
|
+
`/api/v2`, the router's `/status` is `/api/v2/status`, and matching the
|
|
305
|
+
mount-relative form would also match every other app's `/status`.
|
|
306
|
+
|
|
307
|
+
The value uses `normalize_path` conventions: root is `''`, and a configured
|
|
308
|
+
path must go through `Otto::Utils.normalize_path` before an exact comparison.
|
|
309
|
+
A malformed escape such as `%zz` is kept as written; the method never raises.
|
|
310
|
+
|
|
248
311
|
## Fallback 404 and 500 responses
|
|
249
312
|
|
|
250
313
|
A `GET /404` or `GET /500` route in the routes file handles misses and
|
|
@@ -41,6 +41,7 @@ require 'rack/mock'
|
|
|
41
41
|
require 'rspec'
|
|
42
42
|
require 'tempfile'
|
|
43
43
|
require 'otto'
|
|
44
|
+
require 'otto/testing'
|
|
44
45
|
|
|
45
46
|
module OttoAppSpecHelpers
|
|
46
47
|
def rack_env(path = '/', method: 'GET', headers: {}, params: {})
|
|
@@ -69,6 +70,10 @@ end
|
|
|
69
70
|
RSpec.configure do |config|
|
|
70
71
|
config.include OttoAppSpecHelpers
|
|
71
72
|
|
|
73
|
+
config.before do
|
|
74
|
+
Otto::Testing.reset!
|
|
75
|
+
end
|
|
76
|
+
|
|
72
77
|
config.after do
|
|
73
78
|
Array(@route_files).each(&:unlink)
|
|
74
79
|
end
|
|
@@ -79,6 +84,46 @@ Within Otto itself, use the existing helpers in
|
|
|
79
84
|
[`spec/support/test_helpers.rb`](../../spec/support/test_helpers.rb) instead of
|
|
80
85
|
copying this application-level helper.
|
|
81
86
|
|
|
87
|
+
## Reset Otto's process-global state between tests
|
|
88
|
+
|
|
89
|
+
`require 'otto'` does not load `otto/testing`. Require it from the test helper;
|
|
90
|
+
it does not depend on RSpec.
|
|
91
|
+
|
|
92
|
+
`Otto.new` pins `Rack::Request.forwarded_priority` from `trusted_proxy_header`
|
|
93
|
+
whenever an application configures proxy trust or names a header. Rack keeps one
|
|
94
|
+
priority per process, so Otto records the family and raises `ArgumentError`
|
|
95
|
+
when a later application in the same process chooses a different one. A suite
|
|
96
|
+
that builds one application with `trusted_proxy_header: 'Forwarded'` and
|
|
97
|
+
another with `trusted_proxies:` fails or passes depending on test order unless
|
|
98
|
+
the record is cleared between tests. `Otto::Testing.reset!` clears it and
|
|
99
|
+
restores Rack's priority to the value Otto saw at load time.
|
|
100
|
+
|
|
101
|
+
Call it before every test:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# RSpec
|
|
105
|
+
RSpec.configure { |config| config.before { Otto::Testing.reset! } }
|
|
106
|
+
|
|
107
|
+
# Minitest
|
|
108
|
+
class Minitest::Test
|
|
109
|
+
def before_setup
|
|
110
|
+
super
|
|
111
|
+
Otto::Testing.reset!
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Tryouts: at the start of each test case that builds an Otto app
|
|
116
|
+
## a depth-mode app reading Forwarded
|
|
117
|
+
Otto::Testing.reset!
|
|
118
|
+
Otto.new(nil, trusted_proxy_depth: 1, trusted_proxy_header: 'Forwarded')
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Tryouts runs a file's setup section once, before all of its test cases, so a
|
|
122
|
+
reset placed there does not separate the cases from each other.
|
|
123
|
+
|
|
124
|
+
`Otto::Security::Config.reset_rack_forwarding_family_for_testing!` does the
|
|
125
|
+
same but raises unless RSpec is loaded. It remains for existing callers.
|
|
126
|
+
|
|
82
127
|
## Test Logic classes as plain Ruby objects
|
|
83
128
|
|
|
84
129
|
Logic classes receive an authentication result, merged parameters, and a locale.
|
|
@@ -260,7 +305,9 @@ Otto's parser. Send an `application/json` body through a real Otto instance or
|
|
|
260
305
|
|
|
261
306
|
Current behavior to cover explicitly:
|
|
262
307
|
|
|
263
|
-
- a JSON object is merged into the Logic parameters
|
|
308
|
+
- a JSON object is merged into the Logic parameters below path captures, the
|
|
309
|
+
query string and any form body, which all win over it;
|
|
310
|
+
- a JSON body on `GET` or `HEAD` is ignored;
|
|
264
311
|
- a valid non-object JSON value is ignored;
|
|
265
312
|
- malformed JSON is logged and the Logic class continues with other parameters;
|
|
266
313
|
- non-JSON bodies are not parsed by the Logic handler.
|
|
@@ -343,6 +390,52 @@ octets, or set `mask_private_ips = true` to include private and localhost
|
|
|
343
390
|
addresses. For application-facing tests, prefer a real Otto instance configured
|
|
344
391
|
through `configure_ip_privacy`.
|
|
345
392
|
|
|
393
|
+
### Hand a harness a resolved client IP
|
|
394
|
+
|
|
395
|
+
Code that runs behind `IPPrivacyMiddleware` reads `env['otto.client_ip']` and,
|
|
396
|
+
for access decisions, `env['otto.ip_match']`. Do not write `otto.client_ip` by
|
|
397
|
+
hand. The middleware treats its presence as a sign that it already ran, so it
|
|
398
|
+
never builds `otto.ip_match` from the full address. Instead it installs a check
|
|
399
|
+
that returns `false` for every range and logs a warning. Allowlist tests then
|
|
400
|
+
deny, and when the application uses CIDR proxy trust it also treats the peer as
|
|
401
|
+
untrusted.
|
|
402
|
+
|
|
403
|
+
`Otto::Testing.env_for` runs the middleware over a Rack env for a request
|
|
404
|
+
arriving directly from `client_ip`, so both keys come from one resolution:
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
env = Otto::Testing.env_for('/admin', client_ip: '203.0.113.9',
|
|
408
|
+
security_config: otto.security_config)
|
|
409
|
+
|
|
410
|
+
env['otto.client_ip'] # => "203.0.113.0" (masked)
|
|
411
|
+
env['otto.ip_match'].call(['203.0.113.9/32']) # => true
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`security_config:` is required. The application's own middleware keeps what
|
|
415
|
+
this resolution produced, so pass `otto.security_config` to get the masking and
|
|
416
|
+
proxy trust of the application under test; `nil` means an unconfigured
|
|
417
|
+
middleware (public addresses masked, no proxy trust). `client_ip: nil` builds a
|
|
418
|
+
request with no resolvable client IP: `env['otto.client_ip']` is `nil` and
|
|
419
|
+
`otto.ip_match` denies every range. Other keywords and String env keys go to
|
|
420
|
+
`Rack::MockRequest.env_for`.
|
|
421
|
+
|
|
422
|
+
`env_for` models a direct request, so it raises `ArgumentError` when given
|
|
423
|
+
`X-Forwarded-For`, `X-Real-IP`, `X-Client-IP` or `Forwarded`: under a
|
|
424
|
+
configuration that trusts the peer, those would resolve an address other than
|
|
425
|
+
`client_ip`. For a request relayed by a proxy, build the env with `REMOTE_ADDR`
|
|
426
|
+
and the forwarded headers, then resolve it under the application's
|
|
427
|
+
configuration:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
env = Rack::MockRequest.env_for('/admin', 'REMOTE_ADDR' => '10.0.0.5',
|
|
431
|
+
'HTTP_X_FORWARDED_FOR' => '203.0.113.9')
|
|
432
|
+
Otto::Testing.resolve_client_ip!(env, otto.security_config)
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Resolved under a different configuration, the proxy can become the client and
|
|
436
|
+
the application keeps that answer. `resolve_client_ip!` raises on an env that
|
|
437
|
+
already carries `otto.client_ip` or `otto.ip_match` for the same reason.
|
|
438
|
+
|
|
346
439
|
See the [privacy guide](privacy.md) and the maintained privacy specs:
|
|
347
440
|
|
|
348
441
|
- [`spec/otto/ip_privacy_spec.rb`](../../spec/otto/ip_privacy_spec.rb)
|
|
@@ -369,13 +462,33 @@ Otto's default route responses include:
|
|
|
369
462
|
- `x-xss-protection: 1; mode=block`
|
|
370
463
|
- `referrer-policy: strict-origin-when-cross-origin`
|
|
371
464
|
|
|
465
|
+
Configure exactly one W3C policy token when constructing the application; Otto
|
|
466
|
+
applies it to routed responses, static files, and authentication failures:
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
otto = Otto.new('routes.txt', referrer_policy: 'no-referrer')
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The same setting is available as
|
|
473
|
+
`otto.security_config.referrer_policy = 'no-referrer'` and
|
|
474
|
+
`otto.security.referrer_policy = 'no-referrer'` during boot, before the first
|
|
475
|
+
request freezes configuration. An unknown policy token raises `ArgumentError`
|
|
476
|
+
at configuration time. Comma-separated policy fallback lists are not accepted.
|
|
477
|
+
Existing applications that pass `referrer-policy`
|
|
478
|
+
through `security_headers` remain supported and receive the same validation.
|
|
479
|
+
A route handler that explicitly sets `res['referrer-policy']` keeps its
|
|
480
|
+
response-specific value.
|
|
481
|
+
|
|
372
482
|
`x-frame-options` is not a default header. Call
|
|
373
483
|
`otto.enable_frame_protection!` before the first request if a test should expect
|
|
374
484
|
`x-frame-options: SAMEORIGIN`.
|
|
375
485
|
|
|
376
486
|
Test security behavior at the narrowest useful level, but do not require every
|
|
377
487
|
unrelated unit test to repeat header and privacy assertions. Keep those checks in
|
|
378
|
-
focused middleware or request specs.
|
|
488
|
+
focused middleware or request specs. When testing a custom referrer policy,
|
|
489
|
+
exercise a complete response from each response family the application uses
|
|
490
|
+
(for example, routed HTML, `Rack::Files`, and an authentication failure), rather
|
|
491
|
+
than asserting only against `security_config.security_headers`.
|
|
379
492
|
|
|
380
493
|
## Maintained examples by task
|
|
381
494
|
|
|
@@ -120,16 +120,16 @@ class Otto
|
|
|
120
120
|
Otto::Utils.relayed_request?(env)
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
-
# Whether this request is for the protected endpoint.
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
123
|
+
# Whether this request is for the protected endpoint. Compares the path
|
|
124
|
+
# the router itself dispatches on (+Otto::Utils.routing_path+), so a
|
|
125
|
+
# percent-encoded, invalid-byte, or trailing-slash variant the router
|
|
126
|
+
# would still route cannot slip past the guard by normalizing differently
|
|
127
|
+
# here than at dispatch.
|
|
128
128
|
#
|
|
129
129
|
# @param env [Hash] Rack environment
|
|
130
130
|
# @return [Boolean]
|
|
131
131
|
def targets_endpoint?(env)
|
|
132
|
-
|
|
132
|
+
Otto::Utils.routing_path(env) == @endpoint
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
# Router-equivalent path normalization. Delegates to the single shared
|
|
@@ -85,10 +85,12 @@ class Otto
|
|
|
85
85
|
@security_config.trusted_proxy_header = opts[:trusted_proxy_header]
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
#
|
|
89
|
-
|
|
88
|
+
# Keep the generic security_headers option compatible while routing a
|
|
89
|
+
# Referrer-Policy entry through the same validated collection as the
|
|
90
|
+
# dedicated setting. When both are present, the dedicated option wins.
|
|
91
|
+
set_security_headers(opts[:security_headers]) if opts[:security_headers]
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
@security_config.referrer_policy = opts[:referrer_policy] if opts.key?(:referrer_policy)
|
|
92
94
|
end
|
|
93
95
|
|
|
94
96
|
def configure_authentication(opts)
|