aikido-zen 0.1.0.alpha4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +674 -0
- data/README.md +40 -0
- data/Rakefile +63 -0
- data/lib/aikido/zen/actor.rb +116 -0
- data/lib/aikido/zen/agent.rb +187 -0
- data/lib/aikido/zen/api_client.rb +132 -0
- data/lib/aikido/zen/attack.rb +138 -0
- data/lib/aikido/zen/capped_collections.rb +68 -0
- data/lib/aikido/zen/config.rb +229 -0
- data/lib/aikido/zen/context/rack_request.rb +24 -0
- data/lib/aikido/zen/context/rails_request.rb +42 -0
- data/lib/aikido/zen/context.rb +101 -0
- data/lib/aikido/zen/errors.rb +88 -0
- data/lib/aikido/zen/event.rb +66 -0
- data/lib/aikido/zen/internals.rb +64 -0
- data/lib/aikido/zen/middleware/check_allowed_addresses.rb +38 -0
- data/lib/aikido/zen/middleware/set_context.rb +26 -0
- data/lib/aikido/zen/middleware/throttler.rb +50 -0
- data/lib/aikido/zen/outbound_connection.rb +45 -0
- data/lib/aikido/zen/outbound_connection_monitor.rb +19 -0
- data/lib/aikido/zen/package.rb +22 -0
- data/lib/aikido/zen/payload.rb +48 -0
- data/lib/aikido/zen/rails_engine.rb +53 -0
- data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
- data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
- data/lib/aikido/zen/rate_limiter/result.rb +31 -0
- data/lib/aikido/zen/rate_limiter.rb +55 -0
- data/lib/aikido/zen/request/heuristic_router.rb +109 -0
- data/lib/aikido/zen/request/rails_router.rb +84 -0
- data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
- data/lib/aikido/zen/request/schema/auth_schemas.rb +40 -0
- data/lib/aikido/zen/request/schema/builder.rb +125 -0
- data/lib/aikido/zen/request/schema/definition.rb +112 -0
- data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
- data/lib/aikido/zen/request/schema.rb +72 -0
- data/lib/aikido/zen/request.rb +97 -0
- data/lib/aikido/zen/route.rb +39 -0
- data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
- data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
- data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
- data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
- data/lib/aikido/zen/runtime_settings.rb +70 -0
- data/lib/aikido/zen/scan.rb +75 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +95 -0
- data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
- data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +85 -0
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +251 -0
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +43 -0
- data/lib/aikido/zen/scanners.rb +5 -0
- data/lib/aikido/zen/sink.rb +108 -0
- data/lib/aikido/zen/sinks/async_http.rb +63 -0
- data/lib/aikido/zen/sinks/curb.rb +89 -0
- data/lib/aikido/zen/sinks/em_http.rb +71 -0
- data/lib/aikido/zen/sinks/excon.rb +103 -0
- data/lib/aikido/zen/sinks/http.rb +76 -0
- data/lib/aikido/zen/sinks/httpclient.rb +68 -0
- data/lib/aikido/zen/sinks/httpx.rb +61 -0
- data/lib/aikido/zen/sinks/mysql2.rb +21 -0
- data/lib/aikido/zen/sinks/net_http.rb +85 -0
- data/lib/aikido/zen/sinks/patron.rb +88 -0
- data/lib/aikido/zen/sinks/pg.rb +50 -0
- data/lib/aikido/zen/sinks/resolv.rb +41 -0
- data/lib/aikido/zen/sinks/socket.rb +51 -0
- data/lib/aikido/zen/sinks/sqlite3.rb +30 -0
- data/lib/aikido/zen/sinks/trilogy.rb +21 -0
- data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
- data/lib/aikido/zen/sinks.rb +21 -0
- data/lib/aikido/zen/stats/routes.rb +53 -0
- data/lib/aikido/zen/stats/sink_stats.rb +95 -0
- data/lib/aikido/zen/stats/users.rb +26 -0
- data/lib/aikido/zen/stats.rb +171 -0
- data/lib/aikido/zen/synchronizable.rb +24 -0
- data/lib/aikido/zen/system_info.rb +84 -0
- data/lib/aikido/zen/version.rb +10 -0
- data/lib/aikido/zen.rb +138 -0
- data/lib/aikido-zen.rb +3 -0
- data/lib/aikido.rb +3 -0
- data/tasklib/libzen.rake +128 -0
- metadata +171 -0
@@ -0,0 +1,251 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "ssrf/private_ip_checker"
|
4
|
+
require_relative "ssrf/dns_lookups"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Scanners
|
8
|
+
class SSRFScanner
|
9
|
+
# Checks if an outbound HTTP request is to a hostname supplied from user
|
10
|
+
# input that resolves to a "dangerous" address. This is called from two
|
11
|
+
# different places:
|
12
|
+
#
|
13
|
+
# * HTTP library sinks, before we make a request. In these cases we can
|
14
|
+
# detect very obvious attempts such as a request that attempts to access
|
15
|
+
# localhost or an internal IP.
|
16
|
+
# * DNS lookup sinks, after we resolve a hostname. For HTTP requests that
|
17
|
+
# are not obviously an attack, we let the DNS resolution happen, and
|
18
|
+
# then check again, now knowing if the domain name provided actually
|
19
|
+
# resolves to an internal IP or not.
|
20
|
+
#
|
21
|
+
# NOTE: Because not all DNS resolutions might be happening in the context
|
22
|
+
# of a protected HTTP request, the +request+ argument below *might* be nil
|
23
|
+
# and we can then skip this scan.
|
24
|
+
#
|
25
|
+
# @param request [Aikido::Zen::Scanners::SSRFScanner::Request, nil]
|
26
|
+
# the ongoing outbound HTTP request.
|
27
|
+
# @param context [Aikido::Zen::Context]
|
28
|
+
# @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
|
29
|
+
# @param operation [Symbol, String] name of the method being scanned.
|
30
|
+
# Expects +sink.operation+ being set to get the full module/name combo.
|
31
|
+
#
|
32
|
+
# @return [Aikido::Zen::Attacks::SSRFAttack, nil] an Attack if any user
|
33
|
+
# input is detected to be attempting SSRF, or +nil+ if not.
|
34
|
+
def self.call(request:, sink:, context:, operation:, **)
|
35
|
+
return if context.nil?
|
36
|
+
return if request.nil? # See NOTE above.
|
37
|
+
|
38
|
+
context["ssrf.redirects"] ||= RedirectChains.new
|
39
|
+
|
40
|
+
context.payloads.each do |payload|
|
41
|
+
scanner = new(request.uri, payload.value, context["ssrf.redirects"])
|
42
|
+
next unless scanner.attack?
|
43
|
+
|
44
|
+
attack = Attacks::SSRFAttack.new(
|
45
|
+
sink: sink,
|
46
|
+
request: request,
|
47
|
+
input: payload,
|
48
|
+
context: context,
|
49
|
+
operation: "#{sink.operation}.#{operation}"
|
50
|
+
)
|
51
|
+
|
52
|
+
return attack
|
53
|
+
end
|
54
|
+
|
55
|
+
nil
|
56
|
+
end
|
57
|
+
|
58
|
+
# Track the origin of a redirection so we know if an attacker is using
|
59
|
+
# redirect chains to mask their use of a (seemingly) safe domain.
|
60
|
+
#
|
61
|
+
# @param request [Aikido::Zen::Scanners::SSRFScanner::Request]
|
62
|
+
# @param response [Aikido::Zen::Scanners::SSRFScanner::Response]
|
63
|
+
# @param context [Aikido::Zen::Context]
|
64
|
+
#
|
65
|
+
# @return [void]
|
66
|
+
def self.track_redirects(request:, response:, context: Aikido::Zen.current_context)
|
67
|
+
return unless response.redirect?
|
68
|
+
|
69
|
+
context["ssrf.redirects"] ||= RedirectChains.new
|
70
|
+
context["ssrf.redirects"].add(
|
71
|
+
source: request.uri,
|
72
|
+
destination: response.redirect_to
|
73
|
+
)
|
74
|
+
end
|
75
|
+
|
76
|
+
# @api private
|
77
|
+
def initialize(request_uri, input, redirects)
|
78
|
+
@request_uri = request_uri
|
79
|
+
@input = input
|
80
|
+
@redirects = redirects
|
81
|
+
end
|
82
|
+
|
83
|
+
# @api private
|
84
|
+
def attack?
|
85
|
+
return false if @input.nil? || @input.to_s.empty?
|
86
|
+
|
87
|
+
# If the request is not aimed at an internal IP, we can ignore it. (It
|
88
|
+
# might still be an SSRF if defined strictly, but it's unlikely to be
|
89
|
+
# exfiltrating data from the app's servers, and the risk for false
|
90
|
+
# positives is too high.)
|
91
|
+
return false unless private_ip?(@request_uri.hostname)
|
92
|
+
|
93
|
+
origins_for_request
|
94
|
+
.product(uris_from_input)
|
95
|
+
.any? { |(conn_uri, candidate)| match?(conn_uri, candidate) }
|
96
|
+
end
|
97
|
+
|
98
|
+
# @!visibility private
|
99
|
+
def self.private_ip_checker
|
100
|
+
@private_ip_checker ||= SSRF::PrivateIPChecker.new
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def match?(conn_uri, input_uri)
|
106
|
+
return false if conn_uri.hostname.nil? || conn_uri.hostname.empty?
|
107
|
+
return false if input_uri.hostname.nil? || input_uri.hostname.empty?
|
108
|
+
|
109
|
+
# The URI library will automatically set the port to the default port
|
110
|
+
# for the current scheme if not provided, which means we can't just
|
111
|
+
# check if the port is present, as it always will be.
|
112
|
+
is_port_relevant = input_uri.port != input_uri.default_port
|
113
|
+
return false if is_port_relevant && input_uri.port != conn_uri.port
|
114
|
+
|
115
|
+
conn_uri.hostname == input_uri.hostname
|
116
|
+
end
|
117
|
+
|
118
|
+
def private_ip?(hostname)
|
119
|
+
self.class.private_ip_checker.private?(hostname)
|
120
|
+
end
|
121
|
+
|
122
|
+
def origins_for_request
|
123
|
+
[@request_uri, @redirects.origin(@request_uri)].compact
|
124
|
+
end
|
125
|
+
|
126
|
+
# Maps the current user input into a Set of URIs we can check against:
|
127
|
+
#
|
128
|
+
# * The input itself, if it already looks like a URI.
|
129
|
+
# * The input prefixed with http://
|
130
|
+
# * The input prefixed with https://
|
131
|
+
#
|
132
|
+
# @return [Set<URI>]
|
133
|
+
def uris_from_input
|
134
|
+
input = @input.to_s
|
135
|
+
|
136
|
+
# If you build a URI manually and set the hostname to an IPv6 string,
|
137
|
+
# the URI library will be helpful to wrap it in brackets so it's a
|
138
|
+
# valid hostname. We should do the same for the input.
|
139
|
+
input = format("[%s]", input) if unescaped_ipv6?(input)
|
140
|
+
|
141
|
+
[input, "http://#{input}", "https://#{input}"]
|
142
|
+
.map { |candidate| as_uri(candidate) }
|
143
|
+
.compact
|
144
|
+
.uniq
|
145
|
+
end
|
146
|
+
|
147
|
+
def as_uri(string)
|
148
|
+
URI(string)
|
149
|
+
rescue URI::InvalidURIError
|
150
|
+
nil
|
151
|
+
end
|
152
|
+
|
153
|
+
# Check if the input is an IPv6 that is not surrounded by square brackets.
|
154
|
+
def unescaped_ipv6?(input)
|
155
|
+
(
|
156
|
+
IPAddr::RE_IPV6ADDRLIKE_FULL.match?(input) ||
|
157
|
+
IPAddr::RE_IPV6ADDRLIKE_COMPRESSED.match?(input)
|
158
|
+
) && !(input.start_with?("[") && input.end_with?("]"))
|
159
|
+
end
|
160
|
+
|
161
|
+
# @api private
|
162
|
+
module Headers
|
163
|
+
# @param headers [Hash<String, Object>]
|
164
|
+
# @param header_normalizer [Proc{Object => String}]
|
165
|
+
def initialize(headers:, header_normalizer: :to_s.to_proc)
|
166
|
+
@headers = headers.to_h
|
167
|
+
@header_normalizer = header_normalizer
|
168
|
+
@normalized_headers = false
|
169
|
+
end
|
170
|
+
|
171
|
+
# @return [Hash<String, String>]
|
172
|
+
def headers
|
173
|
+
return @headers if @normalized_headers
|
174
|
+
|
175
|
+
@headers
|
176
|
+
.transform_keys!(&:downcase)
|
177
|
+
.transform_values!(&@header_normalizer)
|
178
|
+
.tap { @normalized_headers = true }
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# @api private
|
183
|
+
class Request
|
184
|
+
include Headers
|
185
|
+
|
186
|
+
attr_reader :verb
|
187
|
+
attr_reader :uri
|
188
|
+
|
189
|
+
def initialize(verb:, uri:, **header_options)
|
190
|
+
super(**header_options)
|
191
|
+
@verb = verb.to_s.upcase
|
192
|
+
@uri = URI(uri)
|
193
|
+
end
|
194
|
+
|
195
|
+
def to_s
|
196
|
+
[@verb, @uri.to_s].join(" ").strip
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# @api private
|
201
|
+
class Response
|
202
|
+
include Headers
|
203
|
+
|
204
|
+
attr_reader :status
|
205
|
+
|
206
|
+
def initialize(status:, **header_options)
|
207
|
+
super(**header_options)
|
208
|
+
@status = status.to_s
|
209
|
+
end
|
210
|
+
|
211
|
+
def redirect?
|
212
|
+
@status.start_with?("3") && headers["location"]
|
213
|
+
end
|
214
|
+
|
215
|
+
def redirect_to
|
216
|
+
URI(headers["location"]) if redirect?
|
217
|
+
rescue URI::BadURIError
|
218
|
+
nil
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# @api private
|
223
|
+
class RedirectChains
|
224
|
+
def initialize
|
225
|
+
@redirects = {}
|
226
|
+
end
|
227
|
+
|
228
|
+
def add(source:, destination:)
|
229
|
+
@redirects[destination] = source
|
230
|
+
self
|
231
|
+
end
|
232
|
+
|
233
|
+
# Recursively looks for the original URI that triggered the current
|
234
|
+
# chain. If given a URI that was not the result of a redirect chain, it
|
235
|
+
# returns +nil+
|
236
|
+
#
|
237
|
+
# @param uri [URI]
|
238
|
+
# @return [URI, nil]
|
239
|
+
def origin(uri)
|
240
|
+
source = @redirects[uri]
|
241
|
+
|
242
|
+
if @redirects[source]
|
243
|
+
origin(source)
|
244
|
+
else
|
245
|
+
source
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
module Scanners
|
5
|
+
# Inspects the result of DNS lookups, to determine if we're being the target
|
6
|
+
# of a stored SSRF targeting IMDS addresses (169.254.169.254).
|
7
|
+
class StoredSSRFScanner
|
8
|
+
def self.call(hostname:, addresses:, operation:, sink:, context:, **opts)
|
9
|
+
offending_address = new(hostname, addresses).attack?
|
10
|
+
return if offending_address.nil?
|
11
|
+
|
12
|
+
Attacks::StoredSSRFAttack.new(
|
13
|
+
hostname: hostname,
|
14
|
+
address: offending_address,
|
15
|
+
sink: sink,
|
16
|
+
context: context,
|
17
|
+
operation: "#{sink.operation}.#{operation}"
|
18
|
+
)
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(hostname, addresses, config: Aikido::Zen.config)
|
22
|
+
@hostname = hostname
|
23
|
+
@addresses = addresses
|
24
|
+
@config = config
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [String, nil] either the offending address, or +nil+ if no
|
28
|
+
# address is deemed dangerous.
|
29
|
+
def attack?
|
30
|
+
return false if @config.imds_allowed_hosts.include?(@hostname)
|
31
|
+
|
32
|
+
@addresses.find do |candidate|
|
33
|
+
DANGEROUS_ADDRESSES.any? { |address| address === candidate }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
DANGEROUS_ADDRESSES = [
|
38
|
+
IPAddr.new("169.254.169.254"),
|
39
|
+
IPAddr.new("fd00:ec2::254")
|
40
|
+
]
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "scan"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Sinks
|
7
|
+
# @api internal
|
8
|
+
# @return [Hash<String, Sink>]
|
9
|
+
def self.registry
|
10
|
+
@registry ||= {}
|
11
|
+
end
|
12
|
+
|
13
|
+
# Primary interface to set up a sink with a list of given scanners.
|
14
|
+
#
|
15
|
+
# @param name [String] name of the library being patched. (This must
|
16
|
+
# match the name of the gem, or we won't report that gem as
|
17
|
+
# supported.)
|
18
|
+
# @param scanners [Array<#call>] a list of objects that respond to
|
19
|
+
# #call with a Hash and return an Attack or nil.
|
20
|
+
# @param opts [Hash<Symbol, Object>] any other options to pass to
|
21
|
+
# the Sink initializer.
|
22
|
+
#
|
23
|
+
# @return [void]
|
24
|
+
# @raise [ArgumentError] if a Sink with this name has already been
|
25
|
+
# registered.
|
26
|
+
def self.add(name, scanners:, **opts)
|
27
|
+
raise ArgumentError, "Sink #{name} already registered" if registry.key?(name.to_s)
|
28
|
+
registry[name.to_s] = Sink.new(name.to_s, scanners: scanners, **opts)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Sinks serve as the proxies between a given library that we protect
|
33
|
+
# (such as a database adapter that we patch to prevent SQL injections)
|
34
|
+
# and the reporting agent.
|
35
|
+
#
|
36
|
+
# When a library is patched to track and potentially block attacks, we
|
37
|
+
# rely on a sink to run any scans required, and report any attacks to
|
38
|
+
# our agent.
|
39
|
+
#
|
40
|
+
# @see ./sinks/trilogy.rb for a reference implementation.
|
41
|
+
class Sink
|
42
|
+
# @return [String] name of the patched library (e.g. "mysql2").
|
43
|
+
attr_reader :name
|
44
|
+
|
45
|
+
# @return [Array<#call>] list of registered scanners for this sink.
|
46
|
+
attr_reader :scanners
|
47
|
+
|
48
|
+
# @return [String] descriptor of the module / method being scanned
|
49
|
+
# for attacks. This is fed into Attacks when instantiated. In
|
50
|
+
# certain cases, some scanners allow you to specialize this by
|
51
|
+
# using an +operation+ param of their own.
|
52
|
+
attr_reader :operation
|
53
|
+
|
54
|
+
DEFAULT_REPORTER = ->(scan) { Aikido::Zen.track_scan(scan) }
|
55
|
+
|
56
|
+
def initialize(name, scanners:, operation: name, reporter: DEFAULT_REPORTER)
|
57
|
+
raise ArgumentError, "scanners cannot be empty" if scanners.empty?
|
58
|
+
|
59
|
+
@name = name
|
60
|
+
@operation = operation
|
61
|
+
@scanners = scanners
|
62
|
+
@reporter = reporter
|
63
|
+
end
|
64
|
+
|
65
|
+
# Run the given arguments through all the registered scanners, until
|
66
|
+
# one of them returns an Attack or all return +nil+, and report the
|
67
|
+
# findings back to the Sink's +reporter+ to track statistics and
|
68
|
+
# potentially handle the +Attack+, if anything.
|
69
|
+
#
|
70
|
+
# This checks if runtime protection has been turned off for the current
|
71
|
+
# route first, and if so skips the scanning altogether, returning nil.
|
72
|
+
#
|
73
|
+
# @param scan_params [Hash] data to pass to all registered scanners.
|
74
|
+
# @option scan_params [Aikido::Zen::Context, nil] :context
|
75
|
+
# The current Context, including the HTTP request being inspected, or
|
76
|
+
# +nil+ if we're scanning outside of an HTTP request.
|
77
|
+
#
|
78
|
+
# @return [Aikido::Zen::Scan, nil] the result of the scan, or +nil+ if the
|
79
|
+
# scan was skipped due to protection being disabled for the current route.
|
80
|
+
# @raise [Aikido::UnderAttackError] if an attack is detected and
|
81
|
+
# blocking_mode is enabled.
|
82
|
+
def scan(context: Aikido::Zen.current_context, **scan_params)
|
83
|
+
return if context&.protection_disabled?
|
84
|
+
|
85
|
+
scan = Scan.new(sink: self, context: context)
|
86
|
+
|
87
|
+
scan.perform do
|
88
|
+
result = nil
|
89
|
+
|
90
|
+
scanners.each do |scanner|
|
91
|
+
result = scanner.call(sink: self, context: context, **scan_params)
|
92
|
+
break result if result
|
93
|
+
rescue Aikido::Zen::InternalsError => error
|
94
|
+
Aikido::Zen.config.logger.warn(error.message)
|
95
|
+
scan.track_error(error, scanner)
|
96
|
+
rescue => error
|
97
|
+
scan.track_error(error, scanner)
|
98
|
+
end
|
99
|
+
|
100
|
+
result
|
101
|
+
end
|
102
|
+
|
103
|
+
@reporter.call(scan)
|
104
|
+
|
105
|
+
scan
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../sink"
|
4
|
+
require_relative "../outbound_connection_monitor"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Sinks
|
8
|
+
module Async
|
9
|
+
module HTTP
|
10
|
+
SINK = Sinks.add("async-http", scanners: [
|
11
|
+
Aikido::Zen::Scanners::SSRFScanner,
|
12
|
+
Aikido::Zen::OutboundConnectionMonitor
|
13
|
+
])
|
14
|
+
|
15
|
+
module Extensions
|
16
|
+
def call(request)
|
17
|
+
uri = URI(format("%<scheme>s://%<authority>s%<path>s", {
|
18
|
+
scheme: request.scheme || scheme,
|
19
|
+
authority: request.authority || authority,
|
20
|
+
path: request.path
|
21
|
+
}))
|
22
|
+
|
23
|
+
wrapped_request = Aikido::Zen::Scanners::SSRFScanner::Request.new(
|
24
|
+
verb: request.method,
|
25
|
+
uri: uri,
|
26
|
+
headers: request.headers.to_h,
|
27
|
+
header_normalizer: ->(value) { Array(value).join(", ") }
|
28
|
+
)
|
29
|
+
|
30
|
+
# Store the request information so the DNS sinks can pick it up.
|
31
|
+
if (context = Aikido::Zen.current_context)
|
32
|
+
prev_request = context["ssrf.request"]
|
33
|
+
context["ssrf.request"] = wrapped_request
|
34
|
+
end
|
35
|
+
|
36
|
+
SINK.scan(
|
37
|
+
connection: Aikido::Zen::OutboundConnection.from_uri(uri),
|
38
|
+
request: wrapped_request,
|
39
|
+
operation: "request"
|
40
|
+
)
|
41
|
+
|
42
|
+
response = super
|
43
|
+
|
44
|
+
Aikido::Zen::Scanners::SSRFScanner.track_redirects(
|
45
|
+
request: wrapped_request,
|
46
|
+
response: Aikido::Zen::Scanners::SSRFScanner::Response.new(
|
47
|
+
status: response.status,
|
48
|
+
headers: response.headers.to_h,
|
49
|
+
header_normalizer: ->(value) { Array(value).join(", ") }
|
50
|
+
)
|
51
|
+
)
|
52
|
+
|
53
|
+
response
|
54
|
+
ensure
|
55
|
+
context["ssrf.request"] = prev_request if context
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
::Async::HTTP::Client.prepend(Aikido::Zen::Sinks::Async::HTTP::Extensions)
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../sink"
|
4
|
+
require_relative "../outbound_connection_monitor"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Sinks
|
8
|
+
module Curl
|
9
|
+
SINK = Sinks.add("curb", scanners: [
|
10
|
+
Aikido::Zen::Scanners::SSRFScanner,
|
11
|
+
Aikido::Zen::OutboundConnectionMonitor
|
12
|
+
])
|
13
|
+
|
14
|
+
module Extensions
|
15
|
+
def self.wrap_request(curl, url: curl.url)
|
16
|
+
Aikido::Zen::Scanners::SSRFScanner::Request.new(
|
17
|
+
verb: nil, # Curb hides this by directly setting an option in C
|
18
|
+
uri: URI(url),
|
19
|
+
headers: curl.headers
|
20
|
+
)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.wrap_response(curl)
|
24
|
+
# Curb made an… interesting choice by not parsing the response headers
|
25
|
+
# and forcing users to do this manually if they need to look at them.
|
26
|
+
_, *headers = curl.header_str.split(/[\r\n]+/).map(&:strip)
|
27
|
+
headers = headers.flat_map { |str| str.scan(/\A(\S+): (.+)\z/) }.to_h
|
28
|
+
|
29
|
+
if curl.url != curl.last_effective_url
|
30
|
+
status = 302 # We can't know what the original status was, but we just need a 3XX
|
31
|
+
headers["Location"] = curl.last_effective_url
|
32
|
+
else
|
33
|
+
status = curl.status.to_i
|
34
|
+
end
|
35
|
+
|
36
|
+
Aikido::Zen::Scanners::SSRFScanner::Response.new(status: status, headers: headers)
|
37
|
+
end
|
38
|
+
|
39
|
+
def perform
|
40
|
+
wrapped_request = Extensions.wrap_request(self)
|
41
|
+
|
42
|
+
# Store the request information so the DNS sinks can pick it up.
|
43
|
+
if (context = Aikido::Zen.current_context)
|
44
|
+
prev_request = context["ssrf.request"]
|
45
|
+
context["ssrf.request"] = wrapped_request
|
46
|
+
end
|
47
|
+
|
48
|
+
SINK.scan(
|
49
|
+
connection: Aikido::Zen::OutboundConnection.from_uri(URI(url)),
|
50
|
+
request: wrapped_request,
|
51
|
+
operation: "request"
|
52
|
+
)
|
53
|
+
|
54
|
+
response = super
|
55
|
+
|
56
|
+
Aikido::Zen::Scanners::SSRFScanner.track_redirects(
|
57
|
+
request: wrapped_request,
|
58
|
+
response: Extensions.wrap_response(self)
|
59
|
+
)
|
60
|
+
|
61
|
+
# When libcurl has follow_location set, it will handle redirections
|
62
|
+
# internally, and expose the "last_effective_url" as the URI that was
|
63
|
+
# last requested in the redirect chain.
|
64
|
+
#
|
65
|
+
# In this case, we can't actually stop the request from happening, but
|
66
|
+
# we can scan again (now that we know another request happened), to
|
67
|
+
# stop the response from being exposed to the user. This downgrades
|
68
|
+
# the SSRF into a blind SSRF, which is better than doing nothing.
|
69
|
+
if url != last_effective_url
|
70
|
+
last_effective_request = Extensions.wrap_request(self, url: last_effective_url)
|
71
|
+
context["ssrf.request"] = last_effective_request if context
|
72
|
+
|
73
|
+
SINK.scan(
|
74
|
+
connection: Aikido::Zen::OutboundConnection.from_uri(URI(last_effective_url)),
|
75
|
+
request: last_effective_request,
|
76
|
+
operation: "request"
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
response
|
81
|
+
ensure
|
82
|
+
context["ssrf.request"] = prev_request if context
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
::Curl::Easy.prepend(Aikido::Zen::Sinks::Curl::Extensions)
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../sink"
|
4
|
+
require_relative "../outbound_connection_monitor"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Sinks
|
8
|
+
module EventMachine
|
9
|
+
module HttpRequest
|
10
|
+
SINK = Sinks.add("em-http-request", scanners: [
|
11
|
+
Aikido::Zen::Scanners::SSRFScanner,
|
12
|
+
Aikido::Zen::OutboundConnectionMonitor
|
13
|
+
])
|
14
|
+
|
15
|
+
module Extensions
|
16
|
+
def send_request(*)
|
17
|
+
wrapped_request = Aikido::Zen::Scanners::SSRFScanner::Request.new(
|
18
|
+
verb: req.method.to_s,
|
19
|
+
uri: URI(req.uri),
|
20
|
+
headers: req.headers
|
21
|
+
)
|
22
|
+
|
23
|
+
# Store the request information so the DNS sinks can pick it up.
|
24
|
+
context = Aikido::Zen.current_context
|
25
|
+
context["ssrf.request"] = wrapped_request if context
|
26
|
+
|
27
|
+
SINK.scan(
|
28
|
+
connection: Aikido::Zen::OutboundConnection.new(
|
29
|
+
host: req.host,
|
30
|
+
port: req.port
|
31
|
+
),
|
32
|
+
request: wrapped_request,
|
33
|
+
operation: "request"
|
34
|
+
)
|
35
|
+
|
36
|
+
super
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class Middleware
|
41
|
+
def response(client)
|
42
|
+
# Store the request information so the DNS sinks can pick it up.
|
43
|
+
context = Aikido::Zen.current_context
|
44
|
+
context["ssrf.request"] = nil if context
|
45
|
+
|
46
|
+
Aikido::Zen::Scanners::SSRFScanner.track_redirects(
|
47
|
+
request: Aikido::Zen::Scanners::SSRFScanner::Request.new(
|
48
|
+
verb: client.req.method,
|
49
|
+
uri: URI(client.req.uri),
|
50
|
+
headers: client.req.headers
|
51
|
+
),
|
52
|
+
response: Aikido::Zen::Scanners::SSRFScanner::Response.new(
|
53
|
+
status: client.response_header.status,
|
54
|
+
headers: client.response_header.to_h
|
55
|
+
)
|
56
|
+
)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
::EventMachine::HttpRequest
|
65
|
+
.use(Aikido::Zen::Sinks::EventMachine::HttpRequest::Middleware)
|
66
|
+
|
67
|
+
# NOTE: We can't use middleware to intercept requests as we want to ensure any
|
68
|
+
# modifications to the request from user-supplied middleware are already applied
|
69
|
+
# before we scan the request.
|
70
|
+
::EventMachine::HttpClient
|
71
|
+
.prepend(Aikido::Zen::Sinks::EventMachine::HttpRequest::Extensions)
|