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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rack middleware hot-path sample for the raw query-string surface:
4
+ # Rack::LibInjection.new(app, scan: [:query]).call(env)
5
+ #
6
+ # This path avoids Rack nested param parsing and scans the raw query string plus
7
+ # decoded variants. It is a fast WAF-style signal surface, not a semantic params
8
+ # walker replacement.
9
+ #
10
+ # Run:
11
+ # bundle exec ruby samples/rack_query_hot_path.rb
12
+ #
13
+ # Optional env:
14
+ # PAYLOAD=clean|sqli|xss DURATION=20 PREHEAT_ITERATIONS=100 DISABLE_GC=0 \
15
+ # THREATS=sqli,xss PATH_DECODE_DEPTH=2 bundle exec ruby samples/rack_query_hot_path.rb
16
+
17
+ $stdout.sync = true
18
+ $stderr.sync = true
19
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
20
+
21
+ require "rack/mock"
22
+ require "rack/libinjection"
23
+
24
+ payload_mode = ENV.fetch("PAYLOAD", "clean")
25
+ threats = ENV.fetch("THREATS", "sqli,xss").split(",").map { |value| value.strip.to_sym }
26
+ path_decode_depth = Integer(ENV.fetch("PATH_DECODE_DEPTH", "2"))
27
+ sleep_before_hot_loop = Float(ENV.fetch("SLEEP_BEFORE_HOT_LOOP", "7.0"))
28
+ duration = Float(ENV.fetch("DURATION", "20.0"))
29
+ preheat_iterations = Integer(ENV.fetch("PREHEAT_ITERATIONS", "100"))
30
+ disable_gc = ENV.fetch("DISABLE_GC", "0") != "0"
31
+ sample_name = "rack_libinjection_query_hot_path"
32
+ native_grep = "Rack::LibInjection|rack/libinjection|LibInjection|libinjection|rb_li_|li_input_|li_scan_|libinjection_sqli|libinjection_xss|collect_attacks_from_env|scan_query_into|scan_query_value_into|scan_url_encoded_string_into|scan_string_into|detect_url_encoded_raw|li_url_decode|li_url_encoded_candidate"
33
+
34
+ ATTACK_ENV_KEY = Rack::LibInjection::ATTACK_ENV_KEY
35
+
36
+ def monotonic
37
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
38
+ end
39
+
40
+ def gc_snapshot
41
+ stat = GC.stat
42
+
43
+ {
44
+ total_allocated_objects: stat.fetch(:total_allocated_objects),
45
+ minor_gc_count: stat.fetch(:minor_gc_count),
46
+ major_gc_count: stat.fetch(:major_gc_count)
47
+ }
48
+ end
49
+
50
+ def gc_delta(before, after)
51
+ before.each_with_object({}) do |(key, value), out|
52
+ out[key] = after.fetch(key) - value
53
+ end
54
+ end
55
+
56
+ def payload_for(mode)
57
+ case mode
58
+ when "clean" then "normal search term"
59
+ when "sqli" then "1 OR 1=1--"
60
+ when "xss" then "<script>alert(1)</script>"
61
+ else
62
+ raise ArgumentError, "PAYLOAD must be one of: clean, sqli, xss"
63
+ end
64
+ end
65
+
66
+ def expected_min_attacks(mode, threats)
67
+ return 0 if mode == "clean"
68
+ return 0 if mode == "sqli" && !threats.include?(:sqli)
69
+ return 0 if mode == "xss" && !threats.include?(:xss)
70
+
71
+ 1
72
+ end
73
+
74
+ def build_query(payload)
75
+ Rack::Utils.build_nested_query(
76
+ "q" => payload,
77
+ "profile" => {
78
+ "name" => "Roman",
79
+ "filters" => ["recent", payload, "safe"]
80
+ },
81
+ "page" => "1"
82
+ )
83
+ end
84
+
85
+ payload = payload_for(payload_mode)
86
+ expected_min = expected_min_attacks(payload_mode, threats)
87
+ query = build_query(payload)
88
+ path = "/search?#{query}"
89
+ base_env = Rack::MockRequest.env_for(path)
90
+
91
+ app = ->(_env) { [204, {}, []] }
92
+ middleware = Rack::LibInjection.new(
93
+ app,
94
+ mode: :report,
95
+ scan: [:query],
96
+ threats: threats,
97
+ path_decode_depth: path_decode_depth,
98
+ notify_skipped: false
99
+ )
100
+
101
+ preheat_iterations.times do
102
+ env = base_env.dup
103
+ status, = middleware.call(env)
104
+ attacks = env.fetch(ATTACK_ENV_KEY)
105
+ raise "preheat: unexpected status #{status}" unless status == 204
106
+ raise "preheat: expected >= #{expected_min} attacks, got #{attacks.inspect}" if attacks.length < expected_min
107
+ end
108
+
109
+ sample_seconds = (sleep_before_hot_loop + duration + 15).ceil
110
+ sample_file = "/tmp/#{sample_name}.sample"
111
+ txt_file = File.expand_path("results/#{sample_name}.txt", __dir__)
112
+ txt_dir = File.dirname(txt_file)
113
+
114
+ puts "pid=#{Process.pid}"
115
+ puts "ruby=#{RUBY_DESCRIPTION}"
116
+ puts "platform=#{RUBY_PLATFORM}"
117
+ puts "mode=#{sample_name}"
118
+ puts "call=Rack::LibInjection.new(app, scan: [:query]).call(env)"
119
+ puts "payload=#{payload_mode}"
120
+ puts "threats=#{threats.join(',')}"
121
+ puts "path_decode_depth=#{path_decode_depth}"
122
+ puts "query_bytes=#{query.bytesize}"
123
+ puts "disable_gc=#{disable_gc}"
124
+ puts "duration=#{duration}"
125
+ puts "preheat_iterations=#{preheat_iterations}"
126
+ puts "sample_seconds=#{sample_seconds}"
127
+ puts "sample_file=#{sample_file}"
128
+ puts "txt_file=#{txt_file}"
129
+ puts
130
+ puts "Copy this one-line capture command:"
131
+ puts %(mkdir -p "#{txt_dir}"; OUT="#{txt_file}"; SAMPLE="#{sample_file}"; { sample #{Process.pid} #{sample_seconds} -f "$SAMPLE"; echo; echo "===== focused Rack/native symbols ====="; filtercalltree "$SAMPLE" | grep -E "#{native_grep}" | head -360; echo; echo "===== filtercalltree head -360 ====="; filtercalltree "$SAMPLE" | head -360; } 2>&1 | tee "$OUT")
132
+ puts
133
+ puts "Expected hot symbols:"
134
+ puts " Rack::LibInjection#call / #collect_attacks_from_env"
135
+ puts " Rack::LibInjection#scan_query_value_into / #scan_url_encoded_string_into"
136
+ puts " LibInjection.detect_url_encoded_raw -> rb_li_detect_url_encoded_raw"
137
+ puts " li_input_prepare / li_scan_run / li_scan_perform"
138
+ puts " libinjection_sqli / libinjection_xss"
139
+ puts
140
+ puts "sleep=#{sleep_before_hot_loop} seconds before hot loop"
141
+ puts
142
+
143
+ begin
144
+ GC.start
145
+ GC.disable if disable_gc
146
+ before_gc = gc_snapshot
147
+ sleep sleep_before_hot_loop
148
+
149
+ count = 0
150
+ total_attacks = 0
151
+ started = monotonic
152
+ deadline = started + duration
153
+
154
+ while monotonic < deadline
155
+ env = base_env.dup
156
+ status, = middleware.call(env)
157
+ raise "hot_loop: unexpected status #{status}" unless status == 204
158
+
159
+ attacks = env.fetch(ATTACK_ENV_KEY)
160
+ total_attacks += attacks.length
161
+ count += 1
162
+ end
163
+
164
+ elapsed = monotonic - started
165
+ after_gc = gc_snapshot
166
+
167
+ puts "count=#{count}"
168
+ puts "total_attacks=#{total_attacks}"
169
+ puts "attacks_per_request=#{format('%.6f', total_attacks.to_f / [count, 1].max)}"
170
+ puts "elapsed=#{format('%.6f', elapsed)}"
171
+ puts "requests_per_sec=#{format('%.6f', count / elapsed)}"
172
+ puts "sec_per_request=#{format('%.9f', elapsed / [count, 1].max)}"
173
+ puts "gc_delta=#{gc_delta(before_gc, after_gc)}"
174
+ ensure
175
+ GC.enable
176
+ end
File without changes
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "securerandom"
7
+ require "libinjection"
8
+
9
+ PAYLOADS = [
10
+ "",
11
+ "1 OR 1=1--",
12
+ "<script>alert(1)</script>",
13
+ "abc\0def".b,
14
+ "\xFF\xFE1 OR 1=1--".b,
15
+ ("a" * 2_048) + " 1 OR 1=1--",
16
+ "1 OR 1=1--".encode(Encoding::UTF_16LE)
17
+ ].freeze
18
+
19
+ PAYLOADS.each do |payload|
20
+ LibInjection.detect(payload)
21
+ LibInjection.sqli?(payload)
22
+ LibInjection.xss?(payload)
23
+ end
24
+
25
+ 1_000.times do |i|
26
+ bytes = SecureRandom.random_bytes(rand(0..4096))
27
+ bytes.force_encoding([Encoding::BINARY, Encoding::UTF_8].sample)
28
+
29
+ LibInjection.detect_raw(bytes)
30
+ LibInjection.sqli?(bytes)
31
+ LibInjection.xss?(bytes)
32
+ rescue LibInjection::ParserError
33
+ next
34
+ rescue StandardError => e
35
+ warn "fuzz smoke failed at iteration #{i}: #{e.class}: #{e.message}"
36
+ raise
37
+ end
38
+
39
+ puts "fuzz smoke: ok"
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "fileutils"
6
+ require "net/http"
7
+ require "uri"
8
+ require "optparse"
9
+ require "rubygems/package"
10
+ require "tmpdir"
11
+ require "zlib"
12
+
13
+ VENDOR_ROOT = File.expand_path("../ext/libinjection/vendor", __dir__)
14
+ LIB_DIR = File.join(VENDOR_ROOT, "libinjection")
15
+ MANIFEST_PATH = File.join(LIB_DIR, ".vendored")
16
+ NORMALIZED_MTIME = Time.utc(2000, 1, 1).freeze
17
+ MANIFEST_HEADER = "# rack-libinjection vendor manifest. Do not edit by hand. Regenerate with: ruby script/vendor_libs.rb"
18
+
19
+ PIN = {
20
+ name: "libinjection",
21
+ version: "4.0.0",
22
+ url: "https://codeload.github.com/libinjection/libinjection/tar.gz/v%<version>s?dummy=/",
23
+ strip_prefix: "libinjection-%<version>s",
24
+ sha256: "a69d27e3d98608df89203c4e1c00c034fe0f8c723017e4088ab53ce3ff5a9129",
25
+ size: 2_237_310,
26
+ keep: %w[
27
+ COPYING
28
+ README.md
29
+ MIGRATION.md
30
+ src/libinjection.h
31
+ src/libinjection_error.h
32
+ src/libinjection_html5.h
33
+ src/libinjection_sqli.h
34
+ src/libinjection_sqli_data.h
35
+ src/libinjection_xss.h
36
+ src/libinjection_sqli.c
37
+ src/libinjection_xss.c
38
+ src/libinjection_html5.c
39
+ ]
40
+ }.freeze
41
+
42
+ options = { mode: :sync }
43
+ OptionParser.new do |opts|
44
+ opts.banner = "Usage: script/vendor_libs.rb [--sync | --verify]"
45
+ opts.on("--sync", "Download and vendor pinned libinjection source") { options[:mode] = :sync }
46
+ opts.on("--verify", "Verify vendored source tree without network") { options[:mode] = :verify }
47
+ end.parse!
48
+
49
+ def normalize_tree!(directory)
50
+ Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH).each do |path|
51
+ base = File.basename(path)
52
+ next if base == "." || base == ".." || File.symlink?(path)
53
+
54
+ if File.file?(path)
55
+ File.chmod(0o644, path)
56
+ File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, path)
57
+ elsif File.directory?(path)
58
+ File.chmod(0o755, path)
59
+ end
60
+ end
61
+ end
62
+
63
+ def tree_sha256_for(directory)
64
+ entries = Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
65
+ .reject { |path| File.directory?(path) || File.symlink?(path) || %w[. .. .vendored].include?(File.basename(path)) }
66
+ .sort
67
+
68
+ digest = Digest::SHA256.new
69
+ entries.each do |path|
70
+ relative = path.sub(/\A#{Regexp.escape(directory)}\/?/, "")
71
+ digest << relative << "\0"
72
+ digest << File.binread(path)
73
+ digest << "\0"
74
+ end
75
+ digest.hexdigest
76
+ end
77
+
78
+ def manifest_body(tree_sha256)
79
+ [
80
+ "libinjection_version=#{PIN[:version]}",
81
+ "libinjection_url=#{format(PIN[:url], version: PIN[:version])}",
82
+ "libinjection_archive_sha256=#{PIN[:sha256]}",
83
+ "libinjection_tree_sha256=#{tree_sha256}"
84
+ ]
85
+ end
86
+
87
+ def write_manifest!(tree_sha256)
88
+ content = ([MANIFEST_HEADER] + manifest_body(tree_sha256)).join("\n") + "\n"
89
+ File.write(MANIFEST_PATH, content)
90
+ File.chmod(0o644, MANIFEST_PATH)
91
+ File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, MANIFEST_PATH)
92
+ end
93
+
94
+ def parse_manifest
95
+ return {} unless File.file?(MANIFEST_PATH)
96
+
97
+ File.readlines(MANIFEST_PATH, chomp: true).each_with_object({}) do |line, kv|
98
+ next if line.empty? || line.start_with?("#")
99
+
100
+ key, value = line.split("=", 2)
101
+ kv[key] = value if key && value
102
+ end
103
+ end
104
+
105
+ def verify_archive!(path)
106
+ size = File.size(path)
107
+ abort "Archive size mismatch: expected #{PIN[:size]}, got #{size}" unless size == PIN[:size]
108
+
109
+ actual = Digest::SHA256.file(path).hexdigest
110
+ abort "SHA256 mismatch: expected #{PIN[:sha256]}, got #{actual}" unless actual == PIN[:sha256]
111
+ end
112
+
113
+ def http_download_to_file!(url, path, redirect_limit: 3)
114
+ abort "too many redirects while downloading #{url}" if redirect_limit.negative?
115
+
116
+ uri = URI(url)
117
+ Net::HTTP.start(
118
+ uri.host,
119
+ uri.port,
120
+ use_ssl: uri.scheme == "https",
121
+ open_timeout: 10,
122
+ read_timeout: 30
123
+ ) do |http|
124
+ request = Net::HTTP::Get.new(uri)
125
+ http.request(request) do |response|
126
+ case response
127
+ when Net::HTTPSuccess
128
+ File.open(path, "wb") { |file| response.read_body { |chunk| file.write(chunk) } }
129
+ when Net::HTTPRedirection
130
+ location = response["location"]
131
+ abort "redirect without Location while downloading #{url}" unless location
132
+
133
+ return http_download_to_file!(URI.join(uri, location).to_s, path, redirect_limit: redirect_limit - 1)
134
+ else
135
+ abort "download failed: HTTP #{response.code} #{response.message}"
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ def download_archive!(path)
142
+ url = format(PIN[:url], version: PIN[:version])
143
+ puts "Downloading #{url}"
144
+
145
+ attempts = 0
146
+ begin
147
+ attempts += 1
148
+ http_download_to_file!(url, path)
149
+ rescue SystemCallError, IOError, Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
150
+ retry if attempts < 3
151
+
152
+ abort "download failed after #{attempts} attempts: #{e.class}: #{e.message}"
153
+ end
154
+
155
+ verify_archive!(path)
156
+ end
157
+
158
+ def extract_archive!(archive)
159
+ strip_prefix = format(PIN[:strip_prefix], version: PIN[:version])
160
+ prefix_re = /\A#{Regexp.escape(strip_prefix)}\//
161
+
162
+ FileUtils.rm_rf(LIB_DIR)
163
+ FileUtils.mkdir_p(LIB_DIR)
164
+
165
+ Gem::Package::TarReader.new(Zlib::GzipReader.open(archive)) do |tar|
166
+ tar.each do |entry|
167
+ relative = entry.full_name.sub(prefix_re, "")
168
+ next if relative.empty? || relative == entry.full_name
169
+ next unless PIN[:keep].include?(relative)
170
+ next unless entry.file?
171
+
172
+ target = File.join(LIB_DIR, relative)
173
+ FileUtils.mkdir_p(File.dirname(target))
174
+ File.binwrite(target, entry.read)
175
+ end
176
+ end
177
+
178
+ missing = PIN[:keep].reject { |relative| File.file?(File.join(LIB_DIR, relative)) }
179
+ abort "Missing expected vendored files:\n #{missing.join("\n ")}" unless missing.empty?
180
+
181
+ normalize_tree!(LIB_DIR)
182
+ tree_sha256_for(LIB_DIR)
183
+ end
184
+
185
+ def verify_vendor!
186
+ failures = []
187
+ manifest = parse_manifest
188
+
189
+ expected_files = PIN[:keep] + [".vendored"]
190
+ missing = expected_files.reject { |relative| File.file?(File.join(LIB_DIR, relative)) }
191
+ failures << "missing files:\n #{missing.join("\n ")}" unless missing.empty?
192
+
193
+ if manifest["libinjection_archive_sha256"] != PIN[:sha256]
194
+ failures << "manifest archive sha256 does not match PIN"
195
+ end
196
+
197
+ if File.directory?(LIB_DIR)
198
+ actual_tree = tree_sha256_for(LIB_DIR)
199
+ manifest_tree = manifest["libinjection_tree_sha256"]
200
+ failures << "tree_sha256 mismatch: manifest=#{manifest_tree.inspect} actual=#{actual_tree}" if manifest_tree && manifest_tree != actual_tree
201
+ end
202
+
203
+ if failures.empty?
204
+ puts "vendor verify: ok"
205
+ exit 0
206
+ end
207
+
208
+ failures.each { |failure| warn failure }
209
+ exit 1
210
+ end
211
+
212
+ case options[:mode]
213
+ when :verify
214
+ verify_vendor!
215
+ when :sync
216
+ FileUtils.mkdir_p(VENDOR_ROOT)
217
+ Dir.mktmpdir("rack-libinjection-vendor-") do |tmpdir|
218
+ archive = File.join(tmpdir, "libinjection-#{PIN[:version]}.tar.gz")
219
+ download_archive!(archive)
220
+ tree = extract_archive!(archive)
221
+ write_manifest!(tree)
222
+ puts "vendor sync: ok"
223
+ puts " libinjection: version=#{PIN[:version]} archive_sha256=#{PIN[:sha256]} tree_sha256=#{tree}"
224
+ end
225
+ else
226
+ abort "unknown mode: #{options[:mode].inspect}"
227
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "minitest/autorun"
6
+ require "rack/mock"
7
+ require "rack/libinjection"
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class TestLibInjection < Minitest::Test
6
+ def test_sqli_detection
7
+ assert_equal true, LibInjection.sqli?("1 OR 1=1--")
8
+ end
9
+
10
+ def test_sqli_fingerprint
11
+ fingerprint = LibInjection.sqli_fingerprint("1 OR 1=1--")
12
+ assert_kind_of String, fingerprint
13
+ refute_empty fingerprint
14
+ end
15
+
16
+ def test_benign_string
17
+ assert_equal false, LibInjection.sqli?("ordinary search text")
18
+ assert_nil LibInjection.sqli_fingerprint("ordinary search text")
19
+ end
20
+
21
+ def test_empty_string_is_safe
22
+ assert_equal false, LibInjection.sqli?("")
23
+ assert_equal false, LibInjection.xss?("")
24
+ assert_nil LibInjection.detect_raw("")
25
+ end
26
+
27
+ def test_nil_is_rejected
28
+ assert_raises TypeError do
29
+ LibInjection.detect_raw(nil)
30
+ end
31
+ end
32
+
33
+ def test_binary_invalid_utf8_is_scanned_as_bytes
34
+ input = "\xFF\xFE1 OR 1=1--".b
35
+ input.force_encoding(Encoding::UTF_8)
36
+
37
+ assert_equal false, input.valid_encoding?
38
+ assert_equal :sqli, LibInjection.detect_raw(input).fetch(0)
39
+ end
40
+
41
+ def test_null_bytes_do_not_crash
42
+ input = "abc\0<script>alert(1)</script>".b
43
+
44
+ assert_equal :xss, LibInjection.detect_raw(input).fetch(0)
45
+ end
46
+
47
+ def test_utf16_is_not_implicitly_decoded
48
+ input = "1 OR 1=1--".encode(Encoding::UTF_16LE)
49
+
50
+ assert_nil LibInjection.detect_raw(input)
51
+ end
52
+
53
+ def test_large_input_uses_nogvl_path_safely
54
+ input = "1 OR 1=1--" + ("a" * 2_048)
55
+
56
+ assert_equal :sqli, LibInjection.detect_raw(input).fetch(0)
57
+ end
58
+
59
+ def test_detect_raw_sqli
60
+ match = LibInjection.detect_raw("1 OR 1=1--")
61
+
62
+ assert_equal :sqli, match[0]
63
+ assert_kind_of String, match[1]
64
+ end
65
+
66
+ def test_detect_raw_xss
67
+ match = LibInjection.detect_raw("<script>alert(1)</script>")
68
+
69
+ assert_equal :xss, match[0]
70
+ assert_nil match[1]
71
+ end
72
+
73
+ def test_detect_raw_benign
74
+ assert_nil LibInjection.detect_raw("ordinary search text")
75
+ end
76
+
77
+ def test_detect_result
78
+ result = LibInjection.detect("1 OR 1=1--")
79
+ assert result.detected?
80
+ assert result.sqli?
81
+ end
82
+
83
+ def test_sqli_result
84
+ result = LibInjection.sqli_result("1 OR 1=1--")
85
+
86
+ assert_equal :sqli, result[:type]
87
+ assert_equal true, result[:detected]
88
+ assert_kind_of String, result[:fingerprint]
89
+ assert_kind_of Hash, result[:stats]
90
+ end
91
+
92
+ def test_sqli_result_rejects_invalid_options
93
+ assert_raises ArgumentError do
94
+ LibInjection.sqli_result("1 OR 1=1--", context: :unknown)
95
+ end
96
+
97
+ assert_raises ArgumentError do
98
+ LibInjection.sqli_result("1 OR 1=1--", quote: :unknown)
99
+ end
100
+
101
+ assert_raises ArgumentError do
102
+ LibInjection.sqli_result("1 OR 1=1--", dialect: :unknown)
103
+ end
104
+ end
105
+
106
+ def test_sqli_contexts
107
+ contexts = LibInjection.sqli_contexts("1 OR 1=1--")
108
+
109
+ refute_empty contexts
110
+ assert contexts.any? { |ctx| ctx[:detected] }
111
+ assert contexts.all? { |ctx| ctx.key?(:context) && ctx.key?(:flags) }
112
+ end
113
+
114
+ def test_sqli_context_flags
115
+ assert_equal LibInjection::SQLI_CONTEXTS.fetch(:single_mysql),
116
+ LibInjection.sqli_flags(context: :single_mysql)
117
+ assert_equal LibInjection::SQLI_QUOTES.fetch(:single) | LibInjection::SQLI_DIALECTS.fetch(:mysql),
118
+ LibInjection.sqli_flags(quote: :single, dialect: :mysql)
119
+ end
120
+
121
+ def test_sqli_fingerprint_for_context
122
+ fp = LibInjection.sqli_fingerprint_for("1 OR 1=1--", context: :none_ansi)
123
+
124
+ assert_kind_of String, fp
125
+ refute_empty fp
126
+ end
127
+
128
+ def test_sqli_tokens
129
+ tokens = LibInjection.sqli_tokens("1 OR 1=1--")
130
+
131
+ refute_empty tokens
132
+ assert_equal :number, tokens.first[:type]
133
+ assert_equal "1", tokens.first[:value]
134
+ end
135
+
136
+ def test_sqli_tokens_rejects_invalid_options
137
+ assert_raises ArgumentError do
138
+ LibInjection.sqli_tokens("1 OR 1=1--", context: :unknown)
139
+ end
140
+ end
141
+
142
+ def test_sqli_folded_tokens
143
+ tokens = LibInjection.sqli_tokens("1 OR 1=1--", fold: true)
144
+
145
+ refute_empty tokens
146
+ assert tokens.size <= LibInjection.sqli_tokens("1 OR 1=1--").size
147
+ end
148
+
149
+ def test_xss_detection
150
+ assert_equal true, LibInjection.xss?("<script>alert(1)</script>")
151
+ end
152
+
153
+ def test_xss_result
154
+ result = LibInjection.xss_result("<script>alert(1)</script>")
155
+
156
+ assert_equal :xss, result[:type]
157
+ assert_equal true, result[:detected]
158
+ end
159
+
160
+ def test_xss_result_rejects_invalid_options
161
+ assert_raises ArgumentError do
162
+ LibInjection.xss_result("<script>alert(1)</script>", context: :unknown)
163
+ end
164
+ end
165
+
166
+ def test_xss_contexts
167
+ contexts = LibInjection.xss_contexts("<script>alert(1)</script>")
168
+
169
+ refute_empty contexts
170
+ assert contexts.any? { |ctx| ctx[:detected] }
171
+ end
172
+
173
+ def test_html5_tokens
174
+ tokens = LibInjection.html5_tokens("<script>alert(1)</script>")
175
+
176
+ refute_empty tokens
177
+ assert_equal :tag_name_open, tokens.first[:type]
178
+ assert_equal "script", tokens.first[:value]
179
+ end
180
+
181
+ def test_html5_tokens_rejects_invalid_options
182
+ assert_raises ArgumentError do
183
+ LibInjection.html5_tokens("<script>alert(1)</script>", context: :unknown)
184
+ end
185
+ end
186
+
187
+ def test_html5_context_flags
188
+ assert_equal LibInjection::HTML5_CONTEXTS.fetch(:value_double_quote),
189
+ LibInjection.xss_flags(context: :value_double_quote)
190
+ end
191
+ def test_detect_url_encoded_raw_decodes_twice
192
+ match = LibInjection.detect_url_encoded_raw("q=1%2520OR%25201%253D1--", 2, true, 3)
193
+
194
+ assert_equal :sqli, match.fetch(0)
195
+ end
196
+
197
+ def test_detect_url_encoded_raw_respects_depth
198
+ assert_nil LibInjection.detect_url_encoded_raw("q=1%2520OR%25201%253D1--", 1, true, 3)
199
+ end
200
+
201
+ def test_detect_url_encoded_raw_can_scan_only_xss
202
+ match = LibInjection.detect_url_encoded_raw("q=%3Cscript%3Ealert(1)%3C/script%3E", 2, true, 2)
203
+
204
+ assert_equal :xss, match.fetch(0)
205
+ end
206
+
207
+ def test_detect_url_encoded_raw_accepts_malformed_percent_escapes
208
+ assert_nil LibInjection.detect_url_encoded_raw("q=%ZZordinary", 2, true, 3)
209
+ end
210
+
211
+ def test_detect_url_encoded_raw_rejects_excessive_depth
212
+ assert_raises ArgumentError do
213
+ LibInjection.detect_url_encoded_raw("q=ordinary", 33, true, 3)
214
+ end
215
+ end
216
+
217
+ def test_detect_url_encoded_raw_rejects_invalid_mask
218
+ assert_raises ArgumentError do
219
+ LibInjection.detect_url_encoded_raw("q=ordinary", 2, true, 0)
220
+ end
221
+ end
222
+
223
+ end