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 +4 -4
- data/lib/vpndetection/middleware/condition.rb +135 -0
- data/lib/vpndetection/middleware/core.rb +180 -0
- data/lib/vpndetection/middleware.rb +4 -0
- data/lib/vpndetection/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e3489ebf7b31c0307756abb483614adb213f1b9feb37890ed1e5b1f163f35c02
|
|
4
|
+
data.tar.gz: b0f1881979f90e0a0378bf399bf1442005c30d1fe775f1c332a71418470799b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/vpndetection/version.rb
CHANGED
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.
|
|
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
|