rack-libinjection 0.1.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 +7 -0
- data/.github/workflows/ci.yml +55 -0
- data/CHANGELOG.md +112 -0
- data/GET_STARTED.md +418 -0
- data/LICENSE-libinjection.txt +33 -0
- data/LICENSE.txt +21 -0
- data/README.md +68 -0
- data/SECURITY.md +65 -0
- data/ext/libinjection/extconf.rb +113 -0
- data/ext/libinjection/libinjection_ext.c +1132 -0
- data/ext/libinjection/vendor/libinjection/.vendored +5 -0
- data/ext/libinjection/vendor/libinjection/COPYING +33 -0
- data/ext/libinjection/vendor/libinjection/MIGRATION.md +393 -0
- data/ext/libinjection/vendor/libinjection/README.md +251 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection.h +70 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_error.h +26 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_html5.c +830 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_html5.h +56 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.c +2342 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.h +297 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli_data.h +9651 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_xss.c +1203 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_xss.h +23 -0
- data/lib/libinjection/version.rb +6 -0
- data/lib/libinjection.rb +31 -0
- data/lib/rack/libinjection.rb +586 -0
- data/lib/rack-libinjection.rb +3 -0
- data/samples/README.md +67 -0
- data/samples/libinjection_detect_raw_hot_path.rb +161 -0
- data/samples/rack_all_surfaces_hot_path.rb +198 -0
- data/samples/rack_params_hot_path.rb +166 -0
- data/samples/rack_query_hot_path.rb +176 -0
- data/samples/results/.gitkeep +0 -0
- data/script/fuzz_smoke.rb +39 -0
- data/script/vendor_libs.rb +227 -0
- data/test/test_helper.rb +7 -0
- data/test/test_libinjection.rb +223 -0
- data/test/test_middleware.rb +404 -0
- metadata +148 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#ifndef LIBINJECTION_XSS
|
|
2
|
+
#define LIBINJECTION_XSS
|
|
3
|
+
|
|
4
|
+
#ifdef __cplusplus
|
|
5
|
+
extern "C" {
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HEY THIS ISN'T DONE
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/* pull in size_t */
|
|
13
|
+
|
|
14
|
+
#include "libinjection.h"
|
|
15
|
+
#include "libinjection_html5.h"
|
|
16
|
+
#include <string.h>
|
|
17
|
+
|
|
18
|
+
injection_result_t libinjection_is_xss(const char *s, size_t len, int flags);
|
|
19
|
+
|
|
20
|
+
#ifdef __cplusplus
|
|
21
|
+
}
|
|
22
|
+
#endif
|
|
23
|
+
#endif
|
data/lib/libinjection.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "libinjection/version"
|
|
4
|
+
|
|
5
|
+
module LibInjection
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class ParserError < Error; end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
require_relative "libinjection/libinjection_native"
|
|
12
|
+
rescue LoadError
|
|
13
|
+
require "libinjection/libinjection_native"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module LibInjection
|
|
17
|
+
Result = Data.define(:type, :detected, :fingerprint) do
|
|
18
|
+
def detected? = !!detected
|
|
19
|
+
def sqli? = type == :sqli && detected?
|
|
20
|
+
def xss? = type == :xss && detected?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def detect(input)
|
|
26
|
+
raw = detect_raw(input)
|
|
27
|
+
return Result.new(type: nil, detected: false, fingerprint: nil) if raw.nil?
|
|
28
|
+
|
|
29
|
+
Result.new(type: raw[0], detected: true, fingerprint: raw[1])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/request"
|
|
4
|
+
require "libinjection"
|
|
5
|
+
|
|
6
|
+
module Rack
|
|
7
|
+
class LibInjection
|
|
8
|
+
DEFAULT_SCAN = %i[params].freeze
|
|
9
|
+
DEFAULT_THREATS = %i[sqli xss].freeze
|
|
10
|
+
DEFAULT_IGNORE_PARAMS = %w[authenticity_token].freeze
|
|
11
|
+
DEFAULT_IGNORE_HEADERS = %w[
|
|
12
|
+
accept
|
|
13
|
+
accept-encoding
|
|
14
|
+
accept-language
|
|
15
|
+
cache-control
|
|
16
|
+
connection
|
|
17
|
+
content-length
|
|
18
|
+
content-type
|
|
19
|
+
host
|
|
20
|
+
pragma
|
|
21
|
+
sec-ch-ua
|
|
22
|
+
sec-ch-ua-mobile
|
|
23
|
+
sec-ch-ua-platform
|
|
24
|
+
sec-fetch-dest
|
|
25
|
+
sec-fetch-mode
|
|
26
|
+
sec-fetch-site
|
|
27
|
+
upgrade-insecure-requests
|
|
28
|
+
].freeze
|
|
29
|
+
DEFAULT_MAX_VALUE_BYTES = 8 * 1024
|
|
30
|
+
DEFAULT_MAX_DEPTH = 8
|
|
31
|
+
DEFAULT_PATH_DECODE_DEPTH = 2
|
|
32
|
+
|
|
33
|
+
ATTACK_ENV_KEY = "rack.libinjection.attacks"
|
|
34
|
+
EVENT_NAME = "rack.libinjection.attack"
|
|
35
|
+
ERROR_EVENT = "rack.libinjection.error"
|
|
36
|
+
SKIPPED_EVENT = "rack.libinjection.skipped"
|
|
37
|
+
|
|
38
|
+
VALID_MODES = %i[report block off].freeze
|
|
39
|
+
VALID_SCAN = %i[query params path headers cookies].freeze
|
|
40
|
+
VALID_THREATS = %i[sqli xss].freeze
|
|
41
|
+
VALID_PARSER_ERRORS = %i[auto report block raise].freeze
|
|
42
|
+
VALID_NOTIFIER_ERRORS = %i[ignore raise].freeze
|
|
43
|
+
VALID_SKIPPED_INPUTS = %i[auto report block allow].freeze
|
|
44
|
+
MAX_PATH_DECODE_DEPTH = 32
|
|
45
|
+
|
|
46
|
+
PARAMETER_ERRORS = [
|
|
47
|
+
defined?(::Rack::QueryParser::ParameterTypeError) && ::Rack::QueryParser::ParameterTypeError,
|
|
48
|
+
defined?(::Rack::QueryParser::InvalidParameterError) && ::Rack::QueryParser::InvalidParameterError,
|
|
49
|
+
defined?(::Rack::QueryParser::ParamsTooDeepError) && ::Rack::QueryParser::ParamsTooDeepError,
|
|
50
|
+
defined?(::Rack::Utils::ParameterTypeError) && ::Rack::Utils::ParameterTypeError,
|
|
51
|
+
defined?(::Rack::Utils::InvalidParameterError) && ::Rack::Utils::InvalidParameterError,
|
|
52
|
+
defined?(::Rack::Utils::ParamsTooDeepError) && ::Rack::Utils::ParamsTooDeepError
|
|
53
|
+
].select { |value| value.is_a?(Class) }.uniq.freeze
|
|
54
|
+
|
|
55
|
+
FORBIDDEN_HEADERS = { "content-type" => "text/plain; charset=utf-8" }.freeze
|
|
56
|
+
FORBIDDEN_BODY = ["Forbidden\n"].freeze
|
|
57
|
+
|
|
58
|
+
NOOP_NOTIFIER = ->(_event, _payload) {}
|
|
59
|
+
ParserBlocked = Class.new(StandardError)
|
|
60
|
+
private_constant :ParserBlocked
|
|
61
|
+
|
|
62
|
+
Attack = Data.define(:type, :location, :key, :key_name, :fingerprint, :bytes) do
|
|
63
|
+
def sqli? = type == :sqli
|
|
64
|
+
def xss? = type == :xss
|
|
65
|
+
def detected_in_key_name? = !!key_name
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Config = Data.define(
|
|
69
|
+
:mode,
|
|
70
|
+
:scan,
|
|
71
|
+
:threats,
|
|
72
|
+
:scan_sqli,
|
|
73
|
+
:scan_xss,
|
|
74
|
+
:detect_mask,
|
|
75
|
+
:ignore_params,
|
|
76
|
+
:ignore_params_lookup,
|
|
77
|
+
:ignore_headers,
|
|
78
|
+
:ignore_headers_lookup,
|
|
79
|
+
:scan_cookie_names,
|
|
80
|
+
:max_value_bytes,
|
|
81
|
+
:path_decode_depth,
|
|
82
|
+
:max_depth,
|
|
83
|
+
:parser_errors,
|
|
84
|
+
:notifier,
|
|
85
|
+
:notifier_errors,
|
|
86
|
+
:notify_skipped,
|
|
87
|
+
:skipped_inputs
|
|
88
|
+
) do
|
|
89
|
+
def self.build(
|
|
90
|
+
mode: :report,
|
|
91
|
+
scan: DEFAULT_SCAN,
|
|
92
|
+
threats: DEFAULT_THREATS,
|
|
93
|
+
ignore_params: DEFAULT_IGNORE_PARAMS,
|
|
94
|
+
ignore_headers: DEFAULT_IGNORE_HEADERS,
|
|
95
|
+
scan_cookie_names: false,
|
|
96
|
+
max_value_bytes: DEFAULT_MAX_VALUE_BYTES,
|
|
97
|
+
max_depth: DEFAULT_MAX_DEPTH,
|
|
98
|
+
path_decode_depth: DEFAULT_PATH_DECODE_DEPTH,
|
|
99
|
+
parser_errors: :auto,
|
|
100
|
+
notifier: nil,
|
|
101
|
+
logger: nil,
|
|
102
|
+
notifier_errors: :ignore,
|
|
103
|
+
notify_skipped: true,
|
|
104
|
+
skipped_inputs: :auto
|
|
105
|
+
)
|
|
106
|
+
raise ArgumentError, "pass either notifier: or logger:, not both" if notifier && logger
|
|
107
|
+
|
|
108
|
+
mode = validate_mode(mode)
|
|
109
|
+
scan = validate_scan(scan)
|
|
110
|
+
threats = validate_threats(threats)
|
|
111
|
+
ignored = normalize_param_names(ignore_params)
|
|
112
|
+
ignored_headers = normalize_header_names(ignore_headers)
|
|
113
|
+
|
|
114
|
+
new(
|
|
115
|
+
mode: mode,
|
|
116
|
+
scan: scan,
|
|
117
|
+
threats: threats,
|
|
118
|
+
scan_sqli: threats.include?(:sqli),
|
|
119
|
+
scan_xss: threats.include?(:xss),
|
|
120
|
+
detect_mask: detect_mask_for(threats),
|
|
121
|
+
ignore_params: ignored,
|
|
122
|
+
ignore_params_lookup: lookup_for(ignored),
|
|
123
|
+
ignore_headers: ignored_headers,
|
|
124
|
+
ignore_headers_lookup: lookup_for(ignored_headers),
|
|
125
|
+
scan_cookie_names: !!scan_cookie_names,
|
|
126
|
+
max_value_bytes: positive_integer!(max_value_bytes, :max_value_bytes),
|
|
127
|
+
path_decode_depth: bounded_non_negative_integer!(path_decode_depth, :path_decode_depth, MAX_PATH_DECODE_DEPTH),
|
|
128
|
+
max_depth: non_negative_integer!(max_depth, :max_depth),
|
|
129
|
+
parser_errors: validate_parser_errors(parser_errors),
|
|
130
|
+
notifier: notifier || build_notifier(logger),
|
|
131
|
+
notifier_errors: validate_notifier_errors(notifier_errors),
|
|
132
|
+
notify_skipped: !!notify_skipped,
|
|
133
|
+
skipped_inputs: validate_skipped_inputs(skipped_inputs)
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def scan_query? = scan.include?(:query)
|
|
138
|
+
def scan_params? = scan.include?(:params)
|
|
139
|
+
def scan_path? = scan.include?(:path)
|
|
140
|
+
def scan_headers? = scan.include?(:headers)
|
|
141
|
+
def scan_cookies? = scan.include?(:cookies)
|
|
142
|
+
def scan_both_threats? = scan_sqli && scan_xss
|
|
143
|
+
def notifier_active? = !notifier.equal?(NOOP_NOTIFIER)
|
|
144
|
+
|
|
145
|
+
def env_only_scan? = !scan_params? && !scan_cookies?
|
|
146
|
+
|
|
147
|
+
def parser_error_policy
|
|
148
|
+
return mode == :block ? :block : :report if parser_errors == :auto
|
|
149
|
+
|
|
150
|
+
parser_errors
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def skipped_input_policy
|
|
154
|
+
return mode == :block ? :block : :report if skipped_inputs == :auto
|
|
155
|
+
|
|
156
|
+
skipped_inputs
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def ignore_param?(key) = ignore_params_lookup.key?(key.to_s.downcase)
|
|
160
|
+
def ignore_header?(normalized_key) = ignore_headers_lookup.key?(normalized_key)
|
|
161
|
+
|
|
162
|
+
def self.normalize_param_names(values)
|
|
163
|
+
Array(values).compact.map { |value| value.to_s.downcase }.uniq.freeze
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.normalize_header_names(values)
|
|
167
|
+
Array(values).compact.map { |value| value.to_s.downcase }.uniq.freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.lookup_for(values)
|
|
171
|
+
values.each_with_object({}) { |key, index| index[key] = true }.freeze
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.positive_integer!(value, name)
|
|
175
|
+
integer = Integer(value)
|
|
176
|
+
return integer if integer.positive?
|
|
177
|
+
|
|
178
|
+
raise ArgumentError, "#{name} must be positive"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.non_negative_integer!(value, name)
|
|
182
|
+
integer = Integer(value)
|
|
183
|
+
return integer if integer >= 0
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "#{name} must be >= 0"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.bounded_non_negative_integer!(value, name, max)
|
|
189
|
+
integer = non_negative_integer!(value, name)
|
|
190
|
+
return integer if integer <= max
|
|
191
|
+
|
|
192
|
+
raise ArgumentError, "#{name} must be <= #{max}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def self.validate_mode(value)
|
|
196
|
+
mode = value.to_sym
|
|
197
|
+
return mode if VALID_MODES.include?(mode)
|
|
198
|
+
|
|
199
|
+
raise ArgumentError, "mode must be one of: #{VALID_MODES.join(", ")}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.validate_scan(value)
|
|
203
|
+
scan = Array(value).map(&:to_sym).uniq.freeze
|
|
204
|
+
unknown = scan - VALID_SCAN
|
|
205
|
+
return scan if unknown.empty?
|
|
206
|
+
|
|
207
|
+
raise ArgumentError, "scan contains unknown locations: #{unknown.join(", ")}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def self.validate_threats(value)
|
|
211
|
+
threats = Array(value).map(&:to_sym).uniq.freeze
|
|
212
|
+
unknown = threats - VALID_THREATS
|
|
213
|
+
raise ArgumentError, "threats must include at least one of: #{VALID_THREATS.join(", ")}" if threats.empty?
|
|
214
|
+
return threats if unknown.empty?
|
|
215
|
+
|
|
216
|
+
raise ArgumentError, "threats contains unknown types: #{unknown.join(", ")}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def self.detect_mask_for(threats)
|
|
220
|
+
(threats.include?(:sqli) ? 1 : 0) | (threats.include?(:xss) ? 2 : 0)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.validate_parser_errors(value)
|
|
224
|
+
mode = value.to_sym
|
|
225
|
+
return mode if VALID_PARSER_ERRORS.include?(mode)
|
|
226
|
+
|
|
227
|
+
raise ArgumentError, "parser_errors must be one of: #{VALID_PARSER_ERRORS.join(", ")}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def self.validate_notifier_errors(value)
|
|
231
|
+
mode = value.to_sym
|
|
232
|
+
return mode if VALID_NOTIFIER_ERRORS.include?(mode)
|
|
233
|
+
|
|
234
|
+
raise ArgumentError, "notifier_errors must be one of: #{VALID_NOTIFIER_ERRORS.join(", ")}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def self.validate_skipped_inputs(value)
|
|
238
|
+
mode = value.to_sym
|
|
239
|
+
return mode if VALID_SKIPPED_INPUTS.include?(mode)
|
|
240
|
+
|
|
241
|
+
raise ArgumentError, "skipped_inputs must be one of: #{VALID_SKIPPED_INPUTS.join(", ")}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def self.build_notifier(logger)
|
|
245
|
+
if logger
|
|
246
|
+
->(event, payload) {
|
|
247
|
+
logger.warn(
|
|
248
|
+
"[rack-libinjection] #{event} type=#{payload[:type]} " \
|
|
249
|
+
"path=#{payload[:path]} #{payload[:location]}=#{payload[:key]}"
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
elsif defined?(::ActiveSupport::Notifications)
|
|
253
|
+
->(event, payload) { ::ActiveSupport::Notifications.instrument(event, payload) }
|
|
254
|
+
else
|
|
255
|
+
NOOP_NOTIFIER
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
attr_reader :app, :config
|
|
261
|
+
|
|
262
|
+
def initialize(app, **options)
|
|
263
|
+
@app = app
|
|
264
|
+
@config = Config.build(**options)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def mode = config.mode
|
|
268
|
+
def scan = config.scan
|
|
269
|
+
def threats = config.threats
|
|
270
|
+
def ignore_params = config.ignore_params
|
|
271
|
+
def max_value_bytes = config.max_value_bytes
|
|
272
|
+
def max_depth = config.max_depth
|
|
273
|
+
def path_decode_depth = config.path_decode_depth
|
|
274
|
+
def parser_errors = config.parser_errors
|
|
275
|
+
def notifier = config.notifier
|
|
276
|
+
|
|
277
|
+
def call(env)
|
|
278
|
+
return app.call(env) if mode == :off
|
|
279
|
+
|
|
280
|
+
context = nil
|
|
281
|
+
attacks = nil
|
|
282
|
+
|
|
283
|
+
if config.env_only_scan?
|
|
284
|
+
context = env
|
|
285
|
+
attacks = collect_attacks_from_env(env)
|
|
286
|
+
else
|
|
287
|
+
context = ::Rack::Request.new(env)
|
|
288
|
+
attacks = collect_attacks(context)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
env[ATTACK_ENV_KEY] = attacks
|
|
292
|
+
|
|
293
|
+
if attacks.any?
|
|
294
|
+
notify_attacks(context, attacks)
|
|
295
|
+
return forbidden_response if mode == :block
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
app.call(env)
|
|
299
|
+
rescue ParserBlocked
|
|
300
|
+
env[ATTACK_ENV_KEY] = []
|
|
301
|
+
forbidden_response
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
def collect_attacks(req)
|
|
307
|
+
attacks = []
|
|
308
|
+
|
|
309
|
+
scan_query_into(attacks, req, req) if config.scan_query?
|
|
310
|
+
scan_path_into(attacks, req.path.to_s, req) if config.scan_path?
|
|
311
|
+
scan_headers_into(attacks, req.env, req) if config.scan_headers?
|
|
312
|
+
scan_cookies_into(attacks, req, req) if config.scan_cookies?
|
|
313
|
+
scan_params_into(attacks, req, req) if config.scan_params?
|
|
314
|
+
|
|
315
|
+
attacks
|
|
316
|
+
rescue ::LibInjection::ParserError => e
|
|
317
|
+
handle_parser_error(req, e)
|
|
318
|
+
[]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def collect_attacks_from_env(env)
|
|
322
|
+
attacks = []
|
|
323
|
+
|
|
324
|
+
scan_query_value_into(attacks, env["QUERY_STRING"].to_s, env) if config.scan_query?
|
|
325
|
+
scan_path_into(attacks, env["PATH_INFO"].to_s, env) if config.scan_path?
|
|
326
|
+
scan_headers_into(attacks, env, env) if config.scan_headers?
|
|
327
|
+
|
|
328
|
+
attacks
|
|
329
|
+
rescue ::LibInjection::ParserError => e
|
|
330
|
+
handle_parser_error(env, e)
|
|
331
|
+
[]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def scan_query_into(attacks, req, context)
|
|
335
|
+
scan_query_value_into(attacks, req.query_string.to_s, context)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def scan_query_value_into(attacks, query, context)
|
|
339
|
+
return if query.empty?
|
|
340
|
+
|
|
341
|
+
scan_url_encoded_string_into(attacks, query, location: :query, key: "query", key_name: false, context: context, plus_as_space: true)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def scan_path_into(attacks, path, context)
|
|
345
|
+
scan_path_value_into(attacks, path, key: "path", context: context)
|
|
346
|
+
scan_path_segments_into(attacks, path, context)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def scan_path_segments_into(attacks, path, context)
|
|
350
|
+
path = path.b
|
|
351
|
+
start = 0
|
|
352
|
+
segment_index = 0
|
|
353
|
+
bytes = path.bytesize
|
|
354
|
+
|
|
355
|
+
loop do
|
|
356
|
+
slash = path.index("/", start) || bytes
|
|
357
|
+
if slash > start
|
|
358
|
+
segment = path.byteslice(start, slash - start)
|
|
359
|
+
scan_path_value_into(attacks, segment, key: "path[#{segment_index}]", context: context)
|
|
360
|
+
segment_index += 1
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
break if slash >= bytes
|
|
364
|
+
|
|
365
|
+
start = slash + 1
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def scan_path_value_into(attacks, value, key:, context:)
|
|
370
|
+
scan_url_encoded_string_into(attacks, value, location: :path, key: key, key_name: false, context: context, plus_as_space: false)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def scan_params_into(attacks, req, context)
|
|
374
|
+
walk_into(attacks, req.params, location: :params, path: +"", depth: 0, context: context)
|
|
375
|
+
rescue *PARAMETER_ERRORS => e
|
|
376
|
+
handle_parser_error(context, e)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def scan_headers_into(attacks, env, context)
|
|
380
|
+
env.each do |key, value|
|
|
381
|
+
name = header_name(key)
|
|
382
|
+
next unless name
|
|
383
|
+
next if config.ignore_header?(name)
|
|
384
|
+
|
|
385
|
+
scan_string_into(attacks, value.to_s, location: :headers, key: name, key_name: false, context: context)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def scan_cookies_into(attacks, req, context)
|
|
390
|
+
req.cookies.each do |key, value|
|
|
391
|
+
key_s = key.to_s
|
|
392
|
+
next if config.ignore_param?(key_s)
|
|
393
|
+
|
|
394
|
+
scan_string_into(attacks, key_s, location: :cookies, key: key_s, key_name: true, context: context) if config.scan_cookie_names
|
|
395
|
+
scan_string_into(attacks, value.to_s, location: :cookies, key: key_s, key_name: false, context: context)
|
|
396
|
+
end
|
|
397
|
+
rescue *PARAMETER_ERRORS => e
|
|
398
|
+
handle_parser_error(context, e)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def header_name(key)
|
|
402
|
+
return "content-type" if key == "CONTENT_TYPE"
|
|
403
|
+
return "content-length" if key == "CONTENT_LENGTH"
|
|
404
|
+
return unless key.start_with?("HTTP_")
|
|
405
|
+
|
|
406
|
+
key.byteslice(5, key.bytesize - 5).tr("_", "-").downcase
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def request_meta(req)
|
|
410
|
+
{ method: req.request_method, path: req.path, ip: req.ip }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def request_meta_from_env(env)
|
|
414
|
+
{
|
|
415
|
+
method: env["REQUEST_METHOD"].to_s,
|
|
416
|
+
path: env["PATH_INFO"].to_s,
|
|
417
|
+
ip: env["REMOTE_ADDR"].to_s
|
|
418
|
+
}
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def meta_hash?(context)
|
|
422
|
+
context.is_a?(Hash) && context.key?(:method) && context.key?(:path) && context.key?(:ip)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def meta_for(context)
|
|
426
|
+
return context if meta_hash?(context)
|
|
427
|
+
return request_meta_from_env(context) if context.is_a?(Hash)
|
|
428
|
+
|
|
429
|
+
request_meta(context)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def notify_attacks(context, attacks)
|
|
433
|
+
return unless config.notifier_active?
|
|
434
|
+
|
|
435
|
+
meta = meta_for(context)
|
|
436
|
+
attacks.each { |attack| notify(EVENT_NAME, attack.to_h.merge(meta)) }
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def notify_error(context, error)
|
|
440
|
+
return unless config.notifier_active?
|
|
441
|
+
|
|
442
|
+
meta = meta_for(context)
|
|
443
|
+
notify(ERROR_EVENT, meta.merge(error: error.class.name, message: error.message))
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def notify_skipped(context, reason:, location:, key:, bytes: nil, limit: nil)
|
|
447
|
+
return unless config.notify_skipped && config.notifier_active?
|
|
448
|
+
|
|
449
|
+
meta = meta_for(context)
|
|
450
|
+
notify(
|
|
451
|
+
SKIPPED_EVENT,
|
|
452
|
+
meta.merge(type: :skipped, reason: reason, location: location, key: key, bytes: bytes, limit: limit)
|
|
453
|
+
)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def notify(event, payload)
|
|
457
|
+
notifier.call(event, payload)
|
|
458
|
+
rescue StandardError
|
|
459
|
+
raise if config.notifier_errors == :raise
|
|
460
|
+
|
|
461
|
+
nil
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def handle_parser_error(context, error)
|
|
465
|
+
case config.parser_error_policy
|
|
466
|
+
when :raise
|
|
467
|
+
raise error
|
|
468
|
+
when :block
|
|
469
|
+
notify_error(context, error)
|
|
470
|
+
raise ParserBlocked
|
|
471
|
+
else
|
|
472
|
+
notify_error(context, error)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def handle_skipped_input(context, reason:, location:, key:, bytes: nil, limit: nil)
|
|
477
|
+
case config.skipped_input_policy
|
|
478
|
+
when :block
|
|
479
|
+
notify_skipped(context, reason: reason, location: location, key: key, bytes: bytes, limit: limit)
|
|
480
|
+
raise ParserBlocked
|
|
481
|
+
when :report
|
|
482
|
+
notify_skipped(context, reason: reason, location: location, key: key, bytes: bytes, limit: limit)
|
|
483
|
+
else
|
|
484
|
+
nil
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def forbidden_response
|
|
489
|
+
[403, FORBIDDEN_HEADERS.dup, FORBIDDEN_BODY]
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def walk_into(attacks, value, location:, path:, depth:, context:)
|
|
493
|
+
if depth > max_depth
|
|
494
|
+
handle_skipped_input(context, reason: :max_depth, location: location, key: path, limit: max_depth)
|
|
495
|
+
return
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
case value
|
|
499
|
+
when Hash
|
|
500
|
+
value.each do |key, child|
|
|
501
|
+
key_s = key.to_s
|
|
502
|
+
next if config.ignore_param?(key_s)
|
|
503
|
+
|
|
504
|
+
child_path = path.empty? ? key_s : "#{path}.#{key_s}"
|
|
505
|
+
scan_string_into(attacks, key_s, location: location, key: child_path, key_name: true, context: context)
|
|
506
|
+
walk_into(attacks, child, location: location, path: child_path, depth: depth + 1, context: context)
|
|
507
|
+
end
|
|
508
|
+
when Array
|
|
509
|
+
value.each_with_index do |child, index|
|
|
510
|
+
child_path = "#{path}[#{index}]"
|
|
511
|
+
walk_into(attacks, child, location: location, path: child_path, depth: depth + 1, context: context)
|
|
512
|
+
end
|
|
513
|
+
when String
|
|
514
|
+
scan_string_into(attacks, value, location: location, key: path, key_name: false, context: context)
|
|
515
|
+
else
|
|
516
|
+
scan_uploaded_filename_into(attacks, value, location: location, key: path, context: context)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def scan_uploaded_filename_into(attacks, value, location:, key:, context:)
|
|
521
|
+
filename = if value.respond_to?(:original_filename)
|
|
522
|
+
value.original_filename
|
|
523
|
+
elsif value.respond_to?(:filename)
|
|
524
|
+
value.filename
|
|
525
|
+
end
|
|
526
|
+
return unless filename.is_a?(String)
|
|
527
|
+
|
|
528
|
+
scan_string_into(attacks, filename, location: location, key: "#{key}.filename", key_name: false, context: context)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def scan_url_encoded_string_into(attacks, value, location:, key:, key_name:, context:, plus_as_space:)
|
|
532
|
+
bytes = value.bytesize
|
|
533
|
+
return if bytes.zero?
|
|
534
|
+
|
|
535
|
+
if bytes > max_value_bytes
|
|
536
|
+
handle_skipped_input(context, reason: :max_value_bytes, location: location, key: key, bytes: bytes, limit: max_value_bytes)
|
|
537
|
+
return
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
match = ::LibInjection.detect_url_encoded_raw(value, path_decode_depth, plus_as_space, config.detect_mask)
|
|
541
|
+
return unless match
|
|
542
|
+
|
|
543
|
+
attacks << Attack.new(
|
|
544
|
+
type: match[0],
|
|
545
|
+
location: location,
|
|
546
|
+
key: key,
|
|
547
|
+
key_name: key_name,
|
|
548
|
+
fingerprint: match[1],
|
|
549
|
+
bytes: bytes
|
|
550
|
+
)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def scan_string_into(attacks, value, location:, key:, key_name:, context:)
|
|
554
|
+
bytes = value.bytesize
|
|
555
|
+
return if bytes.zero?
|
|
556
|
+
|
|
557
|
+
if bytes > max_value_bytes
|
|
558
|
+
handle_skipped_input(context, reason: :max_value_bytes, location: location, key: key, bytes: bytes, limit: max_value_bytes)
|
|
559
|
+
return
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
match = detect_value(value)
|
|
563
|
+
return unless match
|
|
564
|
+
|
|
565
|
+
attacks << Attack.new(
|
|
566
|
+
type: match[0],
|
|
567
|
+
location: location,
|
|
568
|
+
key: key,
|
|
569
|
+
key_name: key_name,
|
|
570
|
+
fingerprint: match[1],
|
|
571
|
+
bytes: bytes
|
|
572
|
+
)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def detect_value(value)
|
|
576
|
+
if config.scan_both_threats?
|
|
577
|
+
::LibInjection.detect_raw(value)
|
|
578
|
+
elsif config.scan_sqli
|
|
579
|
+
fingerprint = ::LibInjection.sqli_fingerprint(value)
|
|
580
|
+
fingerprint && [:sqli, fingerprint]
|
|
581
|
+
elsif ::LibInjection.xss?(value)
|
|
582
|
+
[:xss, nil]
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|