sitegoose 0.3.3
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/lib/sitegoose/analytics.rb +113 -0
- data/lib/sitegoose/challenge.rb +177 -0
- data/lib/sitegoose/core.rb +523 -0
- data/lib/sitegoose/crawlers.rb +195 -0
- data/lib/sitegoose/errors.rb +456 -0
- data/lib/sitegoose/firewall.rb +417 -0
- data/lib/sitegoose/inventory.rb +131 -0
- data/lib/sitegoose/ip.rb +159 -0
- data/lib/sitegoose/middleware.rb +52 -0
- data/lib/sitegoose/performance.rb +114 -0
- data/lib/sitegoose/platform.rb +557 -0
- data/lib/sitegoose/posture.rb +272 -0
- data/lib/sitegoose/proxy.rb +260 -0
- data/lib/sitegoose/queries.rb +399 -0
- data/lib/sitegoose/query_text.rb +215 -0
- data/lib/sitegoose/railtie.rb +124 -0
- data/lib/sitegoose/request_rules.rb +390 -0
- data/lib/sitegoose/resources.rb +449 -0
- data/lib/sitegoose/sidekiq.rb +95 -0
- data/lib/sitegoose/small_modules.rb +250 -0
- data/lib/sitegoose/version.rb +5 -0
- data/lib/sitegoose.rb +261 -0
- metadata +92 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module SiteGoose
|
|
6
|
+
# Rails: the middleware first in the stack, the options from (lowest
|
|
7
|
+
# first) the environment, credentials (sitegoose:), the Rails
|
|
8
|
+
# config (config.sitegoose) and SiteGoose.configure in an
|
|
9
|
+
# initializer. Then, on their own: Rails.error reports (Rails.error.handle,
|
|
10
|
+
# .record, .report: the errors your code handles), Rails.logger with
|
|
11
|
+
# errors: {capture_logging: true}, Devise's failed logins (bot protection)
|
|
12
|
+
# and its logins and logouts (audit log), and the controller, mailer and job
|
|
13
|
+
# steps before an error (its breadcrumbs).
|
|
14
|
+
class Railtie < ::Rails::Railtie
|
|
15
|
+
config.sitegoose = ActiveSupport::OrderedOptions.new
|
|
16
|
+
|
|
17
|
+
initializer "sitegoose.middleware" do |app|
|
|
18
|
+
app.config.middleware.insert_before 0, SiteGoose::Middleware
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config.after_initialize do |app|
|
|
22
|
+
SiteGoose::RailsSetup.run(app)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module RailsSetup
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def run(app)
|
|
30
|
+
credentials = begin
|
|
31
|
+
given = app.credentials.sitegoose
|
|
32
|
+
given.respond_to?(:to_h) ? given.to_h : {}
|
|
33
|
+
rescue StandardError
|
|
34
|
+
{}
|
|
35
|
+
end
|
|
36
|
+
from_config = app.config.sitegoose.respond_to?(:to_h) ? app.config.sitegoose.to_h : {}
|
|
37
|
+
SiteGoose.defaults(**SiteGoose.symbolize(credentials).merge(SiteGoose.symbolize(from_config)))
|
|
38
|
+
subscribe_errors
|
|
39
|
+
SiteGoose.when_made { |made| hook_logger(made) }
|
|
40
|
+
subscribe_steps
|
|
41
|
+
hook_warden
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def subscribe_errors
|
|
45
|
+
return unless ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
|
|
46
|
+
|
|
47
|
+
::Rails.error.subscribe(ErrorSubscriber.new)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def hook_logger(made)
|
|
51
|
+
return unless made.errors&.capture_logging && ::Rails.logger
|
|
52
|
+
|
|
53
|
+
made.capture_logger(::Rails.logger)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Public notifications only: which controller action ran, which mail
|
|
57
|
+
# went out, which job was queued. Never their contents.
|
|
58
|
+
def subscribe_steps
|
|
59
|
+
return unless defined?(::ActiveSupport::Notifications)
|
|
60
|
+
|
|
61
|
+
::ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*, payload|
|
|
62
|
+
Context.add_breadcrumb("#{payload[:controller]}##{payload[:action]}") if payload.is_a?(Hash)
|
|
63
|
+
end
|
|
64
|
+
::ActiveSupport::Notifications.subscribe("deliver.action_mailer") do |*, payload|
|
|
65
|
+
Context.add_breadcrumb("Mail sent: #{payload[:mailer]}") if payload.is_a?(Hash) && payload[:mailer]
|
|
66
|
+
end
|
|
67
|
+
::ActiveSupport::Notifications.subscribe("enqueue.active_job") do |*, payload|
|
|
68
|
+
job = payload.is_a?(Hash) ? payload[:job] : nil
|
|
69
|
+
Context.add_breadcrumb("Job queued: #{job.class.name}") if job
|
|
70
|
+
end
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Devise (Warden): a failed sign-in counts for bot protection, as
|
|
76
|
+
# Django's do in the Python package; sign-ins and sign-outs go in the
|
|
77
|
+
# audit log.
|
|
78
|
+
def hook_warden
|
|
79
|
+
return unless defined?(::Warden::Manager)
|
|
80
|
+
|
|
81
|
+
::Warden::Manager.before_failure do |env, opts|
|
|
82
|
+
if %i[invalid not_found_in_database].include?(opts.is_a?(Hash) ? opts[:message] : nil)
|
|
83
|
+
SiteGoose.platform&.login_failed(env)
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
::Warden::Manager.after_authentication do |user, auth, _opts|
|
|
89
|
+
SiteGoose.platform&.audit("user.login", actor: RailsSetup.actor(user), request: auth.env)
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
::Warden::Manager.before_logout do |user, auth, _opts|
|
|
94
|
+
SiteGoose.platform&.audit("user.logout", actor: RailsSetup.actor(user), request: auth.env) if user
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def actor(user)
|
|
101
|
+
%i[email username login name].each do |field|
|
|
102
|
+
value = user.respond_to?(field) ? user.public_send(field) : nil
|
|
103
|
+
return value.to_s unless value.nil? || value.to_s.empty?
|
|
104
|
+
end
|
|
105
|
+
user.respond_to?(:id) ? user.id.to_s : ""
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Rails.error's subscriber: what your code handles (Rails.error.handle,
|
|
110
|
+
# .record, .report) is sent. An unhandled error Rails reports from inside a
|
|
111
|
+
# request or job is left to the middleware or the job integration, which
|
|
112
|
+
# know its status and whether it will be retried.
|
|
113
|
+
class ErrorSubscriber
|
|
114
|
+
def report(error, handled: true, severity: :error, context: {}, source: nil, **_rest)
|
|
115
|
+
return if severity.to_s == "info" || !error.is_a?(Exception)
|
|
116
|
+
return if error.instance_variable_defined?(:@__sitegoose_skip)
|
|
117
|
+
return if !handled && Context.request
|
|
118
|
+
|
|
119
|
+
SiteGoose.platform&.capture_exception(error, kind: handled ? "handled" : "error")
|
|
120
|
+
rescue StandardError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Request rules (virtual patching; protocol in sdk/protocols/firewall.md):
|
|
4
|
+
# the firewall has always matched addresses, these match the request -- its
|
|
5
|
+
# method, path, query string and user agent -- so an attack is turned away
|
|
6
|
+
# before the vulnerable code runs. They arrive with the other rules and are
|
|
7
|
+
# compiled once per fetch; matching is plain string work on text we already
|
|
8
|
+
# have, so it costs nothing per request.
|
|
9
|
+
#
|
|
10
|
+
# The matching is the same in every package: the shared cases are in
|
|
11
|
+
# sdk/protocols/request-rules-cases.json, run by test/request_rules_test.rb.
|
|
12
|
+
#
|
|
13
|
+
# Ruby's ^ and $ are line anchors, unlike JavaScript's and Python's, so a
|
|
14
|
+
# pattern's anchors are rewritten to \A and \z before it is compiled: a
|
|
15
|
+
# decoded %0a in a path must not end the "line" a rule anchors to.
|
|
16
|
+
|
|
17
|
+
module SiteGoose
|
|
18
|
+
module RequestRules
|
|
19
|
+
MAX_RULES = 200
|
|
20
|
+
# How much of each part a rule may look at. Patterns come from our rules,
|
|
21
|
+
# but the text comes from whoever is asking: a bounded slice keeps a bad
|
|
22
|
+
# pattern from being a hung request (and Ruby 3.2+ also times it out).
|
|
23
|
+
MAX_TEXT = 2000
|
|
24
|
+
MAX_REGEX = 200
|
|
25
|
+
SECTIONS = %w[path query ua].freeze
|
|
26
|
+
MATCHERS = %w[exact prefix suffix contains].freeze
|
|
27
|
+
REGEX_TIMEOUT = 0.05
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# A pattern can only come from us, but a careless one would be matched
|
|
32
|
+
# against text an attacker chooses. So the client checks the shape itself
|
|
33
|
+
# instead of trusting what it was handed: a repeated group that holds a
|
|
34
|
+
# quantifier or an alternation ("(a+)+", "(a|ab)*"), backreferences and
|
|
35
|
+
# lookbehind are refused, and so is more than one free repeat. The same
|
|
36
|
+
# check as backend/services/firewall/requestRules.js.
|
|
37
|
+
#
|
|
38
|
+
# Group syntax is a list of what is allowed, not of what isn't: only
|
|
39
|
+
# "(?:", "(?=" and "(?!". Ruby (Onigmo) has more than JavaScript and
|
|
40
|
+
# Python -- subexpression calls (\g<1>, \g'x'), named groups and
|
|
41
|
+
# references written with quotes ((?'x'...), \k'x'), absence operators
|
|
42
|
+
# ((?~...)), conditionals ((?(1)...)), inline options -- and a call
|
|
43
|
+
# repeated against an alternation took 51 seconds in a test.
|
|
44
|
+
GROUPS = [":", "=", "!"].freeze
|
|
45
|
+
# Escapes that refer to a group: \1-\9, \g (call), \k (named reference);
|
|
46
|
+
# and \N, which isn't in the shared subset either.
|
|
47
|
+
REFERENCE = /\\(?:[1-9]|[gkN])/.freeze
|
|
48
|
+
|
|
49
|
+
def safe_pattern?(pattern)
|
|
50
|
+
return false unless pattern.is_a?(String) && !pattern.empty? && pattern.size <= MAX_REGEX
|
|
51
|
+
|
|
52
|
+
open = []
|
|
53
|
+
i = 0
|
|
54
|
+
n = pattern.size
|
|
55
|
+
while i < n
|
|
56
|
+
char = pattern[i]
|
|
57
|
+
if char == "\\"
|
|
58
|
+
return false if pattern[i, 2].match?(REFERENCE)
|
|
59
|
+
|
|
60
|
+
i += 2
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
if char == "["
|
|
64
|
+
# A character class: skipped whole, quantifiers inside it are literals.
|
|
65
|
+
i += pattern[i] == "\\" ? 2 : 1 while i < n && pattern[i] != "]"
|
|
66
|
+
i += 1
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
if char == "("
|
|
70
|
+
open << false
|
|
71
|
+
# "(?:", "(?=", "(?!": that ? names the kind of group, it repeats nothing.
|
|
72
|
+
# "(*": a PCRE verb ("(*FAIL)"), never in the shared subset.
|
|
73
|
+
return false if pattern[i + 1] == "*"
|
|
74
|
+
|
|
75
|
+
if pattern[i + 1] == "?"
|
|
76
|
+
return false unless GROUPS.include?(pattern[i + 2])
|
|
77
|
+
|
|
78
|
+
i += 2
|
|
79
|
+
end
|
|
80
|
+
elsif "*+?".include?(char) && pattern[i + 1] == "+" && i.positive? && pattern[i - 1] != "("
|
|
81
|
+
return false # possessive: a++, a*+, a?+
|
|
82
|
+
elsif char == "}" && pattern[i + 1] == "+" && pattern[0, i].match?(/\{\d*(,\d*)?\z/)
|
|
83
|
+
return false # possessive: a{2,}+
|
|
84
|
+
elsif char == ")"
|
|
85
|
+
return false if open.empty?
|
|
86
|
+
|
|
87
|
+
ambiguous = open.pop
|
|
88
|
+
following = pattern[i + 1]
|
|
89
|
+
return false if ambiguous && ["*", "+", "{"].include?(following)
|
|
90
|
+
|
|
91
|
+
open[-1] = true if ambiguous && !open.empty?
|
|
92
|
+
elsif !open.empty? && "|*+?{".include?(char)
|
|
93
|
+
open[-1] = true
|
|
94
|
+
end
|
|
95
|
+
i += 1
|
|
96
|
+
end
|
|
97
|
+
open.empty? && free_repeats(pattern) <= 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def literal_at(pattern, i)
|
|
101
|
+
return nil if i >= pattern.size
|
|
102
|
+
|
|
103
|
+
char = pattern[i]
|
|
104
|
+
if char == "\\"
|
|
105
|
+
following = pattern[i + 1]
|
|
106
|
+
return following && !following.match?(/[a-z0-9]/i) ? following : nil
|
|
107
|
+
end
|
|
108
|
+
".[](){}*+?|^$".include?(char) ? nil : char
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Two repeats that can both take the same text multiply each other's work
|
|
112
|
+
# (".*=.*=.*x"). A repeat is pinned when the character straight after it is
|
|
113
|
+
# one it can't match ("[^/]+/"); any other is free, and a pattern may hold
|
|
114
|
+
# one free repeat.
|
|
115
|
+
def free_repeats(pattern)
|
|
116
|
+
free = 0
|
|
117
|
+
i = 0
|
|
118
|
+
n = pattern.size
|
|
119
|
+
while i < n
|
|
120
|
+
start = i
|
|
121
|
+
char = pattern[i]
|
|
122
|
+
if char == "\\"
|
|
123
|
+
i += 2
|
|
124
|
+
elsif char == "["
|
|
125
|
+
i += 1
|
|
126
|
+
i += pattern[i] == "\\" ? 2 : 1 while i < n && pattern[i] != "]"
|
|
127
|
+
i += 1
|
|
128
|
+
elsif char == "("
|
|
129
|
+
i += pattern[i + 1] == "?" ? 3 : 1
|
|
130
|
+
next
|
|
131
|
+
elsif "|^$".include?(char)
|
|
132
|
+
i += 1
|
|
133
|
+
next
|
|
134
|
+
else
|
|
135
|
+
i += 1
|
|
136
|
+
end
|
|
137
|
+
# A group's text can't be tried against one character.
|
|
138
|
+
atom = char == ")" ? nil : pattern[start...i]
|
|
139
|
+
quantifier = pattern[i]
|
|
140
|
+
repeats = ["*", "+"].include?(quantifier)
|
|
141
|
+
if quantifier == "{"
|
|
142
|
+
bounds = /\A\{(\d*)(,(\d*))?\}/.match(pattern[i..])
|
|
143
|
+
next unless bounds
|
|
144
|
+
|
|
145
|
+
# "{3}" is one length, not a choice; "{2,}" and "{2,9}" are choices.
|
|
146
|
+
repeats = !bounds[2].nil? && bounds[3] != bounds[1]
|
|
147
|
+
i += bounds[0].size - 1
|
|
148
|
+
elsif quantifier != "?" && !repeats
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
i += 1
|
|
152
|
+
i += 1 if pattern[i] == "?"
|
|
153
|
+
next unless repeats
|
|
154
|
+
|
|
155
|
+
following = literal_at(pattern, i)
|
|
156
|
+
pinned = false
|
|
157
|
+
if atom && following
|
|
158
|
+
begin
|
|
159
|
+
pinned = !Regexp.new("\\A(?:#{atom})\\z", Regexp::IGNORECASE).match?(following)
|
|
160
|
+
rescue RegexpError
|
|
161
|
+
# Not an atom on its own: free.
|
|
162
|
+
pinned = false
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
free += 1 unless pinned
|
|
166
|
+
end
|
|
167
|
+
free
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ^ and $ outside character classes as \A and \z: what they mean in
|
|
171
|
+
# JavaScript and Python (without a multiline flag).
|
|
172
|
+
def ruby_anchors(pattern)
|
|
173
|
+
out = +""
|
|
174
|
+
i = 0
|
|
175
|
+
in_class = false
|
|
176
|
+
while i < pattern.size
|
|
177
|
+
char = pattern[i]
|
|
178
|
+
if char == "\\"
|
|
179
|
+
out << pattern[i, 2]
|
|
180
|
+
i += 2
|
|
181
|
+
next
|
|
182
|
+
end
|
|
183
|
+
if in_class
|
|
184
|
+
in_class = false if char == "]"
|
|
185
|
+
out << char
|
|
186
|
+
elsif char == "["
|
|
187
|
+
in_class = true
|
|
188
|
+
out << char
|
|
189
|
+
# A "]" straight after "[" or "[^" is a literal.
|
|
190
|
+
if pattern[i + 1] == "^"
|
|
191
|
+
out << "^"
|
|
192
|
+
i += 1
|
|
193
|
+
end
|
|
194
|
+
if pattern[i + 1] == "]"
|
|
195
|
+
out << "]"
|
|
196
|
+
i += 1
|
|
197
|
+
end
|
|
198
|
+
elsif char == "^"
|
|
199
|
+
out << "\\A"
|
|
200
|
+
elsif char == "$"
|
|
201
|
+
out << "\\z"
|
|
202
|
+
else
|
|
203
|
+
out << char
|
|
204
|
+
end
|
|
205
|
+
i += 1
|
|
206
|
+
end
|
|
207
|
+
out
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Ruby before 3.2 can't stop a match that runs long, so there a rule's
|
|
211
|
+
# regex is left out (its other parts still match): failing open, said
|
|
212
|
+
# once. Our shape check refuses what we know to be slow, but a timeout
|
|
213
|
+
# is what makes that safe to rely on.
|
|
214
|
+
@regex_allowed = Regexp.respond_to?(:timeout)
|
|
215
|
+
@warned = false
|
|
216
|
+
|
|
217
|
+
class << self
|
|
218
|
+
attr_accessor :regex_allowed
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def regex_skipped
|
|
222
|
+
return if @warned
|
|
223
|
+
|
|
224
|
+
@warned = true
|
|
225
|
+
warn "[analytics-platform] request rules: Ruby #{RUBY_VERSION} can't time out a regex, so patterns are skipped (their other parts still match); Ruby 3.2+ matches them"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def compile_regex(pattern)
|
|
229
|
+
source = ruby_anchors(pattern)
|
|
230
|
+
# Ruby before 3.2 has no per-pattern timeout (and reads the keyword as
|
|
231
|
+
# a third positional argument), so ask which Ruby this is.
|
|
232
|
+
if Regexp.respond_to?(:timeout)
|
|
233
|
+
Regexp.new(source, Regexp::IGNORECASE, timeout: REGEX_TIMEOUT)
|
|
234
|
+
else
|
|
235
|
+
Regexp.new(source, Regexp::IGNORECASE)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def section(data)
|
|
240
|
+
return nil unless data.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
out = {}
|
|
243
|
+
MATCHERS.each do |matcher|
|
|
244
|
+
entries = data[matcher]
|
|
245
|
+
next unless entries.is_a?(Array)
|
|
246
|
+
|
|
247
|
+
values = entries.select { |e| e.is_a?(String) && (matcher == "exact" || !e.empty?) }.map(&:downcase)
|
|
248
|
+
out[matcher] = values unless values.empty?
|
|
249
|
+
end
|
|
250
|
+
pattern = data["regex"]
|
|
251
|
+
if pattern && !RequestRules.regex_allowed
|
|
252
|
+
regex_skipped
|
|
253
|
+
elsif safe_pattern?(pattern)
|
|
254
|
+
begin
|
|
255
|
+
out["regex"] = compile_regex(pattern)
|
|
256
|
+
rescue RegexpError
|
|
257
|
+
# A pattern we can't compile is dropped; the rule's other parts stay.
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
out.empty? ? nil : out
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The rules as they will be matched; [] when there are none.
|
|
265
|
+
def compile(data)
|
|
266
|
+
given = data.is_a?(Hash) ? data["rules"] : nil
|
|
267
|
+
rules = []
|
|
268
|
+
(given.is_a?(Array) ? given : []).first(MAX_RULES).each do |rule|
|
|
269
|
+
next unless rule.is_a?(Hash) && rule["id"].is_a?(String) && !rule["id"].empty?
|
|
270
|
+
# We are called before the body is read, so a rule that needs the body
|
|
271
|
+
# is skipped whole rather than matched on the rest of it.
|
|
272
|
+
next if rule.key?("body")
|
|
273
|
+
|
|
274
|
+
methods = rule["methods"]
|
|
275
|
+
ban = rule["ban"]
|
|
276
|
+
compiled = {
|
|
277
|
+
"id" => rule["id"],
|
|
278
|
+
"action" => rule["action"] == "block" ? "block" : "monitor",
|
|
279
|
+
"ban" => ban.is_a?(Numeric) && ban.to_i.positive? ? ban.to_i : 0,
|
|
280
|
+
"methods" => methods.is_a?(Array) ? methods.select { |m| m.is_a?(String) }.map(&:upcase) : []
|
|
281
|
+
}
|
|
282
|
+
count = 0
|
|
283
|
+
SECTIONS.each do |name|
|
|
284
|
+
part = section(rule[name])
|
|
285
|
+
next unless part
|
|
286
|
+
|
|
287
|
+
compiled[name] = part
|
|
288
|
+
count += 1
|
|
289
|
+
end
|
|
290
|
+
# A rule with nothing to match would match everything.
|
|
291
|
+
next if count.zero?
|
|
292
|
+
|
|
293
|
+
rules << compiled
|
|
294
|
+
end
|
|
295
|
+
rules
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# One pass only, as the protocol says: enough for %2e%2e, and no risk of
|
|
299
|
+
# decoding a visitor's own text into something that looks like an attack.
|
|
300
|
+
# An escape that isn't one stays as it is, and bytes that aren't UTF-8
|
|
301
|
+
# become U+FFFD, as Python's unquote_plus does.
|
|
302
|
+
def decode_once(text)
|
|
303
|
+
return text unless text.include?("%") || text.include?("+")
|
|
304
|
+
|
|
305
|
+
decoded = text.b.tr("+", " ").gsub(/%[0-9a-fA-F]{2}/n) { |m| m[1, 2].to_i(16).chr }
|
|
306
|
+
decoded.force_encoding(Encoding::UTF_8).scrub("�")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def normalize_path(path)
|
|
310
|
+
path.downcase.tr("\\", "/").gsub(%r{/{2,}}, "/")
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The path as a router reads it: ";params" dropped from each segment
|
|
314
|
+
# (Tomcat, Jetty and some proxies do; "/x;y/../admin" is "/admin" to
|
|
315
|
+
# them), decoded once, "\\" as "/", "//" as "/", "." and ".." resolved
|
|
316
|
+
# (never above the root), lower-cased. The same as every package's.
|
|
317
|
+
def router_path(path)
|
|
318
|
+
text = normalize_path(decode_once(path.to_s.gsub(%r{;[^/]*}, "")))
|
|
319
|
+
parts = []
|
|
320
|
+
segments = text.split("/", -1)
|
|
321
|
+
segments.each_with_index do |segment, index|
|
|
322
|
+
if segment == ".."
|
|
323
|
+
parts.pop if parts.size > 1
|
|
324
|
+
elsif segment != "." && (!segment.empty? || index.zero?)
|
|
325
|
+
parts << segment
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
resolved = parts.join("/")
|
|
329
|
+
resolved += "/" if ["", ".", ".."].include?(segments[-1]) && segments.size > 1 && !resolved.end_with?("/")
|
|
330
|
+
resolved = "/#{resolved}" if text.start_with?("/") && !resolved.start_with?("/")
|
|
331
|
+
resolved
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# A request target's path (no query string) as the router reads it:
|
|
335
|
+
# what the probes, the static-file check and the rules' path match too.
|
|
336
|
+
def normalize_target(target)
|
|
337
|
+
text = SiteGoose.text(target).split(/[?#]/, 2)[0].to_s
|
|
338
|
+
router_path(text[0, MAX_TEXT])[0, MAX_TEXT]
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def regex_hit?(regex, text)
|
|
342
|
+
regex.match?(text)
|
|
343
|
+
rescue Regexp::TimeoutError
|
|
344
|
+
false
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def hits?(part, raw, decoded, routed = nil)
|
|
348
|
+
texts = [raw, decoded, routed].compact.uniq
|
|
349
|
+
texts.any? do |text|
|
|
350
|
+
next true if part["exact"]&.include?(text)
|
|
351
|
+
next true if part["prefix"]&.any? { |entry| text.start_with?(entry) }
|
|
352
|
+
next true if part["suffix"]&.any? { |entry| !entry.empty? && text.end_with?(entry) }
|
|
353
|
+
next true if part["contains"]&.any? { |entry| !entry.empty? && text.include?(entry) }
|
|
354
|
+
|
|
355
|
+
part["regex"] ? regex_hit?(part["regex"], text) : false
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# The first rule that matches, or nil. target is the request target as
|
|
360
|
+
# the server received it, query string and all.
|
|
361
|
+
def match(rules, method = "", target = "", user_agent = "")
|
|
362
|
+
return nil if rules.nil? || rules.empty?
|
|
363
|
+
|
|
364
|
+
target = SiteGoose.text(target)
|
|
365
|
+
mark = target.index("?")
|
|
366
|
+
path_part = mark ? target[0, mark] : target
|
|
367
|
+
query = mark ? target[(mark + 1)..] : ""
|
|
368
|
+
raw_path = normalize_path(path_part[0, MAX_TEXT])
|
|
369
|
+
query = query[0, MAX_TEXT]
|
|
370
|
+
raw_query = query.downcase
|
|
371
|
+
ua = SiteGoose.text(user_agent)[0, MAX_TEXT].downcase
|
|
372
|
+
decoded_path = normalize_path(decode_once(path_part[0, MAX_TEXT])[0, MAX_TEXT])
|
|
373
|
+
# And as the router reads it: "/x;y/../admin" is "/admin" there, so a
|
|
374
|
+
# rule on "/admin" catches it; a hit on any of the three counts.
|
|
375
|
+
routed_path = router_path(path_part[0, MAX_TEXT])[0, MAX_TEXT]
|
|
376
|
+
# Lower-cased after decoding: %55NION decodes to "UNION", which a
|
|
377
|
+
# lower-cased "union select" rule would otherwise never see.
|
|
378
|
+
decoded_query = decode_once(query)[0, MAX_TEXT].downcase
|
|
379
|
+
upper = method.to_s.upcase
|
|
380
|
+
rules.find do |rule|
|
|
381
|
+
next false if !rule["methods"].empty? && !rule["methods"].include?(upper)
|
|
382
|
+
next false if rule["path"] && !hits?(rule["path"], raw_path, decoded_path, routed_path)
|
|
383
|
+
next false if rule["query"] && !hits?(rule["query"], raw_query, decoded_query)
|
|
384
|
+
next false if rule["ua"] && !hits?(rule["ua"], ua, ua)
|
|
385
|
+
|
|
386
|
+
true
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|