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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8303453d265fcf2d5b0fa0754aafdbdb3e24eb89be9dc5a7b5611b4f2bcc699
4
- data.tar.gz: 2081706f65a3475f95555d96547841691f906b015f8b012739abc7804923fc76
3
+ metadata.gz: d60f9cd87b0c935f8c79ecc1aae11f4b9880794d57b0ee5309c53baafcb84dcf
4
+ data.tar.gz: d92225f2ab02c1fbbd9c6ab745c27373aa6f11608f27067ec4485ca4f81878b2
5
5
  SHA512:
6
- metadata.gz: 75c496ee29c9c4ec0b05a8effda77e4e1dafc02080fb341a2320313011437f2b0bc0511ad6ca7d1b53928d6f75130693c974c72ed053aa35e5c6d16f60af0a5e
7
- data.tar.gz: 9d9615efa73c7ebafafe7f7e1eb3e098f8c07f3200ac62882c52c39954087c73053fc9574beda1b1e11ecc836e0b9bacb70fe267c90fa92600531ef14fca1b63
6
+ metadata.gz: cf10b7fdf1245e1dd64140ef3a57fc69f2a42197ede66812f02a89f80f291ff3759a7b5245487fd0e7948e4da6534534dbe9d753e0d15cf0ea57366da2c928da
7
+ data.tar.gz: cba8ac893ef636d0e978061304704018907bba315160405561f41592613a250a762ac1a0ebad7de675984101f711f44aabec8693ac3445e4e1e6d9b8f7ffd788
@@ -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@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
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@19dda84776b3518d98b8798e591daee763049ed3 # v1.0.220
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@19dda84776b3518d98b8798e591daee763049ed3 # v1.0.220
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@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
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@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
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@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
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@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
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@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
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: 4
415
+ # Offense count: 6
416
416
  # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
417
417
  Metrics/ParameterLists:
418
- Max: 11
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.2' # Optional geo DB reader; exercised by geo specs
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.90.0', require: false
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.11.0)
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 (2.21.2)
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.4.0)
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.90.0)
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.7.3)
169
+ rubocop-thread_safety (0.8.0)
170
170
  lint_roller (~> 1.1)
171
- rubocop (~> 1.72, >= 1.72.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.2)
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.90.0)
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
@@ -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
@@ -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. JSON bodies are parsed for Logic-class parameters when the content
146
- type is JSON and the body is a JSON object. A valid non-object JSON body is
147
- ignored. Malformed JSON is logged and the Logic class still runs with its other
148
- parameters; perform application validation when malformed JSON must return a
149
- client error.
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. Normalizes
124
- # +PATH_INFO+ through the same +Otto::Utils.normalize_path+ the router
125
- # uses for literal matching, so a percent-encoded, invalid-byte, or
126
- # trailing-slash variant the router would still route cannot slip past the
127
- # guard by normalizing differently here than at dispatch.
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
- normalize_path(env['PATH_INFO']) == @endpoint
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
- # Set custom security headers
89
- return unless opts[:security_headers]
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
- set_security_headers(opts[:security_headers])
93
+ @security_config.referrer_policy = opts[:referrer_policy] if opts.key?(:referrer_policy)
92
94
  end
93
95
 
94
96
  def configure_authentication(opts)