otto 2.6.0 → 2.8.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +254 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +13 -11
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +180 -0
  18. data/docs/migrating/v2.3.0.md +55 -22
  19. data/docs/reverse-proxy-network-services.md +19 -6
  20. data/examples/simple_geo_resolver.rb +38 -5
  21. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  22. data/lib/otto/core/middleware_stack.rb +72 -25
  23. data/lib/otto/env_keys.rb +58 -12
  24. data/lib/otto/logging_helpers.rb +50 -1
  25. data/lib/otto/mcp/rate_limiting.rb +5 -2
  26. data/lib/otto/privacy/config.rb +245 -3
  27. data/lib/otto/privacy/core.rb +104 -14
  28. data/lib/otto/privacy/geo_resolver.rb +228 -128
  29. data/lib/otto/privacy/ip_privacy.rb +24 -0
  30. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  31. data/lib/otto/privacy.rb +3 -1
  32. data/lib/otto/request.rb +25 -9
  33. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  34. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  35. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  36. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  37. data/lib/otto/security/config.rb +61 -1
  38. data/lib/otto/security/core.rb +4 -1
  39. data/lib/otto/security/csp/report_middleware.rb +3 -1
  40. data/lib/otto/security/middleware/ip_privacy_middleware.rb +228 -18
  41. data/lib/otto/security/rate_limiter.rb +7 -1
  42. data/lib/otto/utils.rb +100 -0
  43. data/lib/otto/version.rb +1 -1
  44. data/lib/otto.rb +11 -3
  45. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de513e2f41fa9d2400937ac2ed577878bd124d5f760558bd12635bf8ad6f296a
4
- data.tar.gz: 4a69f3741d3a6dbbe5a7387e2504446ce690927be12ccf90fa814db1a893d13d
3
+ metadata.gz: 2d4e92c540f41f9cad40b495f8a7106f49a11d8eb9b5011981c97bf585026065
4
+ data.tar.gz: 10c592de40e1b0b35f57379b5dfca20a51b76967898e0cccce33d903fbfcb75d
5
5
  SHA512:
6
- metadata.gz: 77ee1dda2b8b84e3848fb72fd737b88b6d6efca57cd0d4effe909451a310aee51ac207a3152848ebc838df52bd02c3153cb818e1bafdc1000282a374be965375
7
- data.tar.gz: 8622e8974f115eee90940a157ea93180f2e4aaa05295f5362ca24f068b359b7d2a1396c42483e4d4fa37b9e8fbee7056460230eb68227033134eda09e5e4d8cc
6
+ metadata.gz: 21268aaf0ec8b815f8981691ef76fe9a41347eeb184b8bb04b7403d5c1aefda9339acd97bee280e53845f3acf17a17cd68175cb272153acfd176ff16183cb0c6
7
+ data.tar.gz: 0d30534c168a390f3dec1cf8e35388e0cec48c7285a0c10e1358af71b03032a52043cb0fbad09d9578ccea34c8ee27ac49eb90eefc09d4b5cbe740f530a6b5f2
@@ -69,7 +69,7 @@ jobs:
69
69
  lockfile: "unlocked"
70
70
 
71
71
  steps:
72
- - uses: actions/checkout@v7.0.0
72
+ - uses: actions/checkout@v7.0.1
73
73
  - name: Set up Ruby
74
74
  uses: ruby/setup-ruby@v1
75
75
  continue-on-error: ${{ matrix.experimental }}
@@ -48,7 +48,7 @@ jobs:
48
48
 
49
49
  steps:
50
50
  - name: Checkout repository
51
- uses: actions/checkout@v7.0.0
51
+ uses: actions/checkout@v7.0.1
52
52
  with:
53
53
  fetch-depth: 1
54
54
 
@@ -26,7 +26,7 @@ jobs:
26
26
  actions: read # Required for Claude to read CI results on PRs
27
27
  steps:
28
28
  - name: Checkout repository
29
- uses: actions/checkout@v7.0.0
29
+ uses: actions/checkout@v7.0.1
30
30
  with:
31
31
  fetch-depth: 1
32
32
 
@@ -21,7 +21,7 @@ jobs:
21
21
 
22
22
  steps:
23
23
  - name: Checkout code
24
- uses: actions/checkout@v7.0.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.0
91
+ uses: actions/checkout@v7.0.1
92
92
 
93
93
  - name: Set up Ruby
94
94
  uses: ruby/setup-ruby@v1
@@ -134,7 +134,7 @@ jobs:
134
134
 
135
135
  steps:
136
136
  - name: Checkout
137
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
137
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
138
138
  with:
139
139
  persist-credentials: false
140
140
 
@@ -41,7 +41,7 @@ jobs:
41
41
 
42
42
  steps:
43
43
  - name: Checkout code
44
- uses: actions/checkout@v7.0.0
44
+ uses: actions/checkout@v7.0.1
45
45
 
46
46
  - uses: ruby/setup-ruby@v1
47
47
  with:
@@ -42,7 +42,7 @@ jobs:
42
42
 
43
43
  steps:
44
44
  - name: Checkout repository
45
- uses: actions/checkout@v7.0.0
45
+ uses: actions/checkout@v7.0.1
46
46
  with:
47
47
  fetch-depth: 0
48
48
 
@@ -95,13 +95,30 @@ repos:
95
95
  # - rubocop-rspec
96
96
 
97
97
  # Commit message issue tracking integration
98
- - repo: https://github.com/avilaton/add-msg-issue-prefix-hook
99
- rev: v0.0.13
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
- - "--default="
106
- - "--pattern=(i18n(?=/)|([a-zA-Z0-9]{0,10}-?[0-9]{1,5}))"
107
- - "--template=[#{}]"
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,260 @@ 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.8.0:
11
+
12
+ 2.8.0 — 2026-08-07
13
+ ==================
14
+
15
+ Changed
16
+ -------
17
+
18
+ - ``env['otto.via_trusted_proxy']`` is now tri-state: written only when proxy
19
+ trust is configured (CIDR matchers or ``trusted_proxy_depth``). A present
20
+ key is authoritative in both directions; an absent key means
21
+ "unconfigured". Consumers reading the raw env key should presence-check
22
+ (``env.key?``) rather than compare ``== true``. (#228)
23
+
24
+ - Configuring an ip-privacy ``geo_header`` together with
25
+ ``trusted_proxy_depth`` now raises ``ArgumentError`` at configuration time:
26
+ geo headers are only honored for CIDR-verified proxies, so under depth mode
27
+ the header could never be consulted. Database-backed geo (``geo_db_path`` /
28
+ ``geo_db_reader``) remains fully supported under depth. (#228)
29
+
30
+ Fixed
31
+ -----
32
+
33
+ - Depth mode now records a peer-trust verdict in
34
+ ``env['otto.via_trusted_proxy']`` (previously always ``false``, leaving
35
+ downstream middleware with no trust signal);
36
+ ``Otto::Request#forwarded_by_trusted_proxy?`` mirrors the grant on its
37
+ no-middleware fallback path. (#226)
38
+
39
+ Documentation
40
+ -------------
41
+
42
+ - Corrected the v2.3.0 migration guide's depth-porting guidance: map depth
43
+ values directly, not ``+1`` — otto's chain index already accounts for the
44
+ appended ``REMOTE_ADDR``. (#227, #228)
45
+
46
+ .. _changelog-2.7.0:
47
+
48
+ 2.7.0 — 2026-08-03
49
+ ==================
50
+
51
+ Added
52
+ -----
53
+
54
+ - Configurable geo-country resolution. ``configure_ip_privacy`` now accepts
55
+ ``geo_header:`` — a trusted, app-configured request header checked *before*
56
+ the built-in CDN headers (e.g. ``geo_header: 'X-Client-Country'``);
57
+ ``geo_db_path:`` — a MaxMind-format ``.mmdb`` country database giving an
58
+ offline IP->country fallback (needs the optional ``maxmind-db`` gem); and
59
+ ``geo_db_reader:`` — bring your own reader (any object responding to
60
+ ``#get``). A bad ``geo_db_path`` fails at boot, not per-request. (#206)
61
+
62
+ - ``X-Vercel-IP-Country`` is now recognized among the built-in CDN/provider
63
+ geo headers. (#206)
64
+
65
+ - Named privacy profiles: ``configure_ip_privacy(profile: :anonymous | :masked
66
+ | :audit)`` (also accepted by ``Otto::Privacy::Config.new``) — a validated
67
+ preset over the existing knobs. ``:masked`` is the default posture (public
68
+ IPs masked, private exempt), ``:anonymous`` masks every IP including
69
+ private/localhost, and ``:audit`` disables IP privacy for
70
+ private/compliance environments (retention responsibility transfers to the
71
+ operator). ``Config#profile`` derives the label from live knob state, so it
72
+ cannot go stale. Unknown names raise ``ArgumentError``. (#218)
73
+
74
+ - ``env['otto.ip_match']``: a verdict-only CIDR membership check over the
75
+ resolved, UNMASKED client IP, installed by ``IPPrivacyMiddleware`` under
76
+ every profile. Call it with an array of CIDR strings (or ``IPAddr``
77
+ objects) and get ``true``/``false`` back, so downstream access control
78
+ (e.g. a per-tenant allowlist) can match at full /32–/128 precision while
79
+ ``otto.client_ip``, ``REMOTE_ADDR``, logs, and fingerprints stay masked —
80
+ only the closure lands in env, never the address. Returns ``false`` when no
81
+ client IP resolves (fail-closed); invalid CIDR entries raise
82
+ ``IPAddr::InvalidAddressError``. (#218)
83
+
84
+ - ``Otto::Utils.ip_in_cidrs?(ip, cidrs)``: the general-purpose CIDR-set
85
+ matcher behind ``otto.ip_match``, sharing the trusted-proxy matcher's
86
+ semantics (port stripping, ``IPAddr#native`` folding, family-aware
87
+ skipping). Runtime ``ip`` fails closed; invalid ``cidrs`` entries raise.
88
+ Accepts pre-parsed ``IPAddr`` entries for hot paths. (#218)
89
+
90
+ - ``MiddlewareStack#execution_order`` returns middleware classes in the order
91
+ they actually run (outermost first), resolving pin tiers — unlike
92
+ ``#middleware_list``, which reports registration order. (#219)
93
+
94
+ - ``add_with_position`` accepts ``position: :innermost`` as a clearer synonym
95
+ for ``:first``, and a new ``position: :entrypoint`` tier that pins middleware
96
+ outside even ``:outermost`` entries. (#219)
97
+
98
+ - ``AuthFailure`` now carries a ``terminal`` flag (``terminal?`` predicate),
99
+ and ``AuthStrategy#failure`` accepts ``terminal: true`` — meaning "credentials
100
+ were presented, examined, and rejected; do not consult further strategies."
101
+ ``RouteAuthWrapper`` halts the chain and renders that failure's 401
102
+ regardless of strategy order, so mixed chains (``auth=basicauth,noauth``)
103
+ fail closed on invalid credentials instead of proceeding as anonymous. Plain
104
+ failures keep the existing OR fallthrough. (#220)
105
+
106
+ Changed
107
+ -------
108
+
109
+ - Geo resolution now runs against a privacy-masked view — the masked IP and an
110
+ env with the IP-bearing headers masked — so the unmasked address never
111
+ reaches a custom resolver or the database. Country networks are >= /24, so
112
+ /24-masked results are identical at the default masking level. A custom
113
+ resolver invoked through the middleware now receives the masked IP and env;
114
+ direct ``GeoResolver.resolve`` callers are unchanged. (#206)
115
+
116
+ - Middleware pins are now recorded per *entry* rather than per class. An
117
+ ``:outermost`` pin previously reordered every registration of that class,
118
+ including ones registered separately with different arguments. (#219)
119
+
120
+ - ``Otto::LoggingHelpers.request_context`` masks its ``:ip`` field when the
121
+ request never passed through ``IPPrivacyMiddleware`` (previously it fell back
122
+ to the raw ``REMOTE_ADDR``). New ``LoggingHelpers.privacy_safe_ip`` exposes
123
+ that behavior for callers outside Otto's stack. (#219)
124
+
125
+ - ``Otto::CaddyTLS::LocalhostGuard`` reads the new leak-free boolean
126
+ ``env['otto.peer_loopback']`` — the loopback verdict ``IPPrivacyMiddleware``
127
+ records on the untouched socket peer before masking — falling back to
128
+ ``REMOTE_ADDR`` when absent. The guard still authenticates the raw peer,
129
+ which it can no longer read directly now that IP masking runs first. (#219)
130
+
131
+ - ``RouteAuthWrapper`` multi-strategy chains now treat an anonymous success
132
+ (a ``StrategyResult`` with no user, e.g. from ``noauth``) as a held fallback
133
+ rather than an immediate win: the rest of the chain still runs so a later
134
+ credentialed strategy can reject presented credentials terminally. The
135
+ fallback wins once the chain completes without an authenticated success or
136
+ terminal failure, preserving OR semantics for credential-less requests.
137
+ Consequently, in ``auth=noauth,apikey`` a later authenticated success now
138
+ wins over an earlier anonymous one. (#220)
139
+
140
+ Removed
141
+ -------
142
+
143
+ - The built-in ``KNOWN_RANGES`` IP-range guess table (and ``detect_by_range``).
144
+ When no header, custom resolver, or database resolves a country, the result
145
+ is now ``'**'`` (unknown) rather than a guess from a hardcoded ~14-entry
146
+ table that mislabeled whole cloud regions. Callers that relied on the table
147
+ (e.g. ``8.8.8.8`` -> ``US``) now get ``'**'``; configure a database or an
148
+ edge header for real geo-location. (#206)
149
+
150
+ Fixed
151
+ -----
152
+
153
+ - IP privacy now redacts the RFC 7239 ``Forwarded`` header
154
+ (``HTTP_FORWARDED``), which Otto reads as an authoritative client-IP source
155
+ in count-based depth mode. Previously it was left intact while
156
+ ``X-Forwarded-For`` and friends were masked, so downstream code could read
157
+ the real client IP from its ``for=`` token. Only the ``for=`` value is
158
+ replaced; ``proto=``/``host=``/``by=`` and the header structure are
159
+ preserved. When no client IP resolves, the forwarded headers are dropped
160
+ rather than left to leak a raw address. (#206)
161
+
162
+ - IPv4-mapped IPv6 CIDR *ranges* are now folded through ``IPAddr#native``, so
163
+ the fold is symmetric with the client address. Previously only the client
164
+ was folded, so a mapped range (``::ffff:10.0.0.0/104``) failed the
165
+ address-family check and was silently skipped — a wrong verdict rather than
166
+ an error. Affects ``Otto::Utils.ip_in_cidrs?`` / ``otto.ip_match`` and
167
+ trusted-proxy entries, where an unmatched proxy silently withheld
168
+ ``otto.via_trusted_proxy`` and with it ``Request#secure?`` and geo-header
169
+ trust. **Behavior change** for anyone who configured a mapped-IPv6 range: it
170
+ now matches the IPv4 clients it names — including ``::ffff:0:0/96``, the
171
+ whole mapped space, which matches every IPv4 address. The prefix must cover
172
+ the mapped marker (``/96`` or longer); ``::ffff:10.0.0.0/64`` masks the
173
+ marker away and still matches neither form. Plain IPv4/IPv6 ranges are
174
+ unaffected, and pre-parsed ``IPAddr`` entries are not mutated. (#218)
175
+
176
+ - ``IPPrivacyMiddleware`` reads the privacy setting per request instead of
177
+ caching it at construction. Otto builds its middleware stack at the end of
178
+ ``Otto.new`` while ``configure_ip_privacy`` stays legal until the first
179
+ request, so a post-construction ``configure_ip_privacy(profile: :audit)``
180
+ was silently ignored and the middleware kept masking. (#218)
181
+
182
+ - ``IPPrivacyMiddleware`` no longer interpolates the unmasked client IP into
183
+ its debug log. Under ``Otto.debug``, the pre-mask resolution line
184
+ (``:masked``, ``:anonymous``) and the private/localhost exemption line
185
+ (``:masked``) logged the raw address — handing back through the log exactly
186
+ what the profile withholds from env. The resolution line is gone; the
187
+ masking path logs the masked IP, and the exemption line now records only
188
+ that the exemption fired. ``:audit`` is unaffected. (#218)
189
+
190
+ - ``configure_ip_privacy`` is now all-or-nothing: assignments are dry-run
191
+ against a copy and validated there before the live config is touched, so a
192
+ rejected value (e.g. ``octet_precision: 7`` alongside a ``profile:`` preset)
193
+ can no longer leave the preset half-applied. (#218)
194
+
195
+ - ``configure_ip_privacy`` fails loudly on falsy knobs. ``octet_precision``,
196
+ ``hash_rotation`` and ``redis`` were truthiness-guarded, so an explicit
197
+ ``false`` was silently dropped instead of assigned or rejected; every kwarg
198
+ now follows the same nil guard — ``nil`` means "leave unchanged", anything
199
+ else must take effect or raise. A non-Numeric ``hash_rotation`` raises
200
+ ``ArgumentError`` naming the value rather than ``NoMethodError``. (#218)
201
+
202
+ - ``Otto::Privacy::Config#profile=`` raises ``ArgumentError`` for a value that
203
+ cannot name a profile; ``config.profile = 123`` and ``config.profile = nil``
204
+ previously raised ``NoMethodError`` on ``#to_sym``. The ``profile:`` option
205
+ form still treats ``nil`` as "leave unchanged", but a non-nameable value now
206
+ raises before any other option in the same call is applied. (#218)
207
+
208
+ - ``IPPrivacyMiddleware``'s idempotency guard installs a fail-closed
209
+ ``otto.ip_match`` when ``otto.client_ip`` was set outside the middleware
210
+ (out of contract, but previously left the advertised capability ``nil`` and
211
+ raised ``NoMethodError`` downstream). It deliberately does not rebuild the
212
+ check from ``otto.client_ip``, which may be masked — matching a masked
213
+ address against a narrow CIDR yields false allows. The deny is logged.
214
+ (#218)
215
+
216
+ - ``Otto::Request#secure?`` now honors ``env['rack.url_scheme']`` — the
217
+ canonical Rack scheme key that ``Rack::Request#scheme``/``#ssl?`` (and
218
+ therefore the session Secure-cookie gate, ``Rack::Protection``, and Otto's
219
+ own CSRF middleware) read. An upstream middleware that normalizes the scheme
220
+ the idiomatic Rack way is now visible to ``secure?`` instead of producing a
221
+ second, divergent scheme-truth. The trusted-proxy gate on the raw
222
+ ``X-Forwarded-Proto`` / ``X-Scheme`` headers is unchanged. (#214)
223
+
224
+ Security
225
+ --------
226
+
227
+ - Geo headers (``CF-IPCountry`` and friends, plus any configured
228
+ ``geo_header``) are now trusted ONLY for a request that demonstrably arrived
229
+ via a configured CIDR trusted proxy. Previously any client could pick its own
230
+ country by sending ``CF-IPCountry``/``X-Client-Country``. An unverifiable
231
+ origin is no longer trusted: count-based depth mode, and — a behavior change
232
+ — deployments with **no** trusted-proxy configuration. **Migration:** to keep
233
+ header-based geo, configure ``trusted_proxies`` (CIDR matchers) so Otto can
234
+ verify the proxy origin. Count-based ``trusted_proxy_depth`` does NOT enable
235
+ header trust, so depth-mode and header-only setups should set ``geo_db_path``
236
+ for a local database instead. Otherwise resolution returns ``'**'``. (#206)
237
+
238
+ - IP masking now applies to the whole middleware stack, not just the
239
+ application. ``IPPrivacyMiddleware`` was registered ``position: :first``,
240
+ which is first-in-*array* and therefore **innermost**, so every other
241
+ middleware Otto mounts — plus anything added via ``Otto#use`` — received the
242
+ raw ``REMOTE_ADDR``, an un-anonymized User-Agent, and a nil
243
+ ``env['otto.client_ip']``. It is now pinned outermost. (#219)
244
+
245
+ - Rate-limit logging no longer writes raw client IPs. The ``rack.attack``
246
+ subscribers in ``Otto::Security::RateLimiting`` and ``Otto::MCP::RateLimiter``
247
+ interpolated ``req.ip`` into a ``warn``-level line on every blocked request,
248
+ so deployments on the default masked profile leaked public IPs to their logs.
249
+ Both now log a masked address. (#219)
250
+
251
+ AI Assistance
252
+ -------------
253
+
254
+ - Geo-header source and local IP->country database fallback designed and
255
+ implemented with AI assistance, including adversarial review and coverage
256
+ for header precedence, spoofing, depth-mode trust, IPv6, custom-resolver
257
+ sealing, boot-time validation, and the real ``maxmind-db`` reader against a
258
+ generated fixture. (#206)
259
+
260
+ - IP precision capability, privacy profiles, middleware ordering audit, and
261
+ terminal auth failures: design, implementation, adversarial review, and
262
+ regression coverage developed with AI assistance. (#214, #218, #219, #220)
263
+
10
264
  .. _changelog-2.6.0:
11
265
 
12
266
  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.1', require: false
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.6.0)
4
+ otto (2.8.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.7)
19
- crass (1.0.6)
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)
@@ -59,7 +59,7 @@ GEM
59
59
  prism (>= 1.3.0)
60
60
  rdoc (>= 4.0.0)
61
61
  reline (>= 0.4.2)
62
- json (2.20.0)
62
+ json (2.21.2)
63
63
  json_schemer (2.5.0)
64
64
  bigdecimal
65
65
  hana (~> 1.3)
@@ -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.1)
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.2)
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.1)
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.49.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.9)
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.44)
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.1)
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 report path, parses both the legacy
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 uses CDN headers (Cloudflare, AWS, etc.) with fallback to IP ranges—no external services required. See [CLAUDE.md](CLAUDE.md) for detailed configuration options.
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
- - **[CLAUDE.md](CLAUDE.md)** - Comprehensive developer guidance covering authentication architecture, configuration freezing, IP privacy, structured logging, and multi-app patterns
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
 
data/docs/.gitignore CHANGED
@@ -3,6 +3,7 @@
3
3
  !migrating/
4
4
  !migrating/*.md
5
5
  !ipaddr-encoding-quirk.md
6
+ !geo-country.md
6
7
  !modern-authentication-authorization-landscape.md
7
8
  !multi-strategy-authentication-design.md
8
9
  !reverse-proxy-network-services.md