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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +55 -0
  3. data/CHANGELOG.md +112 -0
  4. data/GET_STARTED.md +418 -0
  5. data/LICENSE-libinjection.txt +33 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +68 -0
  8. data/SECURITY.md +65 -0
  9. data/ext/libinjection/extconf.rb +113 -0
  10. data/ext/libinjection/libinjection_ext.c +1132 -0
  11. data/ext/libinjection/vendor/libinjection/.vendored +5 -0
  12. data/ext/libinjection/vendor/libinjection/COPYING +33 -0
  13. data/ext/libinjection/vendor/libinjection/MIGRATION.md +393 -0
  14. data/ext/libinjection/vendor/libinjection/README.md +251 -0
  15. data/ext/libinjection/vendor/libinjection/src/libinjection.h +70 -0
  16. data/ext/libinjection/vendor/libinjection/src/libinjection_error.h +26 -0
  17. data/ext/libinjection/vendor/libinjection/src/libinjection_html5.c +830 -0
  18. data/ext/libinjection/vendor/libinjection/src/libinjection_html5.h +56 -0
  19. data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.c +2342 -0
  20. data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.h +297 -0
  21. data/ext/libinjection/vendor/libinjection/src/libinjection_sqli_data.h +9651 -0
  22. data/ext/libinjection/vendor/libinjection/src/libinjection_xss.c +1203 -0
  23. data/ext/libinjection/vendor/libinjection/src/libinjection_xss.h +23 -0
  24. data/lib/libinjection/version.rb +6 -0
  25. data/lib/libinjection.rb +31 -0
  26. data/lib/rack/libinjection.rb +586 -0
  27. data/lib/rack-libinjection.rb +3 -0
  28. data/samples/README.md +67 -0
  29. data/samples/libinjection_detect_raw_hot_path.rb +161 -0
  30. data/samples/rack_all_surfaces_hot_path.rb +198 -0
  31. data/samples/rack_params_hot_path.rb +166 -0
  32. data/samples/rack_query_hot_path.rb +176 -0
  33. data/samples/results/.gitkeep +0 -0
  34. data/script/fuzz_smoke.rb +39 -0
  35. data/script/vendor_libs.rb +227 -0
  36. data/test/test_helper.rb +7 -0
  37. data/test/test_libinjection.rb +223 -0
  38. data/test/test_middleware.rb +404 -0
  39. 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibInjection
4
+ VERSION = "0.1.0"
5
+ LIBINJECTION_VERSION = "4.0.0"
6
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/libinjection"