Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions lib/aikido/zen/context.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# frozen_string_literal: true

require "forwardable"
require "base64"
require "json"
require "cgi"

require_relative "request"
require_relative "payload"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
59 changes: 59 additions & 0 deletions test/aikido/zen/context_extraction_normalization_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading