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.
Files changed (85) 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/libzen-v0.1.26.aarch64.dylib +0 -0
  22. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +38 -0
  23. data/lib/aikido/zen/middleware/set_context.rb +26 -0
  24. data/lib/aikido/zen/middleware/throttler.rb +50 -0
  25. data/lib/aikido/zen/outbound_connection.rb +45 -0
  26. data/lib/aikido/zen/outbound_connection_monitor.rb +19 -0
  27. data/lib/aikido/zen/package.rb +22 -0
  28. data/lib/aikido/zen/payload.rb +48 -0
  29. data/lib/aikido/zen/rails_engine.rb +53 -0
  30. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  31. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  32. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  33. data/lib/aikido/zen/rate_limiter.rb +55 -0
  34. data/lib/aikido/zen/request/heuristic_router.rb +109 -0
  35. data/lib/aikido/zen/request/rails_router.rb +84 -0
  36. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  37. data/lib/aikido/zen/request/schema/auth_schemas.rb +40 -0
  38. data/lib/aikido/zen/request/schema/builder.rb +125 -0
  39. data/lib/aikido/zen/request/schema/definition.rb +112 -0
  40. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  41. data/lib/aikido/zen/request/schema.rb +72 -0
  42. data/lib/aikido/zen/request.rb +97 -0
  43. data/lib/aikido/zen/route.rb +39 -0
  44. data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
  45. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  46. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  47. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  48. data/lib/aikido/zen/runtime_settings.rb +70 -0
  49. data/lib/aikido/zen/scan.rb +75 -0
  50. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +95 -0
  51. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  52. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +85 -0
  53. data/lib/aikido/zen/scanners/ssrf_scanner.rb +251 -0
  54. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +43 -0
  55. data/lib/aikido/zen/scanners.rb +5 -0
  56. data/lib/aikido/zen/sink.rb +108 -0
  57. data/lib/aikido/zen/sinks/async_http.rb +63 -0
  58. data/lib/aikido/zen/sinks/curb.rb +89 -0
  59. data/lib/aikido/zen/sinks/em_http.rb +71 -0
  60. data/lib/aikido/zen/sinks/excon.rb +103 -0
  61. data/lib/aikido/zen/sinks/http.rb +76 -0
  62. data/lib/aikido/zen/sinks/httpclient.rb +68 -0
  63. data/lib/aikido/zen/sinks/httpx.rb +61 -0
  64. data/lib/aikido/zen/sinks/mysql2.rb +21 -0
  65. data/lib/aikido/zen/sinks/net_http.rb +85 -0
  66. data/lib/aikido/zen/sinks/patron.rb +88 -0
  67. data/lib/aikido/zen/sinks/pg.rb +50 -0
  68. data/lib/aikido/zen/sinks/resolv.rb +41 -0
  69. data/lib/aikido/zen/sinks/socket.rb +51 -0
  70. data/lib/aikido/zen/sinks/sqlite3.rb +30 -0
  71. data/lib/aikido/zen/sinks/trilogy.rb +21 -0
  72. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  73. data/lib/aikido/zen/sinks.rb +21 -0
  74. data/lib/aikido/zen/stats/routes.rb +53 -0
  75. data/lib/aikido/zen/stats/sink_stats.rb +95 -0
  76. data/lib/aikido/zen/stats/users.rb +26 -0
  77. data/lib/aikido/zen/stats.rb +171 -0
  78. data/lib/aikido/zen/synchronizable.rb +24 -0
  79. data/lib/aikido/zen/system_info.rb +84 -0
  80. data/lib/aikido/zen/version.rb +10 -0
  81. data/lib/aikido/zen.rb +138 -0
  82. data/lib/aikido-zen.rb +3 -0
  83. data/lib/aikido.rb +3 -0
  84. data/tasklib/libzen.rake +128 -0
  85. 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,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)