aikido-zen 0.1.0.alpha4-arm64-darwin
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.
- 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/libzen-v0.1.26.aarch64.dylib +0 -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 +175 -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)
|