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.
Files changed (44) 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 +218 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +12 -10
  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 +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/simple_geo_resolver.rb +38 -5
  20. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  21. data/lib/otto/core/middleware_stack.rb +72 -25
  22. data/lib/otto/env_keys.rb +32 -0
  23. data/lib/otto/logging_helpers.rb +50 -1
  24. data/lib/otto/mcp/rate_limiting.rb +5 -2
  25. data/lib/otto/privacy/config.rb +245 -3
  26. data/lib/otto/privacy/core.rb +93 -14
  27. data/lib/otto/privacy/geo_resolver.rb +228 -128
  28. data/lib/otto/privacy/ip_privacy.rb +24 -0
  29. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  30. data/lib/otto/privacy.rb +3 -1
  31. data/lib/otto/request.rb +8 -1
  32. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  33. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  34. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  36. data/lib/otto/security/config.rb +23 -1
  37. data/lib/otto/security/core.rb +4 -1
  38. data/lib/otto/security/csp/report_middleware.rb +3 -1
  39. data/lib/otto/security/middleware/ip_privacy_middleware.rb +201 -12
  40. data/lib/otto/security/rate_limiter.rb +7 -1
  41. data/lib/otto/utils.rb +100 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +11 -3
  44. metadata +6 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de513e2f41fa9d2400937ac2ed577878bd124d5f760558bd12635bf8ad6f296a
4
- data.tar.gz: 4a69f3741d3a6dbbe5a7387e2504446ce690927be12ccf90fa814db1a893d13d
3
+ metadata.gz: d14c212b610b94a8a21abdae0c1d691e342c849e6160dc03615c8c00e30ddca8
4
+ data.tar.gz: 0a22e90e372914211b0828eb4c0048edb3171c011407778af73d0be098a79a68
5
5
  SHA512:
6
- metadata.gz: 77ee1dda2b8b84e3848fb72fd737b88b6d6efca57cd0d4effe909451a310aee51ac207a3152848ebc838df52bd02c3153cb818e1bafdc1000282a374be965375
7
- data.tar.gz: 8622e8974f115eee90940a157ea93180f2e4aaa05295f5362ca24f068b359b7d2a1396c42483e4d4fa37b9e8fbee7056460230eb68227033134eda09e5e4d8cc
6
+ metadata.gz: f1c55db0ffbe490ad295c6d73b1e6012f1e12702dd33f971f3e7a2a42f9b269cceaf0deccdb5e9fb958f56dfe338bbbb71561ea01ec8b2c7e18d71e507ddb065
7
+ data.tar.gz: 78f3e09cd71f33caceab30691d562a65552c765f4b1690b4b1ad36776cf89600839bf1fb37ab2a7d4335fd58e2d75ad403f6129f7e879dc12581ded07f1aa3ac
@@ -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,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.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.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.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)
@@ -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