diff --git a/lib/aikido/zen/context.rb b/lib/aikido/zen/context.rb index d6759f6b..0776b934 100644 --- a/lib/aikido/zen/context.rb +++ b/lib/aikido/zen/context.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require "forwardable" +require "base64" +require "json" +require "cgi" require_relative "request" require_relative "payload" @@ -104,10 +107,17 @@ def payload_sources # @!visibility private def extract_payloads_from(data, source_type, prefix = nil) if data.is_a?(String) - [Payload.new(data, source_type, prefix.to_s)] + payloads = [Payload.new(data, source_type, prefix.to_s)] + payloads.concat(url_decoded_payloads(data, source_type, prefix)) + payloads.concat(jwt_payloads(data, source_type, prefix)) + payloads elsif data.respond_to?(:to_hash) data.to_hash.flat_map do |key, value| - extract_payloads_from(value, source_type, [prefix, key].compact.join(".")) + key_prefix = [prefix, key].compact.join(".") + # Emit object keys as scannable payloads so attacks hidden in keys + # (rather than values) are still inspected by the scanners. + key_payloads = key.is_a?(String) ? [Payload.new(key, source_type, [prefix, "__key__"].compact.join("."))] : [] + key_payloads + extract_payloads_from(value, source_type, key_prefix) end elsif data.respond_to?(:to_ary) array = data.to_ary @@ -136,6 +146,61 @@ def extract_payloads_from(data, source_type, prefix = nil) end end + # Emit URL-decoded variants of strings that contain percent-encoding, so + # payloads hidden behind (possibly repeated) URL-encoding are scanned. + def url_decoded_payloads(data, source_type, prefix) + payloads = [] + current = data + 5.times do + break if current.length < 3 || !current.include?("%") + + decoded = begin + CGI.unescape(current) + rescue + nil + end + break if decoded.nil? || decoded == current + + payloads << Payload.new(decoded, source_type, [prefix, "__url_decoded__"].compact.join(".")) + current = decoded + end + payloads + end + + # If the string is a JWT, decode its body and emit the claims as payloads so + # attacks hidden inside JWT claims are scanned. + def jwt_payloads(data, source_type, prefix) + object = decode_jwt(data) + return [] if object.nil? + + # Drop the issuer, which is often a URL/domain and produces false positives. + object.delete("iss") if object.is_a?(Hash) + + extract_payloads_from(object, source_type, [prefix, "__jwt__"].compact.join(".")) + end + + def decode_jwt(token) + parts = token.to_s.split(".") + return nil unless parts.length == 3 + + body = parts[1] + return nil if body.nil? || body.empty? + + decoded = begin + Base64.urlsafe_decode64(body + "=" * (-body.length % 4)) + rescue + return nil + end + + begin + parsed = JSON.parse(decoded) + rescue + return nil + end + + (parsed.is_a?(Hash) || parsed.is_a?(Array)) ? parsed : nil + end + def unsafe_path?(filepath) normalized_filepath = Pathname.new(filepath).cleanpath.to_s.downcase diff --git a/test/aikido/zen/context_extraction_normalization_test.rb b/test/aikido/zen/context_extraction_normalization_test.rb new file mode 100644 index 00000000..8aac9c7d --- /dev/null +++ b/test/aikido/zen/context_extraction_normalization_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "test_helper" +require "base64" +require "json" + +class Aikido::Zen::ContextExtractionNormalizationTest < ActiveSupport::TestCase + setup do + Aikido::Zen.config.request_builder = Aikido::Zen::Context::RACK_REQUEST_BUILDER + Aikido::Zen.config.harden = false + end + + def build_context_for(path, env = {}) + env = Rack::MockRequest.env_for(path, env) + Aikido::Zen::Context.from_rack_env(env) + end + + def values(context) + context.payloads.map { |p| p.value.to_s } + end + + def present?(context, needle) + values(context).any? { |v| v.include?(needle) } + end + + SQLI = "users WHERE id = 1 OR 1=1" + + test "object keys are emitted as scannable payloads" do + context = build_context_for("/path", { + method: "POST", + params: {SQLI => "1"} + }) + + assert present?(context, SQLI), "expected the object key to be extracted as a payload" + end + + test "URL-encoded values are decoded and emitted as payloads" do + context = build_context_for("/path", {"HTTP_X_CUSTOM" => Rack::Utils.escape(SQLI)}) + + assert present?(context, SQLI), "expected the URL-decoded value to be extracted as a payload" + end + + test "JWT claims are decoded and emitted as payloads" do + header = Base64.urlsafe_encode64('{"alg":"HS256","typ":"JWT"}').delete("=") + body = Base64.urlsafe_encode64(JSON.dump({"sub" => SQLI, "role" => "../../etc/passwd"})).delete("=") + jwt = "#{header}.#{body}.signaturesignature" + + context = build_context_for("/path", {"HTTP_AUTHORIZATION" => "Bearer #{jwt}"}) + + assert present?(context, SQLI), "expected the decoded JWT 'sub' claim to be extracted" + assert present?(context, "../../etc/passwd"), "expected the decoded JWT 'role' claim to be extracted" + end + + test "non-JWT strings do not produce spurious payloads" do + context = build_context_for("/path?q=hello") + + refute values(context).any? { |v| v.include?("\u0000") } + end +end