aikido-zen 1.0.2-aarch64-linux

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 (125) hide show
  1. checksums.yaml +7 -0
  2. data/.aikido +6 -0
  3. data/.ruby-version +1 -0
  4. data/.simplecov +32 -0
  5. data/.standard.yml +3 -0
  6. data/LICENSE +674 -0
  7. data/README.md +148 -0
  8. data/Rakefile +67 -0
  9. data/benchmarks/README.md +22 -0
  10. data/benchmarks/rails7.1_benchmark.js +1 -0
  11. data/benchmarks/rails7.1_sql_injection.js +102 -0
  12. data/docs/banner.svg +202 -0
  13. data/docs/config.md +133 -0
  14. data/docs/proxy.md +10 -0
  15. data/docs/rails.md +112 -0
  16. data/docs/troubleshooting.md +62 -0
  17. data/lib/aikido/zen/actor.rb +146 -0
  18. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  19. data/lib/aikido/zen/agent.rb +181 -0
  20. data/lib/aikido/zen/api_client.rb +145 -0
  21. data/lib/aikido/zen/attack.rb +217 -0
  22. data/lib/aikido/zen/attack_wave/helpers.rb +457 -0
  23. data/lib/aikido/zen/attack_wave.rb +88 -0
  24. data/lib/aikido/zen/background_worker.rb +52 -0
  25. data/lib/aikido/zen/cache.rb +91 -0
  26. data/lib/aikido/zen/capped_collections.rb +86 -0
  27. data/lib/aikido/zen/collector/event.rb +238 -0
  28. data/lib/aikido/zen/collector/hosts.rb +30 -0
  29. data/lib/aikido/zen/collector/routes.rb +71 -0
  30. data/lib/aikido/zen/collector/sink_stats.rb +95 -0
  31. data/lib/aikido/zen/collector/stats.rb +122 -0
  32. data/lib/aikido/zen/collector/users.rb +32 -0
  33. data/lib/aikido/zen/collector.rb +223 -0
  34. data/lib/aikido/zen/config.rb +312 -0
  35. data/lib/aikido/zen/context/rack_request.rb +27 -0
  36. data/lib/aikido/zen/context/rails_request.rb +47 -0
  37. data/lib/aikido/zen/context.rb +145 -0
  38. data/lib/aikido/zen/detached_agent/agent.rb +79 -0
  39. data/lib/aikido/zen/detached_agent/front_object.rb +41 -0
  40. data/lib/aikido/zen/detached_agent/server.rb +78 -0
  41. data/lib/aikido/zen/detached_agent.rb +2 -0
  42. data/lib/aikido/zen/errors.rb +107 -0
  43. data/lib/aikido/zen/event.rb +116 -0
  44. data/lib/aikido/zen/helpers.rb +24 -0
  45. data/lib/aikido/zen/internals.rb +123 -0
  46. data/lib/aikido/zen/libzen-v0.1.48-aarch64-linux.so +0 -0
  47. data/lib/aikido/zen/middleware/allowed_address_checker.rb +26 -0
  48. data/lib/aikido/zen/middleware/attack_wave_protector.rb +46 -0
  49. data/lib/aikido/zen/middleware/context_setter.rb +26 -0
  50. data/lib/aikido/zen/middleware/fork_detector.rb +23 -0
  51. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  52. data/lib/aikido/zen/middleware/rack_throttler.rb +50 -0
  53. data/lib/aikido/zen/middleware/request_tracker.rb +197 -0
  54. data/lib/aikido/zen/outbound_connection.rb +62 -0
  55. data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
  56. data/lib/aikido/zen/package.rb +22 -0
  57. data/lib/aikido/zen/payload.rb +50 -0
  58. data/lib/aikido/zen/rails_engine.rb +53 -0
  59. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  60. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  61. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  62. data/lib/aikido/zen/rate_limiter.rb +50 -0
  63. data/lib/aikido/zen/request/heuristic_router.rb +115 -0
  64. data/lib/aikido/zen/request/rails_router.rb +92 -0
  65. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  66. data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
  67. data/lib/aikido/zen/request/schema/builder.rb +121 -0
  68. data/lib/aikido/zen/request/schema/definition.rb +107 -0
  69. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  70. data/lib/aikido/zen/request/schema.rb +87 -0
  71. data/lib/aikido/zen/request.rb +88 -0
  72. data/lib/aikido/zen/route.rb +96 -0
  73. data/lib/aikido/zen/runtime_settings/endpoints.rb +78 -0
  74. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  75. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  76. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  77. data/lib/aikido/zen/runtime_settings.rb +66 -0
  78. data/lib/aikido/zen/scan.rb +75 -0
  79. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +68 -0
  80. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +64 -0
  81. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  82. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +65 -0
  83. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +94 -0
  84. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  85. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
  86. data/lib/aikido/zen/scanners/ssrf_scanner.rb +266 -0
  87. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +55 -0
  88. data/lib/aikido/zen/scanners.rb +7 -0
  89. data/lib/aikido/zen/sink.rb +118 -0
  90. data/lib/aikido/zen/sinks/action_controller.rb +85 -0
  91. data/lib/aikido/zen/sinks/async_http.rb +80 -0
  92. data/lib/aikido/zen/sinks/curb.rb +113 -0
  93. data/lib/aikido/zen/sinks/em_http.rb +83 -0
  94. data/lib/aikido/zen/sinks/excon.rb +118 -0
  95. data/lib/aikido/zen/sinks/file.rb +153 -0
  96. data/lib/aikido/zen/sinks/http.rb +93 -0
  97. data/lib/aikido/zen/sinks/httpclient.rb +95 -0
  98. data/lib/aikido/zen/sinks/httpx.rb +78 -0
  99. data/lib/aikido/zen/sinks/kernel.rb +33 -0
  100. data/lib/aikido/zen/sinks/mysql2.rb +31 -0
  101. data/lib/aikido/zen/sinks/net_http.rb +101 -0
  102. data/lib/aikido/zen/sinks/patron.rb +103 -0
  103. data/lib/aikido/zen/sinks/pg.rb +72 -0
  104. data/lib/aikido/zen/sinks/resolv.rb +62 -0
  105. data/lib/aikido/zen/sinks/socket.rb +85 -0
  106. data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
  107. data/lib/aikido/zen/sinks/trilogy.rb +31 -0
  108. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  109. data/lib/aikido/zen/sinks.rb +36 -0
  110. data/lib/aikido/zen/sinks_dsl.rb +250 -0
  111. data/lib/aikido/zen/synchronizable.rb +24 -0
  112. data/lib/aikido/zen/system_info.rb +80 -0
  113. data/lib/aikido/zen/version.rb +10 -0
  114. data/lib/aikido/zen/worker.rb +87 -0
  115. data/lib/aikido/zen.rb +303 -0
  116. data/lib/aikido-zen.rb +3 -0
  117. data/placeholder/.gitignore +4 -0
  118. data/placeholder/README.md +11 -0
  119. data/placeholder/Rakefile +75 -0
  120. data/placeholder/lib/placeholder.rb.template +3 -0
  121. data/placeholder/placeholder.gemspec.template +20 -0
  122. data/tasklib/bench.rake +94 -0
  123. data/tasklib/libzen.rake +133 -0
  124. data/tasklib/wrk.rb +88 -0
  125. metadata +214 -0
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Middleware
5
+ # Rack middleware used to track request
6
+ # It implements the logic under that which is considered worthy of being tracked.
7
+ class RequestTracker
8
+ def initialize(app, settings: Aikido::Zen.runtime_settings)
9
+ @app = app
10
+ @settings = settings
11
+ end
12
+
13
+ def call(env)
14
+ request = Aikido::Zen::Middleware.request_from(env)
15
+ response = @app.call(env)
16
+
17
+ if request.route && track?(
18
+ status_code: response[0],
19
+ route: request.route.path,
20
+ http_method: request.request_method,
21
+ ip: request.ip
22
+ )
23
+ Aikido::Zen.track_request(request)
24
+
25
+ if Aikido::Zen.config.collect_api_schema?
26
+ Aikido::Zen.track_discovered_route(request)
27
+ end
28
+ end
29
+
30
+ response
31
+ end
32
+
33
+ IGNORED_METHODS = %w[OPTIONS HEAD]
34
+ IGNORED_EXTENSIONS = %w[properties config webmanifest]
35
+ IGNORED_SEGMENTS = ["cgi-bin"]
36
+ WELL_KNOWN_URIS = %w[
37
+ /.well-known/acme-challenge
38
+ /.well-known/amphtml
39
+ /.well-known/api-catalog
40
+ /.well-known/appspecific
41
+ /.well-known/ashrae
42
+ /.well-known/assetlinks.json
43
+ /.well-known/broadband-labels
44
+ /.well-known/brski
45
+ /.well-known/caldav
46
+ /.well-known/carddav
47
+ /.well-known/change-password
48
+ /.well-known/cmp
49
+ /.well-known/coap
50
+ /.well-known/coap-eap
51
+ /.well-known/core
52
+ /.well-known/csaf
53
+ /.well-known/csaf-aggregator
54
+ /.well-known/csvm
55
+ /.well-known/did.json
56
+ /.well-known/did-configuration.json
57
+ /.well-known/dnt
58
+ /.well-known/dnt-policy.txt
59
+ /.well-known/dots
60
+ /.well-known/ecips
61
+ /.well-known/edhoc
62
+ /.well-known/enterprise-network-security
63
+ /.well-known/enterprise-transport-security
64
+ /.well-known/est
65
+ /.well-known/genid
66
+ /.well-known/gnap-as-rs
67
+ /.well-known/gpc.json
68
+ /.well-known/gs1resolver
69
+ /.well-known/hoba
70
+ /.well-known/host-meta
71
+ /.well-known/host-meta.json
72
+ /.well-known/hosting-provider
73
+ /.well-known/http-opportunistic
74
+ /.well-known/idp-proxy
75
+ /.well-known/jmap
76
+ /.well-known/keybase.txt
77
+ /.well-known/knx
78
+ /.well-known/looking-glass
79
+ /.well-known/masque
80
+ /.well-known/matrix
81
+ /.well-known/mercure
82
+ /.well-known/mta-sts.txt
83
+ /.well-known/mud
84
+ /.well-known/nfv-oauth-server-configuration
85
+ /.well-known/ni
86
+ /.well-known/nodeinfo
87
+ /.well-known/nostr.json
88
+ /.well-known/oauth-authorization-server
89
+ /.well-known/oauth-protected-resource
90
+ /.well-known/ohttp-gateway
91
+ /.well-known/openid-federation
92
+ /.well-known/open-resource-discovery
93
+ /.well-known/openid-configuration
94
+ /.well-known/openorg
95
+ /.well-known/oslc
96
+ /.well-known/pki-validation
97
+ /.well-known/posh
98
+ /.well-known/privacy-sandbox-attestations.json
99
+ /.well-known/private-token-issuer-directory
100
+ /.well-known/probing.txt
101
+ /.well-known/pvd
102
+ /.well-known/rd
103
+ /.well-known/related-website-set.json
104
+ /.well-known/reload-config
105
+ /.well-known/repute-template
106
+ /.well-known/resourcesync
107
+ /.well-known/sbom
108
+ /.well-known/security.txt
109
+ /.well-known/ssf-configuration
110
+ /.well-known/sshfp
111
+ /.well-known/stun-key
112
+ /.well-known/terraform.json
113
+ /.well-known/thread
114
+ /.well-known/time
115
+ /.well-known/timezone
116
+ /.well-known/tdmrep.json
117
+ /.well-known/tor-relay
118
+ /.well-known/tpcd
119
+ /.well-known/traffic-advice
120
+ /.well-known/trust.txt
121
+ /.well-known/uma2-configuration
122
+ /.well-known/void
123
+ /.well-known/webfinger
124
+ /.well-known/webweaver.json
125
+ /.well-known/wot
126
+ ]
127
+
128
+ # @param status_code [Integer]
129
+ # @param route [String]
130
+ # @param http_method [String]
131
+ def track?(status_code:, route:, http_method:, ip: nil)
132
+ # Bypass request and route tracking for allowed IPs
133
+ return false if @settings.allowed_ips.include?(ip)
134
+
135
+ # In the UI we want to show only successful (2xx) or redirect (3xx) responses
136
+ # anything else is discarded.
137
+ return false unless status_code >= 200 && status_code <= 399
138
+
139
+ return false if IGNORED_METHODS.include?(http_method)
140
+
141
+ segments = route.split "/"
142
+
143
+ # Do not discover routes with dot files like `/path/to/.file` or `/.directory/file`
144
+ # We want to allow discovery of well-known URIs like `/.well-known/acme-challenge`
145
+ return false if segments.any? { |s| is_dot_file s } && !is_well_known_uri(route)
146
+
147
+ return false if segments.any? { |s| contains_ignored_string s }
148
+
149
+ # Check for every file segment if it contains a file extension and if it
150
+ # should be discovered or ignored
151
+ segments.all? { |s| should_track_extension s }
152
+ end
153
+
154
+ private
155
+
156
+ # Check if a path is a well-known URI
157
+ # e.g. /.well-known/acme-challenge
158
+ # https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml
159
+ def is_well_known_uri(route)
160
+ WELL_KNOWN_URIS.include?(route)
161
+ end
162
+
163
+ def is_dot_file(segment)
164
+ segment.start_with?(".") && segment.size > 1
165
+ end
166
+
167
+ def contains_ignored_string(segment)
168
+ IGNORED_SEGMENTS.any? { |ignored| segment.include?(ignored) }
169
+ end
170
+
171
+ # Ignore routes which contain file extensions
172
+ def should_track_extension(segment)
173
+ extension = get_file_extension(segment)
174
+
175
+ return true unless extension
176
+
177
+ # Do not discover files with extensions of 1 to 5 characters,
178
+ # e.g. file.css, file.js, file.woff2
179
+ return false if extension.size > 1 && extension.size < 6
180
+
181
+ # Ignore some file extensions that are longer than 5 characters or shorter than 2 chars
182
+ return false if IGNORED_EXTENSIONS.include?(extension)
183
+
184
+ true
185
+ end
186
+
187
+ def get_file_extension(segment)
188
+ extension = File.extname(segment)
189
+ if extension&.start_with?(".")
190
+ # Remove the dot from the extension
191
+ return extension[1..]
192
+ end
193
+ extension
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,62 @@
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
+ def self.from_json(data)
7
+ new(
8
+ host: data[:hostname],
9
+ port: data[:port]
10
+ )
11
+ end
12
+
13
+ # Convenience factory to create connection descriptions out of URI objects.
14
+ #
15
+ # @param uri [URI]
16
+ # @return [Aikido::Zen::OutboundConnection]
17
+ def self.from_uri(uri)
18
+ new(host: uri.hostname, port: uri.port)
19
+ end
20
+
21
+ # @return [String] the hostname or IP address to which the connection was
22
+ # attempted.
23
+ attr_reader :host
24
+
25
+ # @return [Integer] the port number to which the connection was attempted.
26
+ attr_reader :port
27
+
28
+ # @return [Integer] the number of times that this connection was seen by
29
+ # the hosts collector.
30
+ attr_reader :hits
31
+
32
+ def initialize(host:, port:)
33
+ @host = host
34
+ @port = port
35
+ end
36
+
37
+ def hit
38
+ # Lazy initialize @hits, so it stays nil until the connection is tracked.
39
+ @hits ||= 0
40
+ @hits += 1
41
+ end
42
+
43
+ def as_json
44
+ {hostname: host, port: port, hits: hits}.compact
45
+ end
46
+
47
+ def ==(other)
48
+ other.is_a?(OutboundConnection) &&
49
+ host == other.host &&
50
+ port == other.port
51
+ end
52
+ alias_method :eql?, :==
53
+
54
+ def hash
55
+ [host, port].hash
56
+ end
57
+
58
+ def inspect
59
+ "#<#{self.class.name} #{host}:#{port}>"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,23 @@
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
+ def self.skips_on_nil_context?
9
+ false
10
+ end
11
+
12
+ # This simply reports the connection to the Agent, and always returns +nil+
13
+ # as it's not scanning for any particular attack.
14
+ #
15
+ # @param connection [Aikido::Zen::OutboundConnection]
16
+ # @return [nil]
17
+ def self.call(connection:, **)
18
+ Aikido::Zen.track_outbound(connection)
19
+
20
+ nil
21
+ end
22
+ end
23
+ 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,50 @@
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
+ UNKNOWN_PAYLOAD = Payload.new("unknown", "unknown", "unknown")
16
+
17
+ alias_method :to_s, :value
18
+
19
+ def ==(other)
20
+ other.is_a?(Payload) &&
21
+ other.value == value &&
22
+ other.source == source &&
23
+ other.path == path
24
+ end
25
+
26
+ def as_json
27
+ {
28
+ payload: value.to_s,
29
+ source: SOURCE_SERIALIZATIONS[source],
30
+ path: path.to_s
31
+ }
32
+ end
33
+
34
+ SOURCE_SERIALIZATIONS = {
35
+ query: "query",
36
+ body: "body",
37
+ header: "headers",
38
+ cookie: "cookies",
39
+ route: "routeParams",
40
+ graphql: "graphql",
41
+ xml: "xml",
42
+ subdomain: "subdomains"
43
+ }
44
+
45
+ def inspect
46
+ val = (value.to_s.size > 128) ? value[0..125] + "..." : value
47
+ "#<Aikido::Zen::Payload #{source}(#{path}) #{val.inspect}>"
48
+ end
49
+ end
50
+ 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.zen`.
9
+ config.zen = Aikido::Zen.config
10
+ end
11
+
12
+ initializer "aikido.add_middleware" do |app|
13
+ app.middleware.insert_before 0, Aikido::Zen::Middleware::ForkDetector
14
+
15
+ app.middleware.use Aikido::Zen::Middleware::ContextSetter
16
+ app.middleware.use Aikido::Zen::Middleware::AllowedAddressChecker
17
+ app.middleware.use Aikido::Zen::Middleware::AttackWaveProtector
18
+ # Request Tracker stats do not consider failed request or 40x, so the middleware
19
+ # must be the last one wrapping the request.
20
+ app.middleware.use Aikido::Zen::Middleware::RequestTracker
21
+
22
+ ActiveSupport.on_load(:action_controller) do
23
+ # Due to how Rails sets up its middleware chain, the routing is evaluated
24
+ # (and the Request object constructed) in the app that terminates the
25
+ # chain, so no amount of middleware will be able to access it.
26
+ #
27
+ # This way, we overwrite the Request object as early as we can in the
28
+ # request handling, so that by the time we start evaluating inputs, we
29
+ # have assigned the request correctly.
30
+ before_action { Aikido::Zen.current_context.update_request(request) }
31
+ end
32
+ end
33
+
34
+ initializer "aikido.configuration" do |app|
35
+ app.config.zen.request_builder = Aikido::Zen::Context::RAILS_REQUEST_BUILDER
36
+
37
+ # Plug Rails' JSON encoder/decoder, but only if the user hasn't changed
38
+ # them for something else.
39
+ if app.config.zen.json_encoder == Aikido::Zen::Config::DEFAULT_JSON_ENCODER
40
+ app.config.zen.json_encoder = ActiveSupport::JSON.method(:encode)
41
+ end
42
+
43
+ if app.config.zen.json_decoder == Aikido::Zen::Config::DEFAULT_JSON_DECODER
44
+ app.config.zen.json_decoder = ActiveSupport::JSON.method(:decode)
45
+ end
46
+ end
47
+
48
+ config.after_initialize do
49
+ # Start the Aikido Agent only once the application starts.
50
+ Aikido::Zen.start!
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 [String] an event type which we'll use to decide
35
+ # if we should throttle it.
36
+ # @return [Boolean]
37
+ def throttle?(event_type)
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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "synchronizable"
4
+ require_relative "middleware/rack_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
+ # Calculate based on the configuration whether a request will be
28
+ # rate-limited or not.
29
+ #
30
+ # @param request [Aikido::Zen::Request]
31
+ # @return [Aikido::Zen::RateLimiter::Result, nil]
32
+ def calculate_rate_limits(request)
33
+ settings = settings_for(request.route)
34
+ return nil unless settings.enabled?
35
+
36
+ bucket = @buckets[request.route]
37
+ key = @config.rate_limiting_discriminator.call(request)
38
+ bucket.increment(key)
39
+ end
40
+
41
+ private
42
+
43
+ def settings_for(route)
44
+ @settings.endpoints[route].rate_limiting
45
+ end
46
+ end
47
+ end
48
+
49
+ require_relative "rate_limiter/bucket"
50
+ require_relative "rate_limiter/breaker"