aikido-zen 0.1.0.alpha4-x86_64-darwin
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/libzen-v0.1.26.x86_64.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,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../context"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Middleware
|
7
|
+
# Middleware that rejects requests from IPs blocked in the Aikido dashboard.
|
8
|
+
class CheckAllowedAddresses
|
9
|
+
def initialize(app, config: Aikido::Zen.config, settings: Aikido::Zen.runtime_settings)
|
10
|
+
@app = app
|
11
|
+
@config = config
|
12
|
+
@settings = settings
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(env)
|
16
|
+
request = request_from(env)
|
17
|
+
|
18
|
+
allowed_ips = @settings.endpoints[request.route].allowed_ips
|
19
|
+
|
20
|
+
if allowed_ips.empty? || allowed_ips.include?(request.ip)
|
21
|
+
@app.call(env)
|
22
|
+
else
|
23
|
+
@config.blocked_ip_responder.call(request)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def request_from(env)
|
30
|
+
if (current_context = Aikido::Zen.current_context)
|
31
|
+
current_context.request
|
32
|
+
else
|
33
|
+
Context.from_rack_env(env).request
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../context"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Middleware
|
7
|
+
# Rack middleware that keeps the current context in a Thread/Fiber-local
|
8
|
+
# variable so that other parts of the agent/firewall can access it.
|
9
|
+
class SetContext
|
10
|
+
def initialize(app)
|
11
|
+
@app = app
|
12
|
+
end
|
13
|
+
|
14
|
+
def call(env)
|
15
|
+
context = Context.from_rack_env(env)
|
16
|
+
|
17
|
+
Aikido::Zen.current_context = context
|
18
|
+
Aikido::Zen.track_request(context.request)
|
19
|
+
|
20
|
+
@app.call(env)
|
21
|
+
ensure
|
22
|
+
Aikido::Zen.current_context = nil
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../context"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Middleware
|
7
|
+
# Middleware that rejects requests from clients that are making too many
|
8
|
+
# requests to a given endpoint, based in the runtime configuration in the
|
9
|
+
# Aikido dashboard.
|
10
|
+
class Throttler
|
11
|
+
def initialize(
|
12
|
+
app,
|
13
|
+
config: Aikido::Zen.config,
|
14
|
+
settings: Aikido::Zen.runtime_settings,
|
15
|
+
rate_limiter: Aikido::Zen::RateLimiter.new
|
16
|
+
)
|
17
|
+
@app = app
|
18
|
+
@config = config
|
19
|
+
@settings = settings
|
20
|
+
@rate_limiter = rate_limiter
|
21
|
+
end
|
22
|
+
|
23
|
+
def call(env)
|
24
|
+
request = request_from(env)
|
25
|
+
|
26
|
+
if should_throttle?(request)
|
27
|
+
@config.rate_limited_responder.call(request)
|
28
|
+
else
|
29
|
+
@app.call(env)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def should_throttle?(request)
|
36
|
+
return false if @settings.skip_protection_for_ips.include?(request.ip)
|
37
|
+
|
38
|
+
@rate_limiter.throttle?(request)
|
39
|
+
end
|
40
|
+
|
41
|
+
def request_from(env)
|
42
|
+
if (current_context = Aikido::Zen.current_context)
|
43
|
+
current_context.request
|
44
|
+
else
|
45
|
+
Context.from_rack_env(env).request
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
# Simple data object to identify connections performed to outbound servers.
|
5
|
+
class OutboundConnection
|
6
|
+
# Convenience factory to create connection descriptions out of URI objects.
|
7
|
+
#
|
8
|
+
# @param uri [URI]
|
9
|
+
# @return [Aikido::Zen::OutboundConnection]
|
10
|
+
def self.from_uri(uri)
|
11
|
+
new(host: uri.hostname, port: uri.port)
|
12
|
+
end
|
13
|
+
|
14
|
+
# @return [String] the hostname or IP address to which the connection was
|
15
|
+
# attempted.
|
16
|
+
attr_reader :host
|
17
|
+
|
18
|
+
# @return [Integer] the port number to which the connection was attempted.
|
19
|
+
attr_reader :port
|
20
|
+
|
21
|
+
def initialize(host:, port:)
|
22
|
+
@host = host
|
23
|
+
@port = port
|
24
|
+
end
|
25
|
+
|
26
|
+
def as_json
|
27
|
+
{hostname: host, port: port}
|
28
|
+
end
|
29
|
+
|
30
|
+
def ==(other)
|
31
|
+
other.is_a?(OutboundConnection) &&
|
32
|
+
host == other.host &&
|
33
|
+
port == other.port
|
34
|
+
end
|
35
|
+
alias_method :eql?, :==
|
36
|
+
|
37
|
+
def hash
|
38
|
+
[host, port].hash
|
39
|
+
end
|
40
|
+
|
41
|
+
def inspect
|
42
|
+
"#<#{self.class.name} #{host}:#{port}>"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
# This simple callable follows the Scanner API so that it can be injected into
|
5
|
+
# any Sink that wraps an HTTP library, and lets us keep track of any hosts to
|
6
|
+
# which the app communicates over HTTP.
|
7
|
+
module OutboundConnectionMonitor
|
8
|
+
# This simply reports the connection to the Agent, and always returns +nil+
|
9
|
+
# as it's not scanning for any particular attack.
|
10
|
+
#
|
11
|
+
# @param connection [Aikido::Zen::OutboundConnection]
|
12
|
+
# @return [nil]
|
13
|
+
def self.call(connection:, **)
|
14
|
+
Aikido::Zen.track_outbound(connection)
|
15
|
+
|
16
|
+
nil
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "sink"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
Package = Struct.new(:name, :version) do
|
7
|
+
def initialize(name, version, sinks = Aikido::Zen::Sinks.registry)
|
8
|
+
super(name, version)
|
9
|
+
@sinks = sinks
|
10
|
+
end
|
11
|
+
|
12
|
+
# @return [Boolean] whether we explicitly protect against exploits in this
|
13
|
+
# library.
|
14
|
+
def supported?
|
15
|
+
@sinks.include?(name)
|
16
|
+
end
|
17
|
+
|
18
|
+
def as_json
|
19
|
+
{name => version.to_s}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
# An individual user input in a request, which may come from different
|
5
|
+
# sources (query string, body, cookies, etc).
|
6
|
+
class Payload
|
7
|
+
attr_reader :value, :source, :path
|
8
|
+
|
9
|
+
def initialize(value, source, path)
|
10
|
+
@value = value
|
11
|
+
@source = source
|
12
|
+
@path = path
|
13
|
+
end
|
14
|
+
|
15
|
+
alias_method :to_s, :value
|
16
|
+
|
17
|
+
def ==(other)
|
18
|
+
other.is_a?(Payload) &&
|
19
|
+
other.value == value &&
|
20
|
+
other.source == source &&
|
21
|
+
other.path == path
|
22
|
+
end
|
23
|
+
|
24
|
+
def as_json
|
25
|
+
{
|
26
|
+
payload: value.to_s,
|
27
|
+
source: SOURCE_SERIALIZATIONS[source],
|
28
|
+
pathToPayload: path.to_s
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
SOURCE_SERIALIZATIONS = {
|
33
|
+
query: "query",
|
34
|
+
body: "body",
|
35
|
+
header: "headers",
|
36
|
+
cookie: "cookies",
|
37
|
+
route: "routeParams",
|
38
|
+
graphql: "graphql",
|
39
|
+
xml: "xml",
|
40
|
+
subdomain: "subdomains"
|
41
|
+
}
|
42
|
+
|
43
|
+
def inspect
|
44
|
+
val = (value.to_s.size > 128) ? value[0..125] + "..." : value
|
45
|
+
"#<Aikido::Zen::Payload #{source}(#{path}) #{val.inspect}>"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action_dispatch"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
class RailsEngine < ::Rails::Engine
|
7
|
+
config.before_configuration do
|
8
|
+
# Access library configuration at `Rails.application.config.aikido_zen`.
|
9
|
+
config.aikido_zen = Aikido::Zen.config
|
10
|
+
end
|
11
|
+
|
12
|
+
initializer "aikido.add_middleware" do |app|
|
13
|
+
app.middleware.use Aikido::Zen::Middleware::SetContext
|
14
|
+
app.middleware.use Aikido::Zen::Middleware::CheckAllowedAddresses
|
15
|
+
app.middleware.use Aikido::Zen::Middleware::Throttler
|
16
|
+
|
17
|
+
# Due to how Rails sets up its middleware chain, the routing is evaluated
|
18
|
+
# (and the Request object constructed) in the app that terminates the
|
19
|
+
# chain, so no amount of middleware will be able to access it.
|
20
|
+
#
|
21
|
+
# This way, we overwrite the Request object as early as we can in the
|
22
|
+
# request handling, so that by the time we start evaluating inputs, we
|
23
|
+
# have assigned the request correctly.
|
24
|
+
ActiveSupport.on_load(:action_controller) do
|
25
|
+
before_action { Aikido::Zen.current_context.update_request(request) }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
initializer "aikido.configuration" do |app|
|
30
|
+
app.config.aikido_zen.logger = ::Rails.logger.tagged("aikido")
|
31
|
+
app.config.aikido_zen.request_builder = Aikido::Zen::Context::RAILS_REQUEST_BUILDER
|
32
|
+
|
33
|
+
# Plug Rails' JSON encoder/decoder, but only if the user hasn't changed
|
34
|
+
# them for something else.
|
35
|
+
if app.config.aikido_zen.json_encoder == Aikido::Zen::Config::DEFAULT_JSON_ENCODER
|
36
|
+
app.config.aikido_zen.json_encoder = ActiveSupport::JSON.method(:encode)
|
37
|
+
end
|
38
|
+
|
39
|
+
if app.config.aikido_zen.json_decoder == Aikido::Zen::Config::DEFAULT_JSON_DECODER
|
40
|
+
app.config.aikido_zen.json_decoder = ActiveSupport::JSON.method(:decode)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
config.after_initialize do
|
45
|
+
Aikido::Zen.initialize!
|
46
|
+
|
47
|
+
# Make sure this is run at the end of the initialization process, so
|
48
|
+
# that any gems required after aikido-zen are detected and patched
|
49
|
+
# accordingly.
|
50
|
+
Aikido::Zen.load_sinks!
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "bucket"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
# @api private
|
7
|
+
#
|
8
|
+
# Circuit breaker that rate limits internal API requests in two ways: By using
|
9
|
+
# a sliding window, to allow only a certain number of events over that window,
|
10
|
+
# and with the ability of manually being tripped open when the API responds to
|
11
|
+
# a request with a 429.
|
12
|
+
class RateLimiter::Breaker
|
13
|
+
def initialize(config: Aikido::Zen.config, clock: RateLimiter::Bucket::DEFAULT_CLOCK)
|
14
|
+
@config = config
|
15
|
+
@clock = clock
|
16
|
+
|
17
|
+
@bucket = RateLimiter::Bucket.new(
|
18
|
+
ttl: config.client_rate_limit_period,
|
19
|
+
max_size: config.client_rate_limit_max_events,
|
20
|
+
clock: clock
|
21
|
+
)
|
22
|
+
@opened_at = nil
|
23
|
+
end
|
24
|
+
|
25
|
+
# Trip the circuit open to force all events to be throttled until the
|
26
|
+
# deadline passes.
|
27
|
+
#
|
28
|
+
# @see Aikido::Zen::Config#server_rate_limit_deadline
|
29
|
+
# @return [void]
|
30
|
+
def open!
|
31
|
+
@opened_at = @clock.call
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param event [#type] an event which we'll discriminate by type to decide
|
35
|
+
# if we should throttle it.
|
36
|
+
# @return [Boolean]
|
37
|
+
def throttle?(event)
|
38
|
+
return true if open? && !try_close
|
39
|
+
|
40
|
+
result = @bucket.increment(event.type)
|
41
|
+
result.throttled?
|
42
|
+
end
|
43
|
+
|
44
|
+
# @!visibility private
|
45
|
+
# @return [Boolean]
|
46
|
+
def open?
|
47
|
+
@opened_at
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def past_deadline?
|
53
|
+
@opened_at < @clock.call - @config.server_rate_limit_deadline
|
54
|
+
end
|
55
|
+
|
56
|
+
def try_close
|
57
|
+
@opened_at = nil if past_deadline?
|
58
|
+
@opened_at.nil?
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../synchronizable"
|
4
|
+
require_relative "result"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
# This models a "sliding window" rate limiting bucket (where we keep a bucket
|
8
|
+
# per endpoint). The timestamps of requests are kept grouped by client, and
|
9
|
+
# when a new request is made, we check if the number of requests falls within
|
10
|
+
# the configured limit.
|
11
|
+
#
|
12
|
+
# @example
|
13
|
+
# bucket = Aikido::Zen::RateLimiter::Bucket.new(ttl: 60, max_size: 3)
|
14
|
+
# bucket.increment("1.2.3.4") #=> true (count for this key: 1)
|
15
|
+
# bucket.increment("1.2.3.4") #=> true (count for this key: 2)
|
16
|
+
#
|
17
|
+
# # 30 seconds go by
|
18
|
+
# bucket.increment("1.2.3.4") #=> true (count for this key: 3)
|
19
|
+
#
|
20
|
+
# # 20 more seconds go by
|
21
|
+
# bucket.increment("1.2.3.4") #=> false (count for this key: 3)
|
22
|
+
#
|
23
|
+
# # 20 more seconds go by
|
24
|
+
# bucket.increment("1.2.3.4") #=> true (count for this key: 2)
|
25
|
+
#
|
26
|
+
class RateLimiter::Bucket
|
27
|
+
prepend Synchronizable
|
28
|
+
|
29
|
+
# @!visibility private
|
30
|
+
#
|
31
|
+
# Use the monotonic clock to ensure time differences are consistent
|
32
|
+
# and not affected by timezones.or daylight savings changes.
|
33
|
+
DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC).round }
|
34
|
+
|
35
|
+
def initialize(ttl:, max_size:, clock: DEFAULT_CLOCK)
|
36
|
+
@ttl = ttl
|
37
|
+
@max_size = max_size
|
38
|
+
@data = Hash.new { |h, k| h[k] = [] }
|
39
|
+
@clock = clock
|
40
|
+
end
|
41
|
+
|
42
|
+
# Increments the key if the number of entries within the current TTL window
|
43
|
+
# is below the configured threshold.
|
44
|
+
#
|
45
|
+
# @param key [String] discriminating key to identify a client.
|
46
|
+
# See {Aikido::Zen::Config#rate_limiting_discriminator}.
|
47
|
+
#
|
48
|
+
# @return [Aikido::Zen::RateLimiter::Result] the result of the operation and
|
49
|
+
# statistics on this bucket for the given key.
|
50
|
+
def increment(key)
|
51
|
+
synchronize do
|
52
|
+
time = @clock.call
|
53
|
+
evict(key, at: time)
|
54
|
+
|
55
|
+
entries = @data[key]
|
56
|
+
throttled = entries.size >= @max_size
|
57
|
+
|
58
|
+
entries << time unless throttled
|
59
|
+
|
60
|
+
RateLimiter::Result.new(
|
61
|
+
throttled: throttled,
|
62
|
+
discriminator: key,
|
63
|
+
current_requests: entries.size,
|
64
|
+
max_requests: @max_size,
|
65
|
+
time_remaining: @ttl - (time - entries.min)
|
66
|
+
)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def evict(key, at: @clock.call)
|
73
|
+
synchronize { @data[key].delete_if { |time| time < (at - @ttl) } }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Aikido::Zen
|
2
|
+
# Holds the stats after checking if a request should be rate limited, which
|
3
|
+
# will be added to the Rack env.
|
4
|
+
class RateLimiter::Result
|
5
|
+
# @return [String] the output of the configured discriminator block, used to
|
6
|
+
# uniquely identify a client (e.g. the remote IP).
|
7
|
+
attr_reader :discriminator
|
8
|
+
|
9
|
+
# @return [Integer] number of requests for the client in the current window.
|
10
|
+
attr_reader :current_requests
|
11
|
+
|
12
|
+
# @return [Integer] configured max number of requests per client.
|
13
|
+
attr_reader :max_requests
|
14
|
+
|
15
|
+
# @return [Integer] number of seconds remaining until the window resets.
|
16
|
+
attr_reader :time_remaining
|
17
|
+
|
18
|
+
def initialize(throttled:, discriminator:, current_requests:, max_requests:, time_remaining:)
|
19
|
+
@throttled = throttled
|
20
|
+
@discriminator = discriminator
|
21
|
+
@current_requests = current_requests
|
22
|
+
@max_requests = max_requests
|
23
|
+
@time_remaining = time_remaining
|
24
|
+
end
|
25
|
+
|
26
|
+
# @return [Boolean] whether the current request was throttled or not.
|
27
|
+
def throttled?
|
28
|
+
@throttled
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "synchronizable"
|
4
|
+
require_relative "middleware/throttler"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
# Keeps track of all requests in this process, broken up by Route and further
|
8
|
+
# discriminated by client. Provides a single method that checks if a certain
|
9
|
+
# Request needs to be throttled or not.
|
10
|
+
class RateLimiter
|
11
|
+
prepend Synchronizable
|
12
|
+
|
13
|
+
def initialize(
|
14
|
+
config: Aikido::Zen.config,
|
15
|
+
settings: Aikido::Zen.runtime_settings
|
16
|
+
)
|
17
|
+
@config = config
|
18
|
+
@settings = settings
|
19
|
+
@buckets = Hash.new { |store, route|
|
20
|
+
synchronize {
|
21
|
+
settings = settings_for(route)
|
22
|
+
store[route] = Bucket.new(ttl: settings.period, max_size: settings.max_requests)
|
23
|
+
}
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
# Checks whether the request requires rate limiting. As a side effect, this
|
28
|
+
# will annotate the request with the "aikido.rate_limiting" ENV key, holding
|
29
|
+
# the result of the check, and including useful stats in case you want to
|
30
|
+
# return RateLimit headers..
|
31
|
+
#
|
32
|
+
# @param request [Aikido::Zen::Request]
|
33
|
+
# @return [Boolean]
|
34
|
+
#
|
35
|
+
# @see Aikido::Zen::RateLimiter::Result
|
36
|
+
def throttle?(request)
|
37
|
+
settings = settings_for(request.route)
|
38
|
+
return false unless settings.enabled?
|
39
|
+
|
40
|
+
bucket = @buckets[request.route]
|
41
|
+
key = @config.rate_limiting_discriminator.call(request)
|
42
|
+
request.env["aikido.rate_limiting"] = bucket.increment(key)
|
43
|
+
request.env["aikido.rate_limiting"].throttled?
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def settings_for(route)
|
49
|
+
@settings.endpoints[route].rate_limiting
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
require_relative "rate_limiter/bucket"
|
55
|
+
require_relative "rate_limiter/breaker"
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ipaddr"
|
4
|
+
require_relative "../route"
|
5
|
+
require_relative "../request"
|
6
|
+
|
7
|
+
module Aikido::Zen
|
8
|
+
# Simple router implementation that just identifies the currently requested
|
9
|
+
# URL as a route, attempting to heuristically substitute any path segments
|
10
|
+
# that may look like a parameterized value by something descriptive.
|
11
|
+
#
|
12
|
+
# For example, "/categories/123/events/2024-10-01" would be matched as
|
13
|
+
# "/categories/:number/events/:date"
|
14
|
+
class Request::HeuristicRouter
|
15
|
+
# @param request [Aikido::Zen::Request]
|
16
|
+
# @return [Aikido::Zen::Route, nil]
|
17
|
+
def recognize(request)
|
18
|
+
path = parameterize(request.path)
|
19
|
+
Route.new(verb: request.request_method, path: path)
|
20
|
+
end
|
21
|
+
|
22
|
+
private def parameterize(path)
|
23
|
+
return if path.nil?
|
24
|
+
|
25
|
+
path = path.split("/").map { |part| parameterize_segment(part) }.join("/")
|
26
|
+
path.prepend("/") unless path.start_with?("/")
|
27
|
+
path.chomp!("/") if path.size > 1
|
28
|
+
path
|
29
|
+
end
|
30
|
+
|
31
|
+
private def parameterize_segment(segment)
|
32
|
+
case segment
|
33
|
+
when NUMBER
|
34
|
+
":number"
|
35
|
+
when UUID
|
36
|
+
":uuid"
|
37
|
+
when DATE
|
38
|
+
":date"
|
39
|
+
when EMAIL
|
40
|
+
":email"
|
41
|
+
when IP
|
42
|
+
":ip"
|
43
|
+
when HASH
|
44
|
+
":hash"
|
45
|
+
when SecretMatcher
|
46
|
+
":secret"
|
47
|
+
else
|
48
|
+
segment
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
NUMBER = /\A\d+\z/
|
53
|
+
HEX = /\A[a-f0-9]+\z/i
|
54
|
+
DATE = /\A\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}\z/
|
55
|
+
UUID = /\A
|
56
|
+
(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}
|
57
|
+
| 00000000-0000-0000-0000-000000000000
|
58
|
+
| ffffffff-ffff-ffff-ffff-ffffffffffff
|
59
|
+
)\z/ix
|
60
|
+
EMAIL = /\A
|
61
|
+
[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+
|
62
|
+
@
|
63
|
+
[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
|
64
|
+
(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*
|
65
|
+
\z/x
|
66
|
+
IP = ->(segment) {
|
67
|
+
IPAddr::RE_IPV4ADDRLIKE.match?(segment) ||
|
68
|
+
IPAddr::RE_IPV6ADDRLIKE_COMPRESSED.match?(segment) ||
|
69
|
+
IPAddr::RE_IPV6ADDRLIKE_FULL.match?(segment)
|
70
|
+
}
|
71
|
+
HASH = ->(segment) { [32, 40, 64, 128].include?(segment.size) && HEX === segment }
|
72
|
+
|
73
|
+
class SecretMatcher
|
74
|
+
# Decides if a given string looks random enough to be a "secret".
|
75
|
+
#
|
76
|
+
# @param candidate [String]
|
77
|
+
# @return [Boolean]
|
78
|
+
def self.===(candidate)
|
79
|
+
new(candidate).matches?
|
80
|
+
end
|
81
|
+
|
82
|
+
private def initialize(string)
|
83
|
+
@string = string
|
84
|
+
end
|
85
|
+
|
86
|
+
def matches?
|
87
|
+
return false if @string.size <= MIN_LENGTH
|
88
|
+
return false if SEPARATORS === @string
|
89
|
+
return false unless DIGIT === @string
|
90
|
+
return false if [LOWER, UPPER, SPECIAL].none? { |pattern| pattern === @string }
|
91
|
+
|
92
|
+
ratios = @string.chars.each_cons(MIN_LENGTH).map do |window|
|
93
|
+
window.to_set.size / MIN_LENGTH.to_f
|
94
|
+
end
|
95
|
+
|
96
|
+
ratios.sum / ratios.size > SECRET_THRESHOLD
|
97
|
+
end
|
98
|
+
|
99
|
+
MIN_LENGTH = 10
|
100
|
+
SECRET_THRESHOLD = 0.75
|
101
|
+
|
102
|
+
LOWER = /[[:lower:]]/
|
103
|
+
UPPER = /[[:upper:]]/
|
104
|
+
DIGIT = /[[:digit:]]/
|
105
|
+
SPECIAL = /[!#\$%^&*|;:<>]/
|
106
|
+
SEPARATORS = /[[:space:]]|-/
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|