otto 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +218 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +12 -10
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +172 -0
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/env_keys.rb +32 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/privacy/config.rb +245 -3
- data/lib/otto/privacy/core.rb +93 -14
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
- data/lib/otto/privacy.rb +3 -1
- data/lib/otto/request.rb +8 -1
- data/lib/otto/security/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +23 -1
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +201 -12
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +11 -3
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d14c212b610b94a8a21abdae0c1d691e342c849e6160dc03615c8c00e30ddca8
|
|
4
|
+
data.tar.gz: 0a22e90e372914211b0828eb4c0048edb3171c011407778af73d0be098a79a68
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f1c55db0ffbe490ad295c6d73b1e6012f1e12702dd33f971f3e7a2a42f9b269cceaf0deccdb5e9fb958f56dfe338bbbb71561ea01ec8b2c7e18d71e507ddb065
|
|
7
|
+
data.tar.gz: 78f3e09cd71f33caceab30691d562a65552c765f4b1690b4b1ad36776cf89600839bf1fb37ab2a7d4335fd58e2d75ad403f6129f7e879dc12581ded07f1aa3ac
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
|
|
22
22
|
steps:
|
|
23
23
|
- name: Checkout code
|
|
24
|
-
uses: actions/checkout@v7.0.
|
|
24
|
+
uses: actions/checkout@v7.0.1
|
|
25
25
|
|
|
26
26
|
- name: Set up Ruby
|
|
27
27
|
uses: ruby/setup-ruby@v1
|
|
@@ -88,7 +88,7 @@ jobs:
|
|
|
88
88
|
|
|
89
89
|
steps:
|
|
90
90
|
- name: Checkout code
|
|
91
|
-
uses: actions/checkout@v7.0.
|
|
91
|
+
uses: actions/checkout@v7.0.1
|
|
92
92
|
|
|
93
93
|
- name: Set up Ruby
|
|
94
94
|
uses: ruby/setup-ruby@v1
|
data/.pre-commit-config.yaml
CHANGED
|
@@ -95,13 +95,30 @@ repos:
|
|
|
95
95
|
# - rubocop-rspec
|
|
96
96
|
|
|
97
97
|
# Commit message issue tracking integration
|
|
98
|
-
- repo: https://github.com/
|
|
99
|
-
rev: v0.
|
|
98
|
+
- repo: https://github.com/delano/add-msg-issue-prefix-hook
|
|
99
|
+
rev: v0.1.1-fork
|
|
100
100
|
hooks:
|
|
101
101
|
- id: add-msg-issue-prefix
|
|
102
102
|
stages: [prepare-commit-msg]
|
|
103
103
|
description: Automatically prefix commits with issue numbers
|
|
104
104
|
args:
|
|
105
|
-
-
|
|
106
|
-
-
|
|
107
|
-
|
|
105
|
+
- '--default='
|
|
106
|
+
- '--exclude-pattern=^(dependabot|renovate)/'
|
|
107
|
+
# Extract the issue ID from the branch name, anchored at the start or a
|
|
108
|
+
# `/` boundary, so version/encoding tokens like v4, utf8, sha256 and
|
|
109
|
+
# 0.26.2 never match. Two shapes yield an ID: a bare number
|
|
110
|
+
# (feature/3840-x) or an explicit word prefix (claude/fix-3499-x,
|
|
111
|
+
# fix/issue-1234), where `(?P<id>...)` emits the bare number.
|
|
112
|
+
# `i18n(?=/)` emits the whole match as [#I18N].
|
|
113
|
+
#
|
|
114
|
+
# NOT JIRA-compatible: there is deliberately no generic `WORD-NNN` arm,
|
|
115
|
+
# so `feature/ENG-123` yields no prefix. GitHub issue numbers are the
|
|
116
|
+
# only ticket scheme here, and a generic arm matched dependency and
|
|
117
|
+
# date tokens instead (deps/postgres-17, security/audit-2026-07-06).
|
|
118
|
+
# Re-add that arm if this repo ever adopts JIRA-style keys. (#3891)
|
|
119
|
+
- '--pattern=(?:^|(?<=/))(?:i18n(?=/)|(?:issue|fix|bug|feat|feature)-(?P<id>[0-9]{1,5})|[0-9]{2,5})(?![0-9])'
|
|
120
|
+
# Separate pattern for has_tag(): detects an already-present [#TAG] in
|
|
121
|
+
# the subject on reword so the hook does not double-prefix. Matches the
|
|
122
|
+
# upcased emitted form, incl. [#I18N] which the branch pattern cannot.
|
|
123
|
+
- '--tag-pattern=[A-Za-z0-9][A-Za-z0-9._-]*'
|
|
124
|
+
- '--template=[#{}]'
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,224 @@ 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.7.0:
|
|
11
|
+
|
|
12
|
+
2.7.0 — 2026-08-03
|
|
13
|
+
==================
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- Configurable geo-country resolution. ``configure_ip_privacy`` now accepts
|
|
19
|
+
``geo_header:`` — a trusted, app-configured request header checked *before*
|
|
20
|
+
the built-in CDN headers (e.g. ``geo_header: 'X-Client-Country'``);
|
|
21
|
+
``geo_db_path:`` — a MaxMind-format ``.mmdb`` country database giving an
|
|
22
|
+
offline IP->country fallback (needs the optional ``maxmind-db`` gem); and
|
|
23
|
+
``geo_db_reader:`` — bring your own reader (any object responding to
|
|
24
|
+
``#get``). A bad ``geo_db_path`` fails at boot, not per-request. (#206)
|
|
25
|
+
|
|
26
|
+
- ``X-Vercel-IP-Country`` is now recognized among the built-in CDN/provider
|
|
27
|
+
geo headers. (#206)
|
|
28
|
+
|
|
29
|
+
- Named privacy profiles: ``configure_ip_privacy(profile: :anonymous | :masked
|
|
30
|
+
| :audit)`` (also accepted by ``Otto::Privacy::Config.new``) — a validated
|
|
31
|
+
preset over the existing knobs. ``:masked`` is the default posture (public
|
|
32
|
+
IPs masked, private exempt), ``:anonymous`` masks every IP including
|
|
33
|
+
private/localhost, and ``:audit`` disables IP privacy for
|
|
34
|
+
private/compliance environments (retention responsibility transfers to the
|
|
35
|
+
operator). ``Config#profile`` derives the label from live knob state, so it
|
|
36
|
+
cannot go stale. Unknown names raise ``ArgumentError``. (#218)
|
|
37
|
+
|
|
38
|
+
- ``env['otto.ip_match']``: a verdict-only CIDR membership check over the
|
|
39
|
+
resolved, UNMASKED client IP, installed by ``IPPrivacyMiddleware`` under
|
|
40
|
+
every profile. Call it with an array of CIDR strings (or ``IPAddr``
|
|
41
|
+
objects) and get ``true``/``false`` back, so downstream access control
|
|
42
|
+
(e.g. a per-tenant allowlist) can match at full /32–/128 precision while
|
|
43
|
+
``otto.client_ip``, ``REMOTE_ADDR``, logs, and fingerprints stay masked —
|
|
44
|
+
only the closure lands in env, never the address. Returns ``false`` when no
|
|
45
|
+
client IP resolves (fail-closed); invalid CIDR entries raise
|
|
46
|
+
``IPAddr::InvalidAddressError``. (#218)
|
|
47
|
+
|
|
48
|
+
- ``Otto::Utils.ip_in_cidrs?(ip, cidrs)``: the general-purpose CIDR-set
|
|
49
|
+
matcher behind ``otto.ip_match``, sharing the trusted-proxy matcher's
|
|
50
|
+
semantics (port stripping, ``IPAddr#native`` folding, family-aware
|
|
51
|
+
skipping). Runtime ``ip`` fails closed; invalid ``cidrs`` entries raise.
|
|
52
|
+
Accepts pre-parsed ``IPAddr`` entries for hot paths. (#218)
|
|
53
|
+
|
|
54
|
+
- ``MiddlewareStack#execution_order`` returns middleware classes in the order
|
|
55
|
+
they actually run (outermost first), resolving pin tiers — unlike
|
|
56
|
+
``#middleware_list``, which reports registration order. (#219)
|
|
57
|
+
|
|
58
|
+
- ``add_with_position`` accepts ``position: :innermost`` as a clearer synonym
|
|
59
|
+
for ``:first``, and a new ``position: :entrypoint`` tier that pins middleware
|
|
60
|
+
outside even ``:outermost`` entries. (#219)
|
|
61
|
+
|
|
62
|
+
- ``AuthFailure`` now carries a ``terminal`` flag (``terminal?`` predicate),
|
|
63
|
+
and ``AuthStrategy#failure`` accepts ``terminal: true`` — meaning "credentials
|
|
64
|
+
were presented, examined, and rejected; do not consult further strategies."
|
|
65
|
+
``RouteAuthWrapper`` halts the chain and renders that failure's 401
|
|
66
|
+
regardless of strategy order, so mixed chains (``auth=basicauth,noauth``)
|
|
67
|
+
fail closed on invalid credentials instead of proceeding as anonymous. Plain
|
|
68
|
+
failures keep the existing OR fallthrough. (#220)
|
|
69
|
+
|
|
70
|
+
Changed
|
|
71
|
+
-------
|
|
72
|
+
|
|
73
|
+
- Geo resolution now runs against a privacy-masked view — the masked IP and an
|
|
74
|
+
env with the IP-bearing headers masked — so the unmasked address never
|
|
75
|
+
reaches a custom resolver or the database. Country networks are >= /24, so
|
|
76
|
+
/24-masked results are identical at the default masking level. A custom
|
|
77
|
+
resolver invoked through the middleware now receives the masked IP and env;
|
|
78
|
+
direct ``GeoResolver.resolve`` callers are unchanged. (#206)
|
|
79
|
+
|
|
80
|
+
- Middleware pins are now recorded per *entry* rather than per class. An
|
|
81
|
+
``:outermost`` pin previously reordered every registration of that class,
|
|
82
|
+
including ones registered separately with different arguments. (#219)
|
|
83
|
+
|
|
84
|
+
- ``Otto::LoggingHelpers.request_context`` masks its ``:ip`` field when the
|
|
85
|
+
request never passed through ``IPPrivacyMiddleware`` (previously it fell back
|
|
86
|
+
to the raw ``REMOTE_ADDR``). New ``LoggingHelpers.privacy_safe_ip`` exposes
|
|
87
|
+
that behavior for callers outside Otto's stack. (#219)
|
|
88
|
+
|
|
89
|
+
- ``Otto::CaddyTLS::LocalhostGuard`` reads the new leak-free boolean
|
|
90
|
+
``env['otto.peer_loopback']`` — the loopback verdict ``IPPrivacyMiddleware``
|
|
91
|
+
records on the untouched socket peer before masking — falling back to
|
|
92
|
+
``REMOTE_ADDR`` when absent. The guard still authenticates the raw peer,
|
|
93
|
+
which it can no longer read directly now that IP masking runs first. (#219)
|
|
94
|
+
|
|
95
|
+
- ``RouteAuthWrapper`` multi-strategy chains now treat an anonymous success
|
|
96
|
+
(a ``StrategyResult`` with no user, e.g. from ``noauth``) as a held fallback
|
|
97
|
+
rather than an immediate win: the rest of the chain still runs so a later
|
|
98
|
+
credentialed strategy can reject presented credentials terminally. The
|
|
99
|
+
fallback wins once the chain completes without an authenticated success or
|
|
100
|
+
terminal failure, preserving OR semantics for credential-less requests.
|
|
101
|
+
Consequently, in ``auth=noauth,apikey`` a later authenticated success now
|
|
102
|
+
wins over an earlier anonymous one. (#220)
|
|
103
|
+
|
|
104
|
+
Removed
|
|
105
|
+
-------
|
|
106
|
+
|
|
107
|
+
- The built-in ``KNOWN_RANGES`` IP-range guess table (and ``detect_by_range``).
|
|
108
|
+
When no header, custom resolver, or database resolves a country, the result
|
|
109
|
+
is now ``'**'`` (unknown) rather than a guess from a hardcoded ~14-entry
|
|
110
|
+
table that mislabeled whole cloud regions. Callers that relied on the table
|
|
111
|
+
(e.g. ``8.8.8.8`` -> ``US``) now get ``'**'``; configure a database or an
|
|
112
|
+
edge header for real geo-location. (#206)
|
|
113
|
+
|
|
114
|
+
Fixed
|
|
115
|
+
-----
|
|
116
|
+
|
|
117
|
+
- IP privacy now redacts the RFC 7239 ``Forwarded`` header
|
|
118
|
+
(``HTTP_FORWARDED``), which Otto reads as an authoritative client-IP source
|
|
119
|
+
in count-based depth mode. Previously it was left intact while
|
|
120
|
+
``X-Forwarded-For`` and friends were masked, so downstream code could read
|
|
121
|
+
the real client IP from its ``for=`` token. Only the ``for=`` value is
|
|
122
|
+
replaced; ``proto=``/``host=``/``by=`` and the header structure are
|
|
123
|
+
preserved. When no client IP resolves, the forwarded headers are dropped
|
|
124
|
+
rather than left to leak a raw address. (#206)
|
|
125
|
+
|
|
126
|
+
- IPv4-mapped IPv6 CIDR *ranges* are now folded through ``IPAddr#native``, so
|
|
127
|
+
the fold is symmetric with the client address. Previously only the client
|
|
128
|
+
was folded, so a mapped range (``::ffff:10.0.0.0/104``) failed the
|
|
129
|
+
address-family check and was silently skipped — a wrong verdict rather than
|
|
130
|
+
an error. Affects ``Otto::Utils.ip_in_cidrs?`` / ``otto.ip_match`` and
|
|
131
|
+
trusted-proxy entries, where an unmatched proxy silently withheld
|
|
132
|
+
``otto.via_trusted_proxy`` and with it ``Request#secure?`` and geo-header
|
|
133
|
+
trust. **Behavior change** for anyone who configured a mapped-IPv6 range: it
|
|
134
|
+
now matches the IPv4 clients it names — including ``::ffff:0:0/96``, the
|
|
135
|
+
whole mapped space, which matches every IPv4 address. The prefix must cover
|
|
136
|
+
the mapped marker (``/96`` or longer); ``::ffff:10.0.0.0/64`` masks the
|
|
137
|
+
marker away and still matches neither form. Plain IPv4/IPv6 ranges are
|
|
138
|
+
unaffected, and pre-parsed ``IPAddr`` entries are not mutated. (#218)
|
|
139
|
+
|
|
140
|
+
- ``IPPrivacyMiddleware`` reads the privacy setting per request instead of
|
|
141
|
+
caching it at construction. Otto builds its middleware stack at the end of
|
|
142
|
+
``Otto.new`` while ``configure_ip_privacy`` stays legal until the first
|
|
143
|
+
request, so a post-construction ``configure_ip_privacy(profile: :audit)``
|
|
144
|
+
was silently ignored and the middleware kept masking. (#218)
|
|
145
|
+
|
|
146
|
+
- ``IPPrivacyMiddleware`` no longer interpolates the unmasked client IP into
|
|
147
|
+
its debug log. Under ``Otto.debug``, the pre-mask resolution line
|
|
148
|
+
(``:masked``, ``:anonymous``) and the private/localhost exemption line
|
|
149
|
+
(``:masked``) logged the raw address — handing back through the log exactly
|
|
150
|
+
what the profile withholds from env. The resolution line is gone; the
|
|
151
|
+
masking path logs the masked IP, and the exemption line now records only
|
|
152
|
+
that the exemption fired. ``:audit`` is unaffected. (#218)
|
|
153
|
+
|
|
154
|
+
- ``configure_ip_privacy`` is now all-or-nothing: assignments are dry-run
|
|
155
|
+
against a copy and validated there before the live config is touched, so a
|
|
156
|
+
rejected value (e.g. ``octet_precision: 7`` alongside a ``profile:`` preset)
|
|
157
|
+
can no longer leave the preset half-applied. (#218)
|
|
158
|
+
|
|
159
|
+
- ``configure_ip_privacy`` fails loudly on falsy knobs. ``octet_precision``,
|
|
160
|
+
``hash_rotation`` and ``redis`` were truthiness-guarded, so an explicit
|
|
161
|
+
``false`` was silently dropped instead of assigned or rejected; every kwarg
|
|
162
|
+
now follows the same nil guard — ``nil`` means "leave unchanged", anything
|
|
163
|
+
else must take effect or raise. A non-Numeric ``hash_rotation`` raises
|
|
164
|
+
``ArgumentError`` naming the value rather than ``NoMethodError``. (#218)
|
|
165
|
+
|
|
166
|
+
- ``Otto::Privacy::Config#profile=`` raises ``ArgumentError`` for a value that
|
|
167
|
+
cannot name a profile; ``config.profile = 123`` and ``config.profile = nil``
|
|
168
|
+
previously raised ``NoMethodError`` on ``#to_sym``. The ``profile:`` option
|
|
169
|
+
form still treats ``nil`` as "leave unchanged", but a non-nameable value now
|
|
170
|
+
raises before any other option in the same call is applied. (#218)
|
|
171
|
+
|
|
172
|
+
- ``IPPrivacyMiddleware``'s idempotency guard installs a fail-closed
|
|
173
|
+
``otto.ip_match`` when ``otto.client_ip`` was set outside the middleware
|
|
174
|
+
(out of contract, but previously left the advertised capability ``nil`` and
|
|
175
|
+
raised ``NoMethodError`` downstream). It deliberately does not rebuild the
|
|
176
|
+
check from ``otto.client_ip``, which may be masked — matching a masked
|
|
177
|
+
address against a narrow CIDR yields false allows. The deny is logged.
|
|
178
|
+
(#218)
|
|
179
|
+
|
|
180
|
+
- ``Otto::Request#secure?`` now honors ``env['rack.url_scheme']`` — the
|
|
181
|
+
canonical Rack scheme key that ``Rack::Request#scheme``/``#ssl?`` (and
|
|
182
|
+
therefore the session Secure-cookie gate, ``Rack::Protection``, and Otto's
|
|
183
|
+
own CSRF middleware) read. An upstream middleware that normalizes the scheme
|
|
184
|
+
the idiomatic Rack way is now visible to ``secure?`` instead of producing a
|
|
185
|
+
second, divergent scheme-truth. The trusted-proxy gate on the raw
|
|
186
|
+
``X-Forwarded-Proto`` / ``X-Scheme`` headers is unchanged. (#214)
|
|
187
|
+
|
|
188
|
+
Security
|
|
189
|
+
--------
|
|
190
|
+
|
|
191
|
+
- Geo headers (``CF-IPCountry`` and friends, plus any configured
|
|
192
|
+
``geo_header``) are now trusted ONLY for a request that demonstrably arrived
|
|
193
|
+
via a configured CIDR trusted proxy. Previously any client could pick its own
|
|
194
|
+
country by sending ``CF-IPCountry``/``X-Client-Country``. An unverifiable
|
|
195
|
+
origin is no longer trusted: count-based depth mode, and — a behavior change
|
|
196
|
+
— deployments with **no** trusted-proxy configuration. **Migration:** to keep
|
|
197
|
+
header-based geo, configure ``trusted_proxies`` (CIDR matchers) so Otto can
|
|
198
|
+
verify the proxy origin. Count-based ``trusted_proxy_depth`` does NOT enable
|
|
199
|
+
header trust, so depth-mode and header-only setups should set ``geo_db_path``
|
|
200
|
+
for a local database instead. Otherwise resolution returns ``'**'``. (#206)
|
|
201
|
+
|
|
202
|
+
- IP masking now applies to the whole middleware stack, not just the
|
|
203
|
+
application. ``IPPrivacyMiddleware`` was registered ``position: :first``,
|
|
204
|
+
which is first-in-*array* and therefore **innermost**, so every other
|
|
205
|
+
middleware Otto mounts — plus anything added via ``Otto#use`` — received the
|
|
206
|
+
raw ``REMOTE_ADDR``, an un-anonymized User-Agent, and a nil
|
|
207
|
+
``env['otto.client_ip']``. It is now pinned outermost. (#219)
|
|
208
|
+
|
|
209
|
+
- Rate-limit logging no longer writes raw client IPs. The ``rack.attack``
|
|
210
|
+
subscribers in ``Otto::Security::RateLimiting`` and ``Otto::MCP::RateLimiter``
|
|
211
|
+
interpolated ``req.ip`` into a ``warn``-level line on every blocked request,
|
|
212
|
+
so deployments on the default masked profile leaked public IPs to their logs.
|
|
213
|
+
Both now log a masked address. (#219)
|
|
214
|
+
|
|
215
|
+
AI Assistance
|
|
216
|
+
-------------
|
|
217
|
+
|
|
218
|
+
- Geo-header source and local IP->country database fallback designed and
|
|
219
|
+
implemented with AI assistance, including adversarial review and coverage
|
|
220
|
+
for header precedence, spoofing, depth-mode trust, IPv6, custom-resolver
|
|
221
|
+
sealing, boot-time validation, and the real ``maxmind-db`` reader against a
|
|
222
|
+
generated fixture. (#206)
|
|
223
|
+
|
|
224
|
+
- IP precision capability, privacy profiles, middleware ordering audit, and
|
|
225
|
+
terminal auth failures: design, implementation, adversarial review, and
|
|
226
|
+
regression coverage developed with AI assistance. (#214, #218, #219, #220)
|
|
227
|
+
|
|
10
228
|
.. _changelog-2.6.0:
|
|
11
229
|
|
|
12
230
|
2.6.0 — 2026-07-10
|
data/Gemfile
CHANGED
|
@@ -19,6 +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'
|
|
22
|
+
gem 'maxmind-db', '~> 1.2' # Optional geo DB reader; exercised by geo specs
|
|
22
23
|
gem 'rack-attack'
|
|
23
24
|
gem 'reek', '~> 6.5'
|
|
24
25
|
end
|
|
@@ -28,7 +29,7 @@ group :development do
|
|
|
28
29
|
gem 'debug'
|
|
29
30
|
gem 'rackup' # Used to boot examples/ apps; not needed by specs
|
|
30
31
|
gem 'rake', '~> 13.4', require: false # Provides `rake release` for release-gem.yml
|
|
31
|
-
gem 'rubocop', '~> 1.88.
|
|
32
|
+
gem 'rubocop', '~> 1.88.2', require: false
|
|
32
33
|
gem 'rubocop-performance', require: false
|
|
33
34
|
gem 'rubocop-rspec', require: false
|
|
34
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.7.0)
|
|
5
5
|
concurrent-ruby (~> 1.3, < 2.0)
|
|
6
6
|
logger (~> 1, < 2.0)
|
|
7
7
|
loofah (~> 2.20)
|
|
@@ -15,8 +15,8 @@ GEM
|
|
|
15
15
|
ast (2.4.3)
|
|
16
16
|
benchmark (0.5.0)
|
|
17
17
|
bigdecimal (4.1.1)
|
|
18
|
-
concurrent-ruby (1.3.
|
|
19
|
-
crass (1.0.
|
|
18
|
+
concurrent-ruby (1.3.8)
|
|
19
|
+
crass (1.0.7)
|
|
20
20
|
date (3.5.1)
|
|
21
21
|
debug (1.11.1)
|
|
22
22
|
irb (~> 1.10)
|
|
@@ -70,9 +70,10 @@ GEM
|
|
|
70
70
|
language_server-protocol (3.17.0.6)
|
|
71
71
|
lint_roller (1.1.0)
|
|
72
72
|
logger (1.7.0)
|
|
73
|
-
loofah (2.25.
|
|
73
|
+
loofah (2.25.2)
|
|
74
74
|
crass (~> 1.0.2)
|
|
75
75
|
nokogiri (>= 1.12.0)
|
|
76
|
+
maxmind-db (1.4.0)
|
|
76
77
|
minitest (5.26.0)
|
|
77
78
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
|
78
79
|
racc (~> 1.4)
|
|
@@ -116,7 +117,7 @@ GEM
|
|
|
116
117
|
rack (>= 3)
|
|
117
118
|
rainbow (3.1.1)
|
|
118
119
|
rake (13.4.2)
|
|
119
|
-
rbs (4.0.
|
|
120
|
+
rbs (4.0.3)
|
|
120
121
|
logger
|
|
121
122
|
prism (>= 1.6.0)
|
|
122
123
|
tsort
|
|
@@ -147,7 +148,7 @@ GEM
|
|
|
147
148
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
148
149
|
rspec-support (~> 3.13.0)
|
|
149
150
|
rspec-support (3.13.7)
|
|
150
|
-
rubocop (1.88.
|
|
151
|
+
rubocop (1.88.2)
|
|
151
152
|
json (~> 2.3)
|
|
152
153
|
language_server-protocol (~> 3.17.0.2)
|
|
153
154
|
lint_roller (~> 1.1.0)
|
|
@@ -158,7 +159,7 @@ GEM
|
|
|
158
159
|
rubocop-ast (>= 1.49.0, < 2.0)
|
|
159
160
|
ruby-progressbar (~> 1.7)
|
|
160
161
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
161
|
-
rubocop-ast (1.
|
|
162
|
+
rubocop-ast (1.50.0)
|
|
162
163
|
parser (>= 3.3.7.2)
|
|
163
164
|
prism (~> 1.7)
|
|
164
165
|
rubocop-performance (1.26.1)
|
|
@@ -173,7 +174,7 @@ GEM
|
|
|
173
174
|
lint_roller (~> 1.1)
|
|
174
175
|
rubocop (~> 1.72, >= 1.72.1)
|
|
175
176
|
rubocop-ast (>= 1.44.0, < 2.0)
|
|
176
|
-
ruby-lsp (0.26.
|
|
177
|
+
ruby-lsp (0.26.10)
|
|
177
178
|
language_server-protocol (~> 3.17.0)
|
|
178
179
|
prism (>= 1.2, < 2.0)
|
|
179
180
|
rbs (>= 3, < 5)
|
|
@@ -200,7 +201,7 @@ GEM
|
|
|
200
201
|
unicode-emoji (~> 4.1)
|
|
201
202
|
unicode-emoji (4.2.0)
|
|
202
203
|
user_agent_parser (2.21.0)
|
|
203
|
-
yard (0.9.
|
|
204
|
+
yard (0.9.45)
|
|
204
205
|
zeitwerk (2.7.3)
|
|
205
206
|
|
|
206
207
|
PLATFORMS
|
|
@@ -218,6 +219,7 @@ DEPENDENCIES
|
|
|
218
219
|
debug
|
|
219
220
|
json_schemer
|
|
220
221
|
kramdown
|
|
222
|
+
maxmind-db (~> 1.2)
|
|
221
223
|
otto!
|
|
222
224
|
rack-attack
|
|
223
225
|
rack-test
|
|
@@ -225,7 +227,7 @@ DEPENDENCIES
|
|
|
225
227
|
rake (~> 13.4)
|
|
226
228
|
reek (~> 6.5)
|
|
227
229
|
rspec (~> 3.13)
|
|
228
|
-
rubocop (~> 1.88.
|
|
230
|
+
rubocop (~> 1.88.2)
|
|
229
231
|
rubocop-performance
|
|
230
232
|
rubocop-rspec
|
|
231
233
|
rubocop-thread_safety
|
data/README.md
CHANGED
|
@@ -165,7 +165,8 @@ end
|
|
|
165
165
|
2. Registers your callback, invoked once per violation with an
|
|
166
166
|
`Otto::Security::CSP::Report`.
|
|
167
167
|
3. Injects `Otto::Security::CSP::ReportMiddleware`, pinned **outermost** in the
|
|
168
|
-
stack, which intercepts `POST`s to the
|
|
168
|
+
stack (only IP masking runs ahead of it), which intercepts `POST`s to the
|
|
169
|
+
report path, parses both the legacy
|
|
169
170
|
`application/csp-report` and the Reporting API `application/reports+json`
|
|
170
171
|
formats, enforces a 64 KiB body cap, and always answers `204 No Content` —
|
|
171
172
|
without touching your routes.
|
|
@@ -231,7 +232,16 @@ app = Otto.new("./routes")
|
|
|
231
232
|
# IP hashing: daily-rotating hashes enable analytics without tracking
|
|
232
233
|
```
|
|
233
234
|
|
|
234
|
-
Private and localhost IPs are exempted by default for development convenience, but this behavior can be customized via `configure_ip_privacy()` method. Geolocation
|
|
235
|
+
Private and localhost IPs are exempted by default for development convenience, but this behavior can be customized via `configure_ip_privacy()` method. Geolocation checks CDN headers (Cloudflare, AWS, Vercel, etc.) first, then an optional local country database—no external services required. You can name a trusted header to check first, plug in a MaxMind-format `.mmdb` file for an offline fallback, or bring your own reader:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
otto.configure_ip_privacy(
|
|
239
|
+
geo_header: 'X-Client-Country', # trusted app header, checked first
|
|
240
|
+
geo_db_path: 'data/country.mmdb' # offline fallback (needs the maxmind-db gem)
|
|
241
|
+
)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Geo headers are only trusted for requests that arrive via a configured trusted proxy (they are client-spoofable otherwise), the database is looked up on the already-masked IP, and `configure_ip_privacy(geo: false)` disables geo entirely. See [AGENTS.md](AGENTS.md) for detailed configuration options.
|
|
235
245
|
|
|
236
246
|
## Internationalization Support
|
|
237
247
|
|
|
@@ -357,7 +367,7 @@ gem install otto
|
|
|
357
367
|
|
|
358
368
|
## Documentation
|
|
359
369
|
|
|
360
|
-
- **[
|
|
370
|
+
- **[AGENTS.md](AGENTS.md)** - Comprehensive developer guidance covering authentication architecture, configuration freezing, IP privacy, structured logging, and multi-app patterns
|
|
361
371
|
- **[docs/](docs/)** - Technical guides and migration guides
|
|
362
372
|
- **[CHANGELOG.rst](CHANGELOG.rst)** - Version history, breaking changes, and upgrade notes
|
|
363
373
|
|