aikido-zen 0.1.0.alpha4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/.standard.yml +3 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE +674 -0
  7. data/README.md +40 -0
  8. data/Rakefile +63 -0
  9. data/lib/aikido/zen/actor.rb +116 -0
  10. data/lib/aikido/zen/agent.rb +187 -0
  11. data/lib/aikido/zen/api_client.rb +132 -0
  12. data/lib/aikido/zen/attack.rb +138 -0
  13. data/lib/aikido/zen/capped_collections.rb +68 -0
  14. data/lib/aikido/zen/config.rb +229 -0
  15. data/lib/aikido/zen/context/rack_request.rb +24 -0
  16. data/lib/aikido/zen/context/rails_request.rb +42 -0
  17. data/lib/aikido/zen/context.rb +101 -0
  18. data/lib/aikido/zen/errors.rb +88 -0
  19. data/lib/aikido/zen/event.rb +66 -0
  20. data/lib/aikido/zen/internals.rb +64 -0
  21. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +38 -0
  22. data/lib/aikido/zen/middleware/set_context.rb +26 -0
  23. data/lib/aikido/zen/middleware/throttler.rb +50 -0
  24. data/lib/aikido/zen/outbound_connection.rb +45 -0
  25. data/lib/aikido/zen/outbound_connection_monitor.rb +19 -0
  26. data/lib/aikido/zen/package.rb +22 -0
  27. data/lib/aikido/zen/payload.rb +48 -0
  28. data/lib/aikido/zen/rails_engine.rb +53 -0
  29. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  30. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  31. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  32. data/lib/aikido/zen/rate_limiter.rb +55 -0
  33. data/lib/aikido/zen/request/heuristic_router.rb +109 -0
  34. data/lib/aikido/zen/request/rails_router.rb +84 -0
  35. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  36. data/lib/aikido/zen/request/schema/auth_schemas.rb +40 -0
  37. data/lib/aikido/zen/request/schema/builder.rb +125 -0
  38. data/lib/aikido/zen/request/schema/definition.rb +112 -0
  39. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  40. data/lib/aikido/zen/request/schema.rb +72 -0
  41. data/lib/aikido/zen/request.rb +97 -0
  42. data/lib/aikido/zen/route.rb +39 -0
  43. data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
  44. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  45. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  46. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  47. data/lib/aikido/zen/runtime_settings.rb +70 -0
  48. data/lib/aikido/zen/scan.rb +75 -0
  49. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +95 -0
  50. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  51. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +85 -0
  52. data/lib/aikido/zen/scanners/ssrf_scanner.rb +251 -0
  53. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +43 -0
  54. data/lib/aikido/zen/scanners.rb +5 -0
  55. data/lib/aikido/zen/sink.rb +108 -0
  56. data/lib/aikido/zen/sinks/async_http.rb +63 -0
  57. data/lib/aikido/zen/sinks/curb.rb +89 -0
  58. data/lib/aikido/zen/sinks/em_http.rb +71 -0
  59. data/lib/aikido/zen/sinks/excon.rb +103 -0
  60. data/lib/aikido/zen/sinks/http.rb +76 -0
  61. data/lib/aikido/zen/sinks/httpclient.rb +68 -0
  62. data/lib/aikido/zen/sinks/httpx.rb +61 -0
  63. data/lib/aikido/zen/sinks/mysql2.rb +21 -0
  64. data/lib/aikido/zen/sinks/net_http.rb +85 -0
  65. data/lib/aikido/zen/sinks/patron.rb +88 -0
  66. data/lib/aikido/zen/sinks/pg.rb +50 -0
  67. data/lib/aikido/zen/sinks/resolv.rb +41 -0
  68. data/lib/aikido/zen/sinks/socket.rb +51 -0
  69. data/lib/aikido/zen/sinks/sqlite3.rb +30 -0
  70. data/lib/aikido/zen/sinks/trilogy.rb +21 -0
  71. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  72. data/lib/aikido/zen/sinks.rb +21 -0
  73. data/lib/aikido/zen/stats/routes.rb +53 -0
  74. data/lib/aikido/zen/stats/sink_stats.rb +95 -0
  75. data/lib/aikido/zen/stats/users.rb +26 -0
  76. data/lib/aikido/zen/stats.rb +171 -0
  77. data/lib/aikido/zen/synchronizable.rb +24 -0
  78. data/lib/aikido/zen/system_info.rb +84 -0
  79. data/lib/aikido/zen/version.rb +10 -0
  80. data/lib/aikido/zen.rb +138 -0
  81. data/lib/aikido-zen.rb +3 -0
  82. data/lib/aikido.rb +3 -0
  83. data/tasklib/libzen.rake +128 -0
  84. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scanners/sql_injection_scanner"
4
+ require_relative "scanners/stored_ssrf_scanner"
5
+ require_relative "scanners/ssrf_scanner"
@@ -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)