otto 2.10.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 +3 -2
- data/AGENTS.md +18 -0
- data/CHANGELOG.rst +132 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +8 -8
- data/README.md +6 -0
- data/docs/guides/configuration_freezing.md +17 -6
- data/docs/guides/forwarded-authority.md +5 -1
- data/docs/guides/privacy.md +5 -0
- data/docs/guides/routing.md +180 -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 +9 -4
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +24 -8
- data/lib/otto/core/router.rb +53 -24
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- 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 +45 -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 +126 -2
- metadata +5 -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.
|
|
@@ -859,6 +859,7 @@ RSpec/SpecFilePathFormat:
|
|
|
859
859
|
- 'spec/otto/enhanced_routing_spec.rb'
|
|
860
860
|
- 'spec/otto/error_handler_registration_spec.rb'
|
|
861
861
|
- 'spec/otto/error_handling_spec.rb'
|
|
862
|
+
- 'spec/otto/fallback_response_isolation_spec.rb'
|
|
862
863
|
- 'spec/otto/file_safety_spec.rb'
|
|
863
864
|
- 'spec/otto/locale_config_spec.rb'
|
|
864
865
|
- 'spec/otto/mcp/rate_limiting_spec.rb'
|
data/AGENTS.md
CHANGED
|
@@ -69,6 +69,24 @@ Helper modules should avoid overriding these methods inherited from Rack::Reques
|
|
|
69
69
|
|
|
70
70
|
No runtime validation is performed for performance reasons. Overriding these methods will cause undefined behavior.
|
|
71
71
|
|
|
72
|
+
## Static File Registration
|
|
73
|
+
|
|
74
|
+
Files under the `public:` directory are served without registration. Use
|
|
75
|
+
`mount_static` to bind a URL prefix to a directory outside it, or to verify a
|
|
76
|
+
required asset directory at boot:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
otto = Otto.new('routes.txt', public: 'public')
|
|
80
|
+
otto.mount_static('/assets', root: 'build/assets')
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- Roots are canonicalized at registration; a missing or unsafe root raises `ArgumentError`
|
|
84
|
+
- Precedence is fixed: literal routes, then mounts (longest prefix first), then `public:`, then dynamic routes
|
|
85
|
+
- Must be registered before first request (before configuration freezing)
|
|
86
|
+
- `add_static_path` was removed in v2.10.0 and has no shim
|
|
87
|
+
|
|
88
|
+
See `docs/guides/routing.md` for the full contract.
|
|
89
|
+
|
|
72
90
|
## Authentication Architecture
|
|
73
91
|
|
|
74
92
|
Authentication is handled by `RouteAuthWrapper` at the handler level, NOT by middleware.
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,138 @@ 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
|
+
|
|
110
|
+
.. _changelog-2.11.0:
|
|
111
|
+
|
|
112
|
+
2.11.0 — 2026-09-12
|
|
113
|
+
===================
|
|
114
|
+
|
|
115
|
+
Added
|
|
116
|
+
-----
|
|
117
|
+
|
|
118
|
+
- ``Otto#mount_static(prefix, root:)`` serves an explicit directory at a URL
|
|
119
|
+
prefix. See ``docs/guides/routing.md`` for configuration, dispatch
|
|
120
|
+
precedence, and migration from ``add_static_path``. (#267)
|
|
121
|
+
|
|
122
|
+
- ``Otto#not_found=`` and ``Otto#server_error=`` now accept callables for
|
|
123
|
+
per-request fallback responses. A server-error callable can receive the
|
|
124
|
+
exception; see ``docs/guides/routing.md`` for the callback contract. (#272)
|
|
125
|
+
|
|
126
|
+
Security
|
|
127
|
+
--------
|
|
128
|
+
|
|
129
|
+
- Static Rack triples configured with ``Otto#not_found=`` or
|
|
130
|
+
``Otto#server_error=`` are now copied for each request, preventing in-place
|
|
131
|
+
header changes, including ``Set-Cookie``, from being shared between fallback
|
|
132
|
+
responses. (#272)
|
|
133
|
+
|
|
134
|
+
Documentation
|
|
135
|
+
-------------
|
|
136
|
+
|
|
137
|
+
- Documented static-file dispatch precedence and migration from the removed
|
|
138
|
+
``add_static_path`` API in ``docs/guides/routing.md``. Corrected stale
|
|
139
|
+
``routes_static`` cache guidance in ``docs/guides/configuration_freezing.md``.
|
|
140
|
+
(#267)
|
|
141
|
+
|
|
10
142
|
.. _changelog-2.10.0:
|
|
11
143
|
|
|
12
144
|
2.10.0 — 2026-09-04
|
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,
|
|
@@ -51,15 +51,17 @@ otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
|
|
|
51
51
|
- the middleware stack;
|
|
52
52
|
- authentication configuration and instance options;
|
|
53
53
|
- dynamic and literal route tables;
|
|
54
|
-
- route definitions and reverse-route indexes
|
|
54
|
+
- route definitions and reverse-route indexes;
|
|
55
|
+
- the explicit static mount table (`mount_static`).
|
|
55
56
|
|
|
56
57
|
Hashes and arrays inside those structures are recursively frozen. Configuration
|
|
57
58
|
objects that implement `deep_freeze!` can prepare memoized values before they
|
|
58
59
|
are frozen.
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
Static-file dispatch keeps no mutable routing state. Files under the implicit
|
|
62
|
+
`public:` directory are resolved on each request against the current canonical
|
|
63
|
+
root, and explicit mounts are an immutable snapshot from the moment they are
|
|
64
|
+
registered; `mount_static` raises `FrozenError` after the freeze boundary.
|
|
63
65
|
|
|
64
66
|
## Scope and current limitations
|
|
65
67
|
|
|
@@ -69,8 +71,12 @@ some state that is not included in `freeze_configuration!`:
|
|
|
69
71
|
|
|
70
72
|
- `error_handlers` remains a mutable Hash, although
|
|
71
73
|
`register_error_handler` rejects calls after freezing;
|
|
72
|
-
- the `not_found` and `server_error` fallback response writers remain
|
|
73
|
-
|
|
74
|
+
- the `not_found` and `server_error` fallback response writers remain
|
|
75
|
+
available. A configured static triple is never returned by reference:
|
|
76
|
+
Otto copies it per request, so header writes by cookie middleware do not
|
|
77
|
+
reach the configured object even though it is not frozen;
|
|
78
|
+
- the `Rack::Files` instance for the implicit `public:` directory is rebuilt
|
|
79
|
+
when that directory is repointed between requests.
|
|
74
80
|
|
|
75
81
|
Application code should not mutate those objects directly after boot. Do not
|
|
76
82
|
state or depend on a guarantee that every object reachable from an Otto instance
|
|
@@ -86,6 +92,9 @@ otto.security_config.disable_csrf_protection!
|
|
|
86
92
|
otto.add_trusted_proxy('192.0.2.10')
|
|
87
93
|
otto.add_rate_limit_rule('uploads', limit: 5, period: 60)
|
|
88
94
|
|
|
95
|
+
# Static mounts
|
|
96
|
+
otto.mount_static('/assets', root: 'public/assets')
|
|
97
|
+
|
|
89
98
|
# Middleware and authentication
|
|
90
99
|
otto.use MyApp::OtherMiddleware
|
|
91
100
|
otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
|
|
@@ -144,3 +153,5 @@ first requests therefore do not run the freeze operation concurrently.
|
|
|
144
153
|
freezing behavior.
|
|
145
154
|
- [Configuration-freezing specs](../../spec/otto/configuration_freezing_spec.rb).
|
|
146
155
|
- [Static-file freezing specs](../../spec/otto/static_file_freezing_spec.rb).
|
|
156
|
+
- [Static mount specs](../../spec/otto/core/static_mounts_spec.rb) — registration
|
|
157
|
+
after the freeze boundary and serving from a frozen mount table.
|
|
@@ -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
|
|
|
@@ -163,6 +183,161 @@ silently weakening the route. Do not use `csrf=exempt` as a general API switch;
|
|
|
163
183
|
choose an independent request-authentication and replay-protection model for
|
|
164
184
|
webhooks or other non-browser endpoints.
|
|
165
185
|
|
|
186
|
+
## Static files
|
|
187
|
+
|
|
188
|
+
Otto serves static files in two ways. Both apply the same safety policy: the
|
|
189
|
+
requested path is joined to a canonical root, resolved with `File.realpath`
|
|
190
|
+
(which follows every `..`, `.`, and symlink component), and served only when
|
|
191
|
+
the result is still inside that root and is a regular, readable file owned by
|
|
192
|
+
the process user or group. Anything else, including a symlink that points
|
|
193
|
+
outside the root, is treated as not found.
|
|
194
|
+
|
|
195
|
+
### Implicit public directory
|
|
196
|
+
|
|
197
|
+
Passing `public:` serves every file under that directory at its relative path.
|
|
198
|
+
Nothing needs registering; a file added after boot is served on the next
|
|
199
|
+
request, and a symlinked public directory that is repointed by a deploy is
|
|
200
|
+
re-resolved on every request.
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
otto = Otto.new('routes', public: File.expand_path('public', __dir__))
|
|
204
|
+
# public/css/site.css is served at GET /css/site.css
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Explicit static mounts
|
|
208
|
+
|
|
209
|
+
`mount_static` binds one URL prefix to one directory. Use it when the files do
|
|
210
|
+
not live under a single public directory, when a URL prefix should map to a
|
|
211
|
+
different directory name, or when a required asset directory must be verified
|
|
212
|
+
at boot.
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
otto = Otto.new('routes')
|
|
216
|
+
otto.mount_static('/assets', root: 'public/assets')
|
|
217
|
+
otto.mount_static('/vendor', root: File.join(Gem.loaded_specs['some-ui-kit'].full_gem_path, 'dist'))
|
|
218
|
+
otto.mount_static('/', root: 'public/root-files') # favicon.ico, robots.txt
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- The prefix must start with `/`. A trailing slash is ignored, and `/`
|
|
222
|
+
mounts the root at the top level. Empty, `.`, and `..` segments are
|
|
223
|
+
rejected.
|
|
224
|
+
- The root is expanded and canonicalized once, at registration. A root that
|
|
225
|
+
is missing, unreadable, not a directory, not owned by the process user or
|
|
226
|
+
group, or a symlink that cannot be resolved raises `ArgumentError`, so a
|
|
227
|
+
misconfigured application does not boot. Because the root is fixed at
|
|
228
|
+
registration, a deploy that repoints a symlinked root takes effect at the
|
|
229
|
+
next restart.
|
|
230
|
+
- A mount authorizes only files inside its own root. It never exposes the
|
|
231
|
+
root's parent or siblings, and it does not widen the implicit public
|
|
232
|
+
directory. Registering the same prefix twice on one instance raises
|
|
233
|
+
`ArgumentError`; different Otto instances are fully independent.
|
|
234
|
+
- Requests are matched on the decoded, trailing-slash-stripped path, the same
|
|
235
|
+
normalization every other dispatch stage uses. Only `GET` is served, the
|
|
236
|
+
prefix itself is not (mounts serve files, not directory listings), and a
|
|
237
|
+
request for a file the root does not contain falls through to the next
|
|
238
|
+
dispatch stage.
|
|
239
|
+
- `mount_static` must be called before the first request. After configuration
|
|
240
|
+
freezing it raises `FrozenError`, and `otto.static_mounts` is a frozen,
|
|
241
|
+
read-only table.
|
|
242
|
+
|
|
243
|
+
### Dispatch precedence
|
|
244
|
+
|
|
245
|
+
Precedence is fixed and does not depend on request history:
|
|
246
|
+
|
|
247
|
+
1. literal routes, such as `GET /assets/app.css Assets#show`;
|
|
248
|
+
2. explicit static mounts, consulted longest prefix first; when the longest
|
|
249
|
+
matching mount does not contain the file, shorter matching mounts are tried
|
|
250
|
+
in turn;
|
|
251
|
+
3. the implicit `public:` directory;
|
|
252
|
+
4. dynamic routes, such as `GET /assets/:name Assets#show`.
|
|
253
|
+
|
|
254
|
+
So a literal route at a mounted path always wins, a mounted file always beats
|
|
255
|
+
a file at the same URL in the public directory, and a dynamic route only sees
|
|
256
|
+
requests that no static source could serve.
|
|
257
|
+
|
|
258
|
+
### Migrating from `add_static_path`
|
|
259
|
+
|
|
260
|
+
`add_static_path` was removed in v2.10.0. It only populated a request-time
|
|
261
|
+
cache; it never registered or restricted anything. Callers that used it to
|
|
262
|
+
"register" files under the public directory can delete the call, because the
|
|
263
|
+
public directory is served without registration. Callers that used it to reach
|
|
264
|
+
files outside the public directory should replace it with `mount_static` and
|
|
265
|
+
an explicit root. There is no compatibility shim: calling the removed method
|
|
266
|
+
raises `NoMethodError` at boot.
|
|
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
|
+
|
|
311
|
+
## Fallback 404 and 500 responses
|
|
312
|
+
|
|
313
|
+
A `GET /404` or `GET /500` route in the routes file handles misses and
|
|
314
|
+
unhandled errors like any other route. Without one, Otto uses `not_found=` and
|
|
315
|
+
`server_error=`, which accept either a Rack triple or a callable:
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
|
|
319
|
+
|
|
320
|
+
otto.server_error = lambda do |env, error|
|
|
321
|
+
[500, { 'content-type' => 'text/plain' }, ["Error #{env['otto.error_id']}"]]
|
|
322
|
+
end
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A callable is invoked on every request with `env` (`not_found`) or `env` and
|
|
326
|
+
the exception (`server_error`), trimmed to the positional parameters it
|
|
327
|
+
declares, so `->(env) { ... }` and `->(env = nil) { ... }` both work for
|
|
328
|
+
`server_error`. It must return a Rack triple: an Integer status, Hash-like
|
|
329
|
+
headers, and a body that responds to `each` (a bare String is rejected, at
|
|
330
|
+
assignment time for a static triple). A static triple is copied per request
|
|
331
|
+
before it is returned, so middleware that writes response headers in place
|
|
332
|
+
(rack-session, Otto's CSRF middleware, anything calling
|
|
333
|
+
`Rack::Utils.set_cookie_header!`) never mutates the configured object or
|
|
334
|
+
leaks one client's `Set-Cookie` into another's response. Do not rely on
|
|
335
|
+
mutating the configured triple after boot; assign a new value or use the
|
|
336
|
+
callable form instead.
|
|
337
|
+
|
|
338
|
+
For JSON clients, an unhandled error returns Otto's built-in JSON error body
|
|
339
|
+
regardless of `server_error`; a `/500` route applies to every client.
|
|
340
|
+
|
|
166
341
|
## Configuration timing
|
|
167
342
|
|
|
168
343
|
Construct and configure the Otto instance before the first request:
|