vpndetection 3.1.0 → 3.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec6ec791baf60e18a16396d053982e23588f084125fa8e54d73554cdfabeea26
4
- data.tar.gz: 2f8abd7e9159b1ae42840706a8f387602f5a655400634147a7facdeb501a1efe
3
+ metadata.gz: e3489ebf7b31c0307756abb483614adb213f1b9feb37890ed1e5b1f163f35c02
4
+ data.tar.gz: b0f1881979f90e0a0378bf399bf1442005c30d1fe775f1c332a71418470799b4
5
5
  SHA512:
6
- metadata.gz: 9b64120388a69ece3380e2c413f25967738a8afaef826d62d1729ed16fce48b6980bea4b301e8f19d66a2f2f3c4f9e0c1e00d2761e0243b6d8777a9a947f9596
7
- data.tar.gz: 8d427526b7b79d44aa777080c140da7e79da69160018cff9766f17b2fe8ccd9464f825173fe8593f5b3e5dae7d3ed94d9d373f0d0df783bbaedf08d9eac2e361
6
+ metadata.gz: a346d47d4e39b0b0ed62649db5d32bf9718f04e15472ff1a3ca6bf2a470cbc5ef69be8e1bfcaa72078d165724c386e948d1003db7eb0a96a02603b848737c53e
7
+ data.tar.gz: 72ad76f8e9fed8ac94b3aaee0fea5e82a40746da4c3a0f62c99f6ea4469fc94d13ec23b064de9025b29b44f163d3b5d174a08774bb965fab00c6d190698023df
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VPNDetection
4
+ module Middleware
5
+ # Deciding whether an answer is worth blocking.
6
+ #
7
+ # A condition is written in the shape of a served answer and keyed by the
8
+ # same names the API uses, so what you write here reads like what you get
9
+ # back. Symbol and string keys both work.
10
+ #
11
+ # { is_vpn: true }
12
+ # { is_vpn: true, vpn: { provider: 'nordvpn' } }
13
+ # { is_resproxy: true, resproxy: { hits: { gte: 5 } } }
14
+ # { vpn: { confidence: %w[high medium] } }
15
+ # [{ is_tor: true }, { is_resproxy: true }] # a list is OR
16
+ #
17
+ # A value may be a scalar (equality, strings without regard to case), an
18
+ # Array meaning any-of, a Hash of `gte`/`gt`/`lte`/`lt` bounding a number,
19
+ # or a nested condition. A member set to `false` or `nil` is ignored
20
+ # entirely - a condition states the positive signals you act on, so there is
21
+ # no way to write "block when this is false", which would otherwise read as
22
+ # blocking everybody.
23
+ module Condition
24
+ BOUND_KEYS = %w[gte gt lte lt].freeze
25
+
26
+ module_function
27
+
28
+ # Whether an answer satisfies the condition, and should therefore be
29
+ # blocked.
30
+ def matches?(condition, result)
31
+ Array(wrap(condition)).any? { |one| matches_object?(one, result.raw) }
32
+ end
33
+
34
+ # The top-level members a condition names that this answer did not carry.
35
+ #
36
+ # A field your plan does not include is absent rather than false, so a
37
+ # condition naming one can never match and the block would silently never
38
+ # fire. Gating is per top-level member, which is why only the first path
39
+ # segment is checked: a detail object present but empty is a real answer
40
+ # meaning the flag is false, not a plan gap.
41
+ #
42
+ # A locally answered bogon needs no special case: it is synthesized in the
43
+ # widest shape, so every member is present and nothing reads as missing.
44
+ def missing_members(condition, result)
45
+ missing = []
46
+ wrap(condition).each do |one|
47
+ one.each do |member, want|
48
+ name = member.to_s
49
+ next if constraint_count(want).zero? || missing.include?(name)
50
+
51
+ missing << name unless result.raw.key?(name)
52
+ end
53
+ end
54
+ missing
55
+ end
56
+
57
+ # Refuse a condition that constrains nothing.
58
+ #
59
+ # Ignoring `false` means `{ is_vpn: false }` and `{}` have no terms left
60
+ # to satisfy, so they would match every answer and block all traffic.
61
+ # Nobody writes that on purpose, and failing when the middleware is built
62
+ # beats discovering it in production.
63
+ def validate!(condition)
64
+ return if condition.nil?
65
+
66
+ wrap(condition).each do |one|
67
+ next unless constraint_count(one).zero?
68
+
69
+ raise ArgumentError,
70
+ "vpndetection: block condition #{one.inspect} constrains nothing, which " \
71
+ 'would block every request; a member set to false or nil is ignored, so ' \
72
+ 'state the positive signals you act on'
73
+ end
74
+ end
75
+
76
+ # How many leaf constraints a condition actually carries.
77
+ def constraint_count(condition)
78
+ case condition
79
+ when nil, false then 0
80
+ when Hash
81
+ bound?(condition) ? 1 : condition.values.sum { |v| constraint_count(v) }
82
+ when Array then condition.sum { |v| constraint_count(v) }
83
+ else 1
84
+ end
85
+ end
86
+
87
+ def wrap(condition)
88
+ condition.is_a?(Array) ? condition : [condition]
89
+ end
90
+
91
+ def matches_object?(condition, value)
92
+ condition.all? do |member, want|
93
+ next true if constraint_count(want).zero?
94
+
95
+ matches_value?(want, value.is_a?(Hash) ? value[member.to_s] : nil)
96
+ end
97
+ end
98
+
99
+ # An ABSENT member arrives here as nil, which is exactly what "not in your
100
+ # plan" looks like. Every branch below must therefore reject it, which is
101
+ # what makes an unserved member fail a match rather than pass it.
102
+ def matches_value?(want, got)
103
+ case want
104
+ when Array then want.any? { |entry| matches_value?(entry, got) }
105
+ when Hash
106
+ bound?(want) ? matches_bound?(want, got) : matches_object?(want, got)
107
+ when String
108
+ # Providers are lowercase slugs on the wire and a caller should not
109
+ # have to know that, so a string compares without case.
110
+ got.is_a?(String) && want.casecmp?(got)
111
+ when true, false then want == got
112
+ when Numeric then got.is_a?(Numeric) && !got.is_a?(TrueClass) && want == got
113
+ else want == got
114
+ end
115
+ end
116
+
117
+ def matches_bound?(bound, got)
118
+ return false unless got.is_a?(Numeric)
119
+
120
+ normalized = bound.transform_keys(&:to_s)
121
+ return false if normalized['gte'] && got < normalized['gte']
122
+ return false if normalized['gt'] && got <= normalized['gt']
123
+ return false if normalized['lte'] && got > normalized['lte']
124
+
125
+ !(normalized['lt'] && got >= normalized['lt'])
126
+ end
127
+
128
+ def bound?(value)
129
+ return false unless value.is_a?(Hash)
130
+
131
+ !value.empty? && value.keys.all? { |k| BOUND_KEYS.include?(k.to_s) }
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'condition'
4
+
5
+ module VPNDetection
6
+ # The framework-agnostic half of a web middleware: resolve a client address,
7
+ # classify it, and decide whether the condition matched.
8
+ #
9
+ # An adapter - the vpndetection-rails gem - keeps only the parts that are
10
+ # genuinely framework-shaped and shares everything here, so the shared
11
+ # conformance corpus is asserted once for Ruby rather than once per framework.
12
+ module Middleware
13
+ # What a middleware attached to the request, whether or not it succeeded.
14
+ Lookup = Struct.new(:blocked, :ip, :result, :error, keyword_init: true) do
15
+ # Whether the condition matched. Always false when none was configured.
16
+ def blocked?
17
+ blocked == true
18
+ end
19
+ end
20
+
21
+ # Enough of an incoming request for a selector to work with, whatever
22
+ # framework it came from. An adapter supplies one of these per request.
23
+ RequestView = Struct.new(:header, :framework_ip, keyword_init: true)
24
+
25
+ # Defaults set for a request path rather than for a script: failing open
26
+ # quickly beats holding a visitor while we try again.
27
+ DEFAULT_TIMEOUT = 2.5
28
+ DEFAULT_RETRIES = 0
29
+
30
+ # Resolve, classify, decide.
31
+ class Core
32
+ # @param default_ip_selector [#call] the framework's own accessor, used
33
+ # when the caller named none.
34
+ def initialize(default_ip_selector, **options)
35
+ Condition.validate!(options[:block_condition])
36
+ @condition = options[:block_condition]
37
+ @selector = options[:ip_selector] || default_ip_selector
38
+ @fail_closed = options.fetch(:fail_closed, false)
39
+ @on_missing_field = options.fetch(:on_missing_field, :warn)
40
+ @skip = options[:skip]
41
+ @on_warn = options[:on_warn]
42
+ @retries = options.fetch(:retries, DEFAULT_RETRIES)
43
+ @warned = {}
44
+ @client = options[:client] || Client.new(
45
+ api_key: options[:api_key],
46
+ **{ base_url: options[:base_url] }.compact,
47
+ timeout: options.fetch(:timeout, DEFAULT_TIMEOUT)
48
+ )
49
+ end
50
+
51
+ # Whether a condition was configured at all.
52
+ def blocking?
53
+ !@condition.nil?
54
+ end
55
+
56
+ # Classify one request. Answers nil when `skip` claimed it.
57
+ #
58
+ # A failed LOOKUP is not raised: it lands on `Lookup#error` and the
59
+ # request is let through. What CAN raise is a misconfiguration - a
60
+ # condition naming a member the plan does not serve, with
61
+ # `on_missing_field: :raise`.
62
+ def evaluate(request)
63
+ return nil if @skip&.call(request)
64
+
65
+ ip = @selector.call(request).to_s.strip
66
+ return unresolved if ip.empty?
67
+
68
+ if Bogon.bogon?(ip)
69
+ # Expected in local development. Anywhere else it means a proxy sits
70
+ # in front and its own address is what reached us.
71
+ warn_once(
72
+ "resolved the client address as #{ip}, which is not a public address. If this " \
73
+ 'application runs behind a proxy or load balancer, configure its trusted-proxy ' \
74
+ "setting or pass an ip_selector that reads your edge's header."
75
+ )
76
+ end
77
+
78
+ begin
79
+ result = @client.lookup(ip, retries: @retries)
80
+ rescue VPNDetection::Error => e
81
+ return Lookup.new(blocked: @fail_closed, ip: ip, error: e)
82
+ end
83
+ decide(ip, result)
84
+ end
85
+
86
+ private
87
+
88
+ def unresolved
89
+ warn_once(
90
+ 'could not resolve a client address from this request; pass an ip_selector that ' \
91
+ 'knows where yours comes from'
92
+ )
93
+ Lookup.new(
94
+ blocked: @fail_closed,
95
+ error: VPNDetection::Error.new(:bad_request, 'no client address on the request')
96
+ )
97
+ end
98
+
99
+ def decide(ip, result)
100
+ return Lookup.new(blocked: false, ip: ip, result: result) if @condition.nil?
101
+
102
+ report_missing(result)
103
+ Lookup.new(
104
+ blocked: Condition.matches?(@condition, result),
105
+ ip: ip,
106
+ result: result
107
+ )
108
+ end
109
+
110
+ def report_missing(result)
111
+ return if @on_missing_field == :ignore
112
+
113
+ missing = Condition.missing_members(@condition, result)
114
+ return if missing.empty?
115
+
116
+ message = "block_condition names #{missing.join(', ')}, which your plan does not " \
117
+ 'include, so those terms can never match. An absent member means "not in ' \
118
+ 'your plan", not "checked, and no".'
119
+ raise ArgumentError, "vpndetection: #{message}" if @on_missing_field == :raise
120
+
121
+ warn_once(message)
122
+ end
123
+
124
+ # A misconfiguration is the same on every request, so saying so once is a
125
+ # warning and saying so a million times is an outage of its own.
126
+ def warn_once(message)
127
+ return if @warned[message]
128
+
129
+ @warned[message] = true
130
+ @on_warn ? @on_warn.call(message) : Kernel.warn("[vpndetection] #{message}")
131
+ end
132
+ end
133
+
134
+ # The shared client-address selectors, bound to one framework's request type.
135
+ #
136
+ # There is no portable default: a framework's own accessor may return the
137
+ # socket peer, or may already have walked a proxy chain, depending on the
138
+ # framework and on how the application configured it.
139
+ class Selectors
140
+ def initialize(&view)
141
+ @view = view
142
+ end
143
+
144
+ # The framework's own client-address accessor.
145
+ def default
146
+ ->(request) { @view.call(request).framework_ip.call }
147
+ end
148
+
149
+ # An address from `X-Forwarded-For`.
150
+ #
151
+ # The LEFT-MOST entry (depth 0) is whatever the caller sent, because
152
+ # proxies append to this header, so a visitor who sets it themselves
153
+ # appears first and this returns their forgery. It is only trustworthy
154
+ # when an edge you control overwrites the header. When you know how many
155
+ # proxies sit in front, count from the right: depth 1 is the address your
156
+ # nearest proxy saw.
157
+ def xff(depth = 0)
158
+ lambda do |request|
159
+ seen = @view.call(request)
160
+ chain = (seen.header.call('X-Forwarded-For') || '').split(',').map(&:strip).reject(&:empty?)
161
+ next seen.framework_ip.call if chain.empty?
162
+ next chain.first if depth <= 0 || depth > chain.length
163
+
164
+ chain[-depth]
165
+ end
166
+ end
167
+
168
+ # An address from a single-value header your edge writes -
169
+ # `header('CF-Connecting-IP')` behind Cloudflare. Falls back to the
170
+ # framework's accessor when the header is absent.
171
+ def header(name)
172
+ lambda do |request|
173
+ seen = @view.call(request)
174
+ value = (seen.header.call(name) || '').strip
175
+ value.empty? ? seen.framework_ip.call : value
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'middleware/condition'
4
+ require_relative 'middleware/core'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VPNDetection
4
- VERSION = '3.1.0'
4
+ VERSION = '3.2.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vpndetection
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mslm Dev
@@ -60,6 +60,9 @@ files:
60
60
  - lib/vpndetection/configuration.rb
61
61
  - lib/vpndetection/database_api.rb
62
62
  - lib/vpndetection/errors.rb
63
+ - lib/vpndetection/middleware.rb
64
+ - lib/vpndetection/middleware/condition.rb
65
+ - lib/vpndetection/middleware/core.rb
63
66
  - lib/vpndetection/models/class_detail.rb
64
67
  - lib/vpndetection/models/database.rb
65
68
  - lib/vpndetection/models/database_checksums_response.rb