otto 2.7.0 → 2.8.1

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.
data/lib/otto/privacy.rb CHANGED
@@ -7,6 +7,8 @@ require_relative 'privacy/config'
7
7
  require_relative 'privacy/ip_privacy'
8
8
  require_relative 'privacy/user_agent_privacy'
9
9
  require_relative 'privacy/geo_resolver'
10
+ require_relative 'privacy/asn_resolver'
11
+ require_relative 'privacy/anonymizer_resolver'
10
12
  require_relative 'privacy/redacted_fingerprint'
11
13
 
12
14
  # Otto::Privacy module provides IP address anonymization and privacy features
@@ -21,6 +23,13 @@ require_relative 'privacy/redacted_fingerprint'
21
23
  # - Geo-location resolution (country-level only): a configurable trusted header,
22
24
  # built-in CDN provider headers, an optional local MaxMind-format (.mmdb)
23
25
  # database looked up on the masked IP, or a custom resolver
26
+ # - ASN resolution (opt-in, default off): the network operator an address
27
+ # belongs to, from a local .mmdb looked up on the masked IP. Database-only —
28
+ # no CDN publishes a client-ASN header worth trusting
29
+ # - Anonymizer classification (opt-in, default off): whether an address is a
30
+ # known Tor / VPN / proxy / hosting egress, from a local .mmdb. The only
31
+ # lookup that reads the unmasked address, because anonymizer data lists
32
+ # individual nodes at /32 — it returns a label, never the address
24
33
  # - User agent anonymization (removes version numbers)
25
34
  #
26
35
  # Privacy is ENABLED BY DEFAULT. To disable:
data/lib/otto/request.rb CHANGED
@@ -15,6 +15,8 @@ class Otto
15
15
  # def show(req, res)
16
16
  # req.masked_ip # Privacy-safe masked IP
17
17
  # req.geo_country # ISO country code
18
+ # req.asn # Network operator, when enabled
19
+ # req.anonymizer # Tor/VPN/proxy label, when enabled
18
20
  # req.check_locale! # Set locale for request
19
21
  # end
20
22
  #
@@ -63,6 +65,10 @@ class Otto
63
65
  # If you need the geo country:
64
66
  # req.geo_country # => 'US' or nil
65
67
  #
68
+ # If you need the opt-in enrichment signals (nil unless enabled):
69
+ # req.asn # => 'AS15169', '**', or nil
70
+ # req.anonymizer # => 'tor', 'none', '**', or nil
71
+ #
66
72
  # If you need the full privacy fingerprint:
67
73
  # req.redacted_fingerprint # => RedactedFingerprint object or nil
68
74
 
@@ -92,6 +98,39 @@ class Otto
92
98
  redacted_fingerprint&.country || env['otto.privacy.geo_country']
93
99
  end
94
100
 
101
+ # Get the Autonomous System Number of the client's network
102
+ #
103
+ # Opt-in: nil unless the operator enabled ASN resolution. '**' means it is
104
+ # enabled but nothing resolved — distinguishable from "switched off", which
105
+ # is what makes the three-state contract worth keeping.
106
+ #
107
+ # Read straight from env rather than through the fingerprint. The
108
+ # `fingerprint&.x || env[...]` idiom used above treats any falsey value as
109
+ # "fall through", which is wrong for a value whose vocabulary is fixed and
110
+ # whose absence is itself meaningful.
111
+ #
112
+ # @return [String, nil] 'AS15169', '**', or nil when disabled
113
+ # @example
114
+ # req.asn # => 'AS15169'
115
+ def asn
116
+ env['otto.privacy.asn']
117
+ end
118
+
119
+ # Get the anonymizing-egress classification for the client address
120
+ #
121
+ # Opt-in: nil unless the operator enabled classification. 'none' means the
122
+ # database was consulted and did not list the address; '**' means no
123
+ # database answered. Do not collapse the two — 'none' is evidence that the
124
+ # address is not a known egress, '**' is the absence of any evidence.
125
+ #
126
+ # @return [String, nil] 'tor', 'proxy', 'vpn', 'residential_proxy',
127
+ # 'hosting', 'anonymous', 'none', '**', or nil when disabled
128
+ # @example
129
+ # req.anonymizer # => 'tor'
130
+ def anonymizer
131
+ env['otto.privacy.anonymizer']
132
+ end
133
+
95
134
  # Get anonymized user agent string
96
135
  #
97
136
  # Returns user agent with version numbers stripped for privacy.
@@ -241,19 +280,28 @@ class Otto
241
280
  #
242
281
  # Prefers the canonical decision recorded once by IPPrivacyMiddleware in
243
282
  # env['otto.via_trusted_proxy'] — evaluated against the original peer before
244
- # REMOTE_ADDR is masked, so it stays correct even after masking. Falls back
245
- # to evaluating the current REMOTE_ADDR when the middleware has not run
246
- # (standalone request use).
247
- #
248
- # This is the trusted-proxy *identity* check only and is independent of
249
- # count-based depth mode: depth resolves the client IP but never grants
250
- # proxy trust for X-Forwarded-Proto.
283
+ # REMOTE_ADDR is masked, so it stays correct even after masking. The key is
284
+ # tri-state: written only when proxy trust is configured, so its absence
285
+ # covers both "middleware not mounted" (standalone request use) and "no
286
+ # proxy trust configured" — the fallback below answers both by evaluating
287
+ # the config directly against the current REMOTE_ADDR (an unconfigured
288
+ # config yields false, matching what the middleware would have implied).
289
+ #
290
+ # A peer earns trust two ways (#226): its identity matches a configured
291
+ # trusted-proxy CIDR (filter mode), or count-based depth mode is active —
292
+ # configuring a depth asserts the connecting peer is the operator's
293
+ # (non-enumerable) proxy tier. The fallback mirrors the grant
294
+ # IPPrivacyMiddleware records so the two paths cannot disagree.
251
295
  #
252
296
  # @return [Boolean]
253
297
  def forwarded_by_trusted_proxy?
254
298
  return env['otto.via_trusted_proxy'] if env.key?('otto.via_trusted_proxy')
255
299
 
256
- otto_security_config ? trusted_proxy?(env['REMOTE_ADDR']) : false
300
+ config = otto_security_config
301
+ return false unless config
302
+ return true if config.trusted_proxy_depth_mode?
303
+
304
+ trusted_proxy?(env['REMOTE_ADDR'])
257
305
  end
258
306
 
259
307
  # See: http://stackoverflow.com/questions/10013812/how-to-prevent-jquery-ajax-from-following-a-redirect-after-a-post
@@ -38,6 +38,23 @@ class Otto
38
38
  hop count, not both.
39
39
  MSG
40
40
 
41
+ # Error raised when an app-configured trusted geo header (ip_privacy
42
+ # geo_header) is combined with count-based depth mode. Geo headers are
43
+ # honored only for peers matching enumerated trusted_proxies CIDRs
44
+ # (geo_headers_trusted? gates on trusted_proxies_configured?) — a hop
45
+ # trusted by count cannot be verified as the geo-setting CDN — so a
46
+ # geo_header configured alongside a depth could never be consulted.
47
+ # Failing loud at config time replaces a silent database/'**' fallback
48
+ # at request time.
49
+ GEO_HEADER_DEPTH_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
50
+ Cannot configure a trusted geo header (ip_privacy geo_header) together
51
+ with trusted_proxy_depth (count mode): geo headers are only honored
52
+ for peers matching enumerated trusted_proxies CIDRs, so the header
53
+ would be silently ignored. Use filter mode (add_trusted_proxy) for
54
+ header-based geo, or drop geo_header and use database-backed geo
55
+ (geo_db_path or geo_db_reader).
56
+ MSG
57
+
41
58
  # Forwarded-header sources depth mode (#trusted_proxy_depth) can count
42
59
  # hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
43
60
  # Both (Forwarded when present, else X-Forwarded-For). Mirrors
@@ -219,18 +236,30 @@ class Otto
219
236
 
220
237
  # Whether any trusted-proxy IP/CIDR/Regexp matchers are configured.
221
238
  #
222
- # This mirrors {#trusted_proxy?}, which consults the same matcher list, so
223
- # the two answers stay consistent: a request can only be "via a trusted
224
- # proxy" when matchers exist. It deliberately EXCLUDES count-based depth
225
- # mode depth resolves the client IP but never confers proxy identity
226
- # trust (the same decoupling {Otto::Request#forwarded_by_trusted_proxy?}
227
- # applies to X-Forwarded-Proto). Used to gate geo-header trust.
239
+ # This mirrors {#trusted_proxy?}, which consults the same matcher list.
240
+ # It deliberately EXCLUDES count-based depth mode: depth grants the peer
241
+ # blanket trust for otto.via_trusted_proxy (#226), but it cannot verify
242
+ # that the hop is a geo-setting CDN, so header-based geo stays gated on
243
+ # enumerated matchers only. Used to gate geo-header trust.
228
244
  #
229
245
  # @return [Boolean] true when at least one trusted-proxy matcher exists
230
246
  def trusted_proxies_configured?
231
247
  @trusted_proxy_matchers.any?
232
248
  end
233
249
 
250
+ # Whether ANY proxy-trust mode is configured — CIDR matchers (filter
251
+ # mode) or count-based depth. This is the gate for writing
252
+ # env['otto.via_trusted_proxy'] at all: when neither mode is configured
253
+ # the key is left ABSENT (tri-state contract), so downstream consumers
254
+ # can distinguish "operator configured trust and this peer failed it"
255
+ # (false) from "no proxy trust configured" (absent) and apply their own
256
+ # legacy heuristics only in the latter case.
257
+ #
258
+ # @return [Boolean] true when filter or depth mode is configured
259
+ def proxy_trust_configured?
260
+ trusted_proxies_configured? || trusted_proxy_depth_mode?
261
+ end
262
+
234
263
  # Whether count-based ("trust the last N hops") proxy resolution is active.
235
264
  #
236
265
  # When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
@@ -260,6 +289,9 @@ class Otto
260
289
 
261
290
  validate_trusted_proxy_depth!(depth)
262
291
  raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
292
+ # Depth-then-geo assignment order is caught by configure_ip_privacy;
293
+ # this catches geo-then-depth so both orders fail eagerly.
294
+ raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if depth.to_i >= 1 && @ip_privacy_config&.geo_header
263
295
 
264
296
  @trusted_proxy_depth = depth
265
297
  end
@@ -763,6 +795,12 @@ class Otto
763
795
  return if @trusted_proxy_depth.nil?
764
796
 
765
797
  raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
798
+
799
+ # Backstop for the direct path (ip_privacy_config.geo_header=) that
800
+ # bypasses both eager checks; the setters cover the common orders.
801
+ return unless @trusted_proxy_depth >= 1 && @ip_privacy_config&.geo_header
802
+
803
+ raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE
766
804
  end
767
805
 
768
806
  # Parse a value into an IPAddr, returning nil for invalid / non-IP input.
@@ -222,15 +222,17 @@ class Otto
222
222
 
223
223
  # CSP directives for the development environment.
224
224
  #
225
- # Development mode allows inline scripts/styles and hot reloading
226
- # connections for better developer experience with build tools like Vite.
225
+ # Development mode allows nonce-authorized inline scripts, inline styles,
226
+ # HTTP(S) scripts, and hot-reloading connections for build tools such as
227
+ # Vite. HTTP(S) script sources support both same-origin reverse proxies
228
+ # (for example Caddy) and direct local Vite servers on another port.
227
229
  #
228
230
  # @param nonce [String] nonce value injected into `script-src`
229
231
  # @return [Array<String>] directive strings, each terminated with `;`
230
232
  def development_directives(nonce)
231
233
  [
232
234
  "default-src 'none';",
233
- "script-src 'nonce-#{nonce}' 'unsafe-inline';", # Allow inline scripts for development tools
235
+ "script-src 'self' 'nonce-#{nonce}' http: https:;",
234
236
  "style-src 'self' 'unsafe-inline';",
235
237
  "connect-src 'self' ws: wss: http: https:;", # Allow HTTP and all WebSocket connections for dev tools
236
238
  "img-src 'self' data:;",
@@ -21,7 +21,9 @@ class Otto
21
21
  # Because it now runs ahead of everything, facts about the ORIGINAL peer
22
22
  # that downstream code can no longer derive from the (masked) REMOTE_ADDR
23
23
  # are recorded first, as leak-free booleans — never as addresses:
24
- # env['otto.via_trusted_proxy'] and env['otto.peer_loopback'].
24
+ # env['otto.via_trusted_proxy'] (only when proxy trust is configured —
25
+ # absent otherwise, see the tri-state note in #call) and
26
+ # env['otto.peer_loopback'].
25
27
  #
26
28
  # @example Default behavior (privacy enabled)
27
29
  # # env['REMOTE_ADDR'] is masked to 192.168.1.0
@@ -62,11 +64,22 @@ class Otto
62
64
  # secure? can authorize X-Forwarded-Proto canonically even after
63
65
  # REMOTE_ADDR is rewritten to the masked client IP. Leak-free boolean.
64
66
  #
65
- # This is the trusted-proxy *identity* check only it is deliberately
66
- # independent of count-based depth mode. Depth resolves the client IP;
67
- # it never grants proxy trust for X-Forwarded-Proto (matching the
68
- # downstream OneTimeSecret behavior).
69
- env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
67
+ # TRI-STATE: the key is written ONLY when the operator configured
68
+ # proxy trust (CIDR matchers or a depth). Present, its value is
69
+ # authoritative in both directions true means the peer matched a
70
+ # CIDR (filter mode) or depth mode is active (configuring a depth
71
+ # asserts the connecting peer IS the operator's proxy tier, #226);
72
+ # false means trust IS configured and this peer failed it. Absent
73
+ # means no proxy trust is configured at all, so downstream consumers
74
+ # may fall back to their own heuristics without this key vetoing
75
+ # them. Writing false on unconfigured deployments made false
76
+ # ambiguous between "untrusted peer" and "nothing configured", which
77
+ # forced consumers into grant-only reads (#228).
78
+ # respond_to?: like geo_headers_trusted?, a partial/duck-typed
79
+ # config (or nil) that cannot report trust state is "unconfigured".
80
+ if @security_config.respond_to?(:proxy_trust_configured?) && @security_config.proxy_trust_configured?
81
+ env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
82
+ end
70
83
 
71
84
  # Same rationale, for loopback: this middleware runs outermost, so a
72
85
  # downstream middleware that must authenticate a DIRECT LOCAL CALL
@@ -207,7 +220,8 @@ class Otto
207
220
  #
208
221
  # This early return also means NONE of the privacy fingerprint
209
222
  # values are produced for exempt IPs — no otto.privacy.fingerprint,
210
- # masked_ip, hashed_ip, geo_country, or correlation_hash. That is
223
+ # masked_ip, hashed_ip, geo_country, asn, anonymizer, or
224
+ # correlation_hash. That is
211
225
  # intentional and consistent: the correlation hash targets public
212
226
  # audit-trail traffic, so req.ip_correlation_hash is nil for
213
227
  # localhost / RFC-1918 addresses (the default dev path) even when a
@@ -238,6 +252,10 @@ class Otto
238
252
  env['otto.privacy.masked_ip'] = fingerprint.masked_ip
239
253
  env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
240
254
  env['otto.privacy.geo_country'] = fingerprint.country
255
+ # nil unless the operator opted in; '**' when enabled but
256
+ # unresolved, so a consumer can tell "off" from "no answer".
257
+ env['otto.privacy.asn'] = fingerprint.asn
258
+ env['otto.privacy.anonymizer'] = fingerprint.anonymizer
241
259
 
242
260
  # Fingerprint the FULL client IP here — while client_ip is still the
243
261
  # real address, before REMOTE_ADDR is masked below — so it identifies
@@ -420,12 +438,20 @@ class Otto
420
438
  Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
421
439
  end
422
440
 
423
- # Check if an IP is from a trusted proxy
441
+ # Check if the connecting peer counts as a trusted proxy
442
+ #
443
+ # CIDR filter mode checks the peer's identity against the configured
444
+ # matchers. Count-based depth mode has no enumerable matchers — the
445
+ # depth setting itself is the operator's assertion that the peer is
446
+ # their proxy tier — so an active depth grants peer trust outright
447
+ # (#226). Geo-header trust is unaffected: geo_headers_trusted? gates on
448
+ # trusted_proxies_configured?, which stays matcher-only.
424
449
  #
425
450
  # @param ip [String] IP address to check
426
451
  # @return [Boolean] true if IP is from a trusted proxy
427
452
  def trusted_proxy?(ip)
428
453
  return false unless @security_config
454
+ return true if @security_config.trusted_proxy_depth_mode?
429
455
 
430
456
  @security_config.trusted_proxy?(ip)
431
457
  end
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.7.0'
6
+ VERSION = '2.8.1'
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.7.0
4
+ version: 2.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-16 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: concurrent-ruby
@@ -131,6 +132,7 @@ files:
131
132
  - ".reek.yml"
132
133
  - ".rspec"
133
134
  - ".rubocop.yml"
135
+ - ".rubocop_todo.yml"
134
136
  - ".yardopts"
135
137
  - AGENTS.md
136
138
  - CHANGELOG.rst
@@ -145,6 +147,7 @@ files:
145
147
  - docs/.gitignore
146
148
  - docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md
147
149
  - docs/1108-STREAMING_SUPPORT_SUMMARY.md
150
+ - docs/enrichment.md
148
151
  - docs/geo-country.md
149
152
  - docs/ipaddr-encoding-quirk.md
150
153
  - docs/migrating/v2.0.0-pre1.md
@@ -255,6 +258,8 @@ files:
255
258
  - lib/otto/mcp/schema_validation.rb
256
259
  - lib/otto/mcp/server.rb
257
260
  - lib/otto/privacy.rb
261
+ - lib/otto/privacy/anonymizer_resolver.rb
262
+ - lib/otto/privacy/asn_resolver.rb
258
263
  - lib/otto/privacy/config.rb
259
264
  - lib/otto/privacy/core.rb
260
265
  - lib/otto/privacy/geo_resolver.rb
@@ -329,6 +334,7 @@ licenses:
329
334
  - MIT
330
335
  metadata:
331
336
  rubygems_mfa_required: 'true'
337
+ post_install_message:
332
338
  rdoc_options: []
333
339
  require_paths:
334
340
  - lib
@@ -346,7 +352,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
346
352
  - !ruby/object:Gem::Version
347
353
  version: '0'
348
354
  requirements: []
349
- rubygems_version: 3.6.9
355
+ rubygems_version: 3.5.22
356
+ signing_key:
350
357
  specification_version: 4
351
358
  summary: Define your rack-apps in plaintext.
352
359
  test_files: []