actionlimiter 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 01f5bc1107b2d90f94cb1805894dc9047764da8c0cfe298836a63ab01f4ad38b
4
+ data.tar.gz: f6eca9a81f6492e8f6a9c3b2231a5f85b0d5417773e57e4495b8976652b8564f
5
+ SHA512:
6
+ metadata.gz: 40a691dd3379ac0c52dd09ba9c3a159953be02457a76984fe6a2d3d36339a4fd0bf2710030d5c8428e020d12053104c192d09807ad47ed57c3266650a914472c
7
+ data.tar.gz: d2bcd2fff1eb33902cbd3993675232224dacfd0d07e9abb49a2adc940c381b84fdcc2f02a0cd6c079675c338c3eac6e0f1313d4c25a018ef8a9454a7640d8f83
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 AngryBoat, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # ActionLimiter
2
+
3
+ [![Unit Test](https://github.com/angryboat/actionlimiter/actions/workflows/testing.yml/badge.svg)](https://github.com/angryboat/actionlimiter/actions/workflows/testing.yml) [![Linters](https://github.com/angryboat/actionlimiter/actions/workflows/linting.yml/badge.svg)](https://github.com/angryboat/actionlimiter/actions/workflows/linting.yml)
4
+
5
+ Provides Redis backed rate limiting for Rails applications.
6
+
7
+ ## Installing
8
+
9
+ ```shell
10
+ gem install actionlimiter
11
+ ```
12
+
13
+ ```shell
14
+ bundler add actionlimiter
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### Rails IP Middleware
20
+
21
+ ```ruby
22
+ Rails.application.configure do |config|
23
+ # Limit a single IP to 20 requests in a 5 second period.
24
+ config.middleware.use(ActionLimiter::Middleware::IP, period: 5, size: 20)
25
+ end
26
+ ```
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'active_support/notifications'
5
+ rescue LoadError
6
+ nil
7
+ end
8
+
9
+ ##
10
+ # @private
11
+ module ActionLimiter
12
+ def self.instrument(name, payload = {})
13
+ if defined?(ActiveSupport::Notifications)
14
+ ActiveSupport::Notifications.instrument(name, payload) { yield(payload) }
15
+ else
16
+ yield(payload)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_limiter/token_bucket'
4
+ require 'digest'
5
+
6
+ module ActionLimiter
7
+ module Middleware
8
+ ##
9
+ # IP based rate limiting middleware
10
+ class IP
11
+ ##
12
+ # @private
13
+ class ResponseBuilder
14
+ ##
15
+ # @private
16
+ def call(_env)
17
+ [429, response_headers, [response_body]]
18
+ end
19
+
20
+ private
21
+
22
+ def response_body
23
+ <<~BODY
24
+ <html>
25
+ <body>
26
+ <h1>Too Many Requests</h1>
27
+ </body>
28
+ </html>
29
+ BODY
30
+ end
31
+
32
+ def response_headers
33
+ {
34
+ 'Content-Type' => 'text/html; charset=utf-8'
35
+ }
36
+ end
37
+ end
38
+
39
+ ##
40
+ # @private
41
+ def initialize(app, options = {})
42
+ @app = app
43
+ @response_builder = options.fetch(:response_builder) { ResponseBuilder.new }
44
+ @token_bucket = ActionLimiter::TokenBucket.new(
45
+ period: options.fetch(:period, 1),
46
+ size: options.fetch(:size, 100),
47
+ namespace: options.fetch(:namespace, 'action_limiter/middleware/ip')
48
+ )
49
+ end
50
+
51
+ ##
52
+ # @private
53
+ def call(env)
54
+ status, headers, body = _call(env)
55
+
56
+ headers.merge!(create_rate_limit_headers(env))
57
+
58
+ [status, headers, body]
59
+ end
60
+
61
+ private
62
+
63
+ def _call(env)
64
+ remote_ip = env.fetch('action_dispatch.remote_ip')
65
+ bucket_key = Digest::MD5.hexdigest(remote_ip.to_s)
66
+ bucket = @token_bucket.increment(bucket_key, Time.now)
67
+
68
+ env['action_limiter.ip_bucket'] = bucket
69
+
70
+ if bucket.value > @token_bucket.size
71
+ @response_builder.call(env)
72
+ else
73
+ @app.call(env)
74
+ end
75
+ end
76
+
77
+ def create_rate_limit_headers(env)
78
+ bucket = env.fetch('action_limiter.ip_bucket')
79
+
80
+ {
81
+ 'X-Request-Count' => bucket.value.to_s,
82
+ 'X-Request-Period' => @token_bucket.period.to_s,
83
+ 'X-Request-Limit' => @token_bucket.size.to_s
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_limiter/middleware/ip'
4
+
5
+ module ActionLimiter
6
+ ##
7
+ # Provides Rack middleware for the rate limiting algorithms
8
+ module Middleware
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+
5
+ module ActionLimiter
6
+ module Rails
7
+ class Engine < ::Rails::Railtie
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_limiter/rails/engine' if defined?(::Rails)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'connection_pool'
4
+ require 'redis'
5
+
6
+ ##
7
+ # @private
8
+ module ActionLimiter
9
+ ##
10
+ # Private
11
+ class RedisProvider
12
+ MUTEX = Mutex.new
13
+
14
+ class << self
15
+ def pool_size
16
+ ENV.fetch('ACTION_LIMITER_POOL_SIZE', 5).to_i
17
+ end
18
+
19
+ def pool_connection_timeout
20
+ ENV.fetch('ACTION_LIMITER_TIMEOUT', 30).to_i
21
+ end
22
+
23
+ def redis_connection_host
24
+ ENV.fetch('ACTION_LIMITER_REDIS_HOST', '127.0.0.1')
25
+ end
26
+
27
+ def redis_connection_port
28
+ ENV.fetch('ACTION_LIMITER_REDIS_PORT', 6379).to_i
29
+ end
30
+
31
+ def redis_connection_database
32
+ ENV.fetch('ACTION_LIMITER_REDIS_DB', 0).to_i
33
+ end
34
+
35
+ def connection_pool
36
+ MUTEX.synchronize do
37
+ @connection_pool ||= unsafe_create_connection_pool
38
+ end
39
+ end
40
+
41
+ def unsafe_create_connection_pool
42
+ ConnectionPool.new(size: pool_size, timeout: pool_connection_timeout) do
43
+ RedisProvider.unsafe_create_redis_connection
44
+ end
45
+ end
46
+
47
+ def unsafe_create_redis_connection
48
+ Redis.new(
49
+ host: redis_connection_host,
50
+ port: redis_connection_port,
51
+ db: redis_connection_database
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,18 @@
1
+ -- Token Bucket
2
+
3
+ -- Period is the number of seconds for the token bucket TTL
4
+ local period = tonumber(ARGV[1])
5
+ -- The server timestamp for the value delivered
6
+ local ts = tonumber(ARGV[2])
7
+
8
+ -- Set the minimum time
9
+ local min = ts - period
10
+
11
+ -- Bucket Name
12
+ local bucket_name = KEYS[1]
13
+
14
+ redis.call('ZREMRANGEBYSCORE', bucket_name, '-inf', min)
15
+ redis.call('ZADD', bucket_name, ts, ts)
16
+ redis.call('EXPIRE', bucket_name, period * 5)
17
+
18
+ return redis.call('ZCARD', bucket_name)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionLimiter
4
+ ##
5
+ # @private
6
+ SCRIPTS = Dir.glob("#{__dir__}/scripts/*.lua").each_with_object({}) do |script_path, object|
7
+ script_name = File.basename(script_path, '.lua').to_sym
8
+
9
+ object[script_name] = File.read(script_path).freeze
10
+ end.freeze
11
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_limiter/instrumentation'
4
+ require 'action_limiter/redis_provider'
5
+ require 'action_limiter/scripts'
6
+
7
+ module ActionLimiter
8
+ ##
9
+ # Implementes a Token Bucket algorithm for rate limiting.
10
+ #
11
+ # @author Maddie Schipper
12
+ # @since 0.1.0
13
+ class TokenBucket
14
+ Bucket = Struct.new(:name, :value)
15
+
16
+ ##
17
+ # The period length for the bucket in seconds
18
+ #
19
+ # @return [Integer] The specified period
20
+ attr_reader :period
21
+
22
+ ##
23
+ # The allowed size of the bucket
24
+ #
25
+ # @return [Integer] The size of the bucket
26
+ attr_reader :size
27
+
28
+ ##
29
+ # The bucket namespace. The value will be prefixed to any bucket's name
30
+ #
31
+ # @return [String] The prefix
32
+ attr_reader :namespace
33
+
34
+ ##
35
+ # Initialize the token bucket instance
36
+ #
37
+ # @param period [#to_i] The value used to determine the bucket's period
38
+ # @param size [#to_i] The maximum number of tokens in the bucket for the given period
39
+ # @param namespace [nil, #to_s] Value to prefix all
40
+ def initialize(period:, size:, namespace: nil)
41
+ @period = period.to_i
42
+ @size = size.to_i
43
+ @namespace = namespace&.to_s || 'action_limiter/token_bucket'
44
+ @script_hash = ActionLimiter::RedisProvider.connection_pool.with do |connection|
45
+ connection.script(:load, ActionLimiter::SCRIPTS.fetch(:token_bucket))
46
+ end
47
+ end
48
+
49
+ ##
50
+ # Predicate for checking if the specified bucket name is incremented
51
+ #
52
+ # @param bucket [String] The name of the bucket to check
53
+ # @param time [Time] The time the check will occur
54
+ #
55
+ # @return [true, false] The limiting status of the bucket
56
+ def limited?(bucket, time: Time.now)
57
+ increment(bucket, time).value > size
58
+ end
59
+
60
+ ##
61
+ # @private
62
+ def increment(bucket, time)
63
+ ActionLimiter.instrument('action_limiter.token_bucket.increment') do
64
+ ActionLimiter::RedisProvider.connection_pool.with do |connection|
65
+ time_stamp = time.to_f
66
+ bucket_key = "#{namespace}/#{bucket}"
67
+ value = connection.evalsha(@script_hash, [bucket_key], [period.to_s, time_stamp.to_s])
68
+ Bucket.new(bucket, value)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionLimiter
4
+ ##
5
+ # The current version number
6
+ VERSION = '0.2.0'
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_limiter/middleware'
4
+ require 'action_limiter/rails'
5
+ require 'action_limiter/token_bucket'
6
+ require 'action_limiter/version'
7
+
8
+ ##
9
+ # ActionLimiter implementes rate limiting backed by Redis
10
+ #
11
+ # @author Maddie Schipper
12
+ # @since 0.1.0
13
+ module ActionLimiter
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file exists only for the purpose of Bundler autoloading
4
+
5
+ require 'action_limiter'
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: actionlimiter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Maddie Schipper
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-10-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: connection_pool
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: redis
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '4.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '5.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '4.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '5.0'
53
+ description: Redis backed request rate limiter
54
+ email:
55
+ - maddie@angryboat.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - LICENSE
61
+ - README.md
62
+ - lib/action_limiter.rb
63
+ - lib/action_limiter/instrumentation.rb
64
+ - lib/action_limiter/middleware.rb
65
+ - lib/action_limiter/middleware/ip.rb
66
+ - lib/action_limiter/rails.rb
67
+ - lib/action_limiter/rails/engine.rb
68
+ - lib/action_limiter/redis_provider.rb
69
+ - lib/action_limiter/scripts.rb
70
+ - lib/action_limiter/scripts/token_bucket.lua
71
+ - lib/action_limiter/token_bucket.rb
72
+ - lib/action_limiter/version.rb
73
+ - lib/actionlimiter.rb
74
+ homepage: https://github.com/angryboat/actionlimiter
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ allowed_push_host: https://rubygems.org
79
+ homepage_uri: https://github.com/angryboat/actionlimiter
80
+ source_code_uri: https://github.com/angryboat/actionlimiter
81
+ changelog_uri: https://github.com/angryboat/actionlimiter
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 2.7.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.1.6
98
+ signing_key:
99
+ specification_version: 4
100
+ summary: Redis backed request rate limiter
101
+ test_files: []