curbit 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/Manifest +12 -0
- data/README.rdoc +162 -0
- data/Rakefile +66 -0
- data/curbit.gemspec +31 -0
- data/init.rb +1 -0
- data/lib/curbit.rb +206 -0
- data/test/custom_key_controller_test.rb +66 -0
- data/test/custom_message_format_controller.rb +69 -0
- data/test/standard_controller_test.rb +119 -0
- data/test/test_helper.rb +9 -0
- data/test/test_rails_helper.rb +29 -0
- metadata +76 -0
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Scott Sayles
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Manifest
ADDED
data/README.rdoc
ADDED
@@ -0,0 +1,162 @@
|
|
1
|
+
= CurbIt
|
2
|
+
CurbIt makes it easy to add application level rate limiting to your Rails
|
3
|
+
app by using a controller macro.
|
4
|
+
|
5
|
+
CurbIt is NOT a replacement for properly configured rate limiting at
|
6
|
+
fronting services. Properly defending your app against DoS attacks or
|
7
|
+
other malicious client behavior should always include configuring rate
|
8
|
+
limiting and throttling at the services that sit in front of your app.
|
9
|
+
This includes firewalls, load balancers, and reverse proxies. But
|
10
|
+
sometimes that just isn't enough. Sometimes you want to rate limit
|
11
|
+
requests from users based on application logic that is not practical
|
12
|
+
to get to or replicate in those services.
|
13
|
+
|
14
|
+
= Usage
|
15
|
+
|
16
|
+
=== Minimal configuration
|
17
|
+
|
18
|
+
Quick setup inside your Rails controller. ActionController::Base is
|
19
|
+
already extended to include Curbit. This will add a rate_limit "macro" to
|
20
|
+
your controllers.
|
21
|
+
|
22
|
+
class InvitesController < ApplicationController
|
23
|
+
def invite
|
24
|
+
# invite logic...
|
25
|
+
end
|
26
|
+
|
27
|
+
rate_limit :invite, :max_calls => 2, :time_limit => 30.seconds, :wait_time => 1.minute
|
28
|
+
end
|
29
|
+
|
30
|
+
If a user calls the invite service from the same remote address more than
|
31
|
+
2 times within 30 seconds, CurbIt will render a '503 Service Unavailable'
|
32
|
+
response and the invite method is never called. The user will then need
|
33
|
+
to wait 1 minute before being allowed to make the request again.
|
34
|
+
Default response messages for html, xml, and json formats are rendered as required.
|
35
|
+
|
36
|
+
=== Custom client identifier
|
37
|
+
|
38
|
+
If you don't want to use the remote address to identify the client,
|
39
|
+
you can specify a method that CurbIt will call to get a key from.
|
40
|
+
|
41
|
+
class InvitesController < ApplicationController
|
42
|
+
def invite
|
43
|
+
# invite logic...
|
44
|
+
end
|
45
|
+
|
46
|
+
rate_limit :invite, :key => :userid, :max_calls => 2,
|
47
|
+
:time_limit => 30.seconds, :wait_time => 1.minute
|
48
|
+
def userid
|
49
|
+
session[:user_id]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
CurbIt will call the :userid method and use the returned value to
|
54
|
+
create a unique identifier. This identifier is used to index cached
|
55
|
+
information about the request.
|
56
|
+
|
57
|
+
You can alternatively pass a Proc that will take the controller
|
58
|
+
instance as an argument.
|
59
|
+
|
60
|
+
rate_limit :invite, :key => proc {|c| c.session[:user_id]},
|
61
|
+
:max_calls => 2,
|
62
|
+
:time_limit => 30.seconds, :wait_time => 1.minute
|
63
|
+
|
64
|
+
(If you're wondering why CurbIt passes the controller into the proc, it's
|
65
|
+
because the Proc is not bound to the controller instance when it's
|
66
|
+
defined. This way, you can at least have access to stuff you might need.)
|
67
|
+
|
68
|
+
=== Custom message
|
69
|
+
|
70
|
+
You might like to customize the messages returned by CurbIt.
|
71
|
+
|
72
|
+
class InvitesController < ApplicationController
|
73
|
+
def invite
|
74
|
+
# invite logic...
|
75
|
+
end
|
76
|
+
|
77
|
+
rate_limit :invite, :max_calls => 2, :time_limit => 30.seconds, :wait_time => 1.minute,
|
78
|
+
:message => "Hey! Slow down there cow polk.",
|
79
|
+
:status => 200
|
80
|
+
end
|
81
|
+
|
82
|
+
After reaching the maximum threshold of requests, CurbIt will render the
|
83
|
+
message "Hey! Slow down there cow polk." with a response status of 200.
|
84
|
+
If :status is not defined, CurbIt will set a 503 status on the response.
|
85
|
+
CurbIt will also embed this message into some default json or xml
|
86
|
+
containers based on the request format.
|
87
|
+
|
88
|
+
=== Custom message rendering
|
89
|
+
CurbIt does it's best to render a response based on the requested format,
|
90
|
+
but you might have an obscure mime-type you're using or you might like to
|
91
|
+
customize the response rendering.
|
92
|
+
|
93
|
+
class InvitesController < ApplicationController
|
94
|
+
def invite
|
95
|
+
# invite logic...
|
96
|
+
end
|
97
|
+
|
98
|
+
rate_limit :invite, :max_calls => 2, :time_limit => 30.seconds, :wait_time => 1.minute,
|
99
|
+
:message => :limit_response
|
100
|
+
|
101
|
+
def limit_response(wait_time)
|
102
|
+
respond_to {|fmt|
|
103
|
+
fmt.csv {
|
104
|
+
render :text => "Plese wait #{wait_time} seconds before trying again",
|
105
|
+
:status => 200
|
106
|
+
}
|
107
|
+
}
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
|
112
|
+
Here, CurbIt will relinquish all control for response rendering to your
|
113
|
+
method. This will ignore any :status argument set in the cofig options.
|
114
|
+
|
115
|
+
=== Default response bodies
|
116
|
+
* xml: <error>message</error>
|
117
|
+
* json: {"error":{"message"}}
|
118
|
+
* html: message
|
119
|
+
* text: message
|
120
|
+
|
121
|
+
|
122
|
+
= Rails Installation
|
123
|
+
|
124
|
+
== As a Gem
|
125
|
+
|
126
|
+
=== Gem install
|
127
|
+
|
128
|
+
$ gem install curbit --source http://gems.github.com
|
129
|
+
|
130
|
+
=== Rails dependency
|
131
|
+
Specify the gem dependency in your config/environment.rb file:
|
132
|
+
|
133
|
+
Rails::Initializer.run do |config|
|
134
|
+
#...
|
135
|
+
config.gem "curbit", :source => "http://gems.github.com"
|
136
|
+
#...
|
137
|
+
end
|
138
|
+
|
139
|
+
Then:
|
140
|
+
|
141
|
+
$ rake gems:install
|
142
|
+
$ rake gems:unpack
|
143
|
+
|
144
|
+
== As a Plugin
|
145
|
+
|
146
|
+
$ script/plugin install git://github.com/ssayles/curbit.git
|
147
|
+
|
148
|
+
= Requirements
|
149
|
+
* Rails >= 2.0
|
150
|
+
* memcached or other compatible caching support in Rails. CurbIt has to store information about requests and assumes your cache implementation will be able to take calls like:
|
151
|
+
|
152
|
+
Rails.cache.write(key, value, :expires_in => wait_time)
|
153
|
+
|
154
|
+
That's it!
|
155
|
+
|
156
|
+
= Credits
|
157
|
+
|
158
|
+
CurbIt is written and maintained by {Scott Sayles}[mailto:ssayles@users.sourceforge.net].
|
159
|
+
|
160
|
+
= Copyright
|
161
|
+
|
162
|
+
Copyright (c) 2009 Scott Sayles. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
require 'echoe'
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'jeweler'
|
7
|
+
Jeweler::Tasks.new do |gem|
|
8
|
+
gem.name = "curbit"
|
9
|
+
gem.summary = %Q{Rails plugin for application level rate limiting}
|
10
|
+
gem.description = %Q{TODO: longer description of your gem}
|
11
|
+
gem.email = "ssayles@users.sourceforge.net"
|
12
|
+
gem.homepage = "http://github.com/ssayles/curbit"
|
13
|
+
gem.authors = ["Scott Sayles"]
|
14
|
+
gem.add_development_dependency "thoughtbot-shoulda"
|
15
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
16
|
+
end
|
17
|
+
rescue LoadError
|
18
|
+
puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
|
19
|
+
end
|
20
|
+
|
21
|
+
Echoe.new('curbit', '0.1.0') do |p|
|
22
|
+
p.description = "Application level rate limiting for Rails"
|
23
|
+
p.url = "http://github.com/ssayles/curbit"
|
24
|
+
p.author = "Scott Sayles"
|
25
|
+
p.email = "ssayles@users.sourceforge.net"
|
26
|
+
p.ignore_pattern = ["tmp/*"]
|
27
|
+
#p.develoment_dependencies = []
|
28
|
+
end
|
29
|
+
|
30
|
+
require 'rake/testtask'
|
31
|
+
Rake::TestTask.new(:test) do |test|
|
32
|
+
test.libs << 'lib' << 'test'
|
33
|
+
test.pattern = 'test/**/*_test.rb'
|
34
|
+
test.verbose = true
|
35
|
+
end
|
36
|
+
|
37
|
+
begin
|
38
|
+
require 'rcov/rcovtask'
|
39
|
+
Rcov::RcovTask.new do |test|
|
40
|
+
test.libs << 'test'
|
41
|
+
test.pattern = 'test/**/*_test.rb'
|
42
|
+
test.verbose = true
|
43
|
+
end
|
44
|
+
rescue LoadError
|
45
|
+
task :rcov do
|
46
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
task :test => :check_dependencies
|
51
|
+
|
52
|
+
task :default => :test
|
53
|
+
|
54
|
+
require 'rake/rdoctask'
|
55
|
+
Rake::RDocTask.new do |rdoc|
|
56
|
+
if File.exist?('VERSION')
|
57
|
+
version = File.read('VERSION')
|
58
|
+
else
|
59
|
+
version = ""
|
60
|
+
end
|
61
|
+
|
62
|
+
rdoc.rdoc_dir = 'rdoc'
|
63
|
+
rdoc.title = "curbit #{version}"
|
64
|
+
rdoc.rdoc_files.include('README*')
|
65
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
66
|
+
end
|
data/curbit.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = %q{curbit}
|
5
|
+
s.version = "0.1.0"
|
6
|
+
|
7
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
|
8
|
+
s.authors = ["Scott Sayles"]
|
9
|
+
s.date = %q{2009-10-25}
|
10
|
+
s.description = %q{Application level rate limiting for Rails}
|
11
|
+
s.email = %q{ssayles@users.sourceforge.net}
|
12
|
+
s.extra_rdoc_files = ["LICENSE", "README.rdoc", "lib/curbit.rb"]
|
13
|
+
s.files = ["LICENSE", "README.rdoc", "Rakefile", "init.rb", "lib/curbit.rb", "test/custom_key_controller_test.rb", "test/custom_message_format_controller.rb", "test/standard_controller_test.rb", "test/test_helper.rb", "test/test_rails_helper.rb", "Manifest", "curbit.gemspec"]
|
14
|
+
s.homepage = %q{http://github.com/ssayles/curbit}
|
15
|
+
s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Curbit", "--main", "README.rdoc"]
|
16
|
+
s.require_paths = ["lib"]
|
17
|
+
s.rubyforge_project = %q{curbit}
|
18
|
+
s.rubygems_version = %q{1.3.5}
|
19
|
+
s.summary = %q{Application level rate limiting for Rails}
|
20
|
+
s.test_files = ["test/custom_key_controller_test.rb", "test/standard_controller_test.rb", "test/test_helper.rb", "test/test_rails_helper.rb"]
|
21
|
+
|
22
|
+
if s.respond_to? :specification_version then
|
23
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
24
|
+
s.specification_version = 3
|
25
|
+
|
26
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
27
|
+
else
|
28
|
+
end
|
29
|
+
else
|
30
|
+
end
|
31
|
+
end
|
data/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ActionController::Base.include(Curbit::Controller)
|
data/lib/curbit.rb
ADDED
@@ -0,0 +1,206 @@
|
|
1
|
+
module Curbit
|
2
|
+
module Controller
|
3
|
+
|
4
|
+
CacheKeyPrefix = "crl_key"
|
5
|
+
|
6
|
+
def self.included(controller)
|
7
|
+
controller.extend ClassMethods
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
|
12
|
+
|
13
|
+
# Establishes a before filter for the specified method that will limit
|
14
|
+
# calls to it based on the given options:
|
15
|
+
#
|
16
|
+
# ==== Options
|
17
|
+
# * +key+ - A symbol representing an instance method or Proc that will return the key used to identify calls. This is what is used to destinguish one call from another. If not specified, the client ip derived from the request will be used. This will check for a HTTP_X_FORWARDED_FOR header first before using <tt>request.remote_addr</tt>. The Proc will be passed the controller instance as it is out of scope when the Proc is initially created (so you can get at request, params, etc.).
|
18
|
+
# * +max_calls+ - maximum number of calls allowed. Required.
|
19
|
+
# * +time_limit+ - only :max_calls will be allowed within the specific time frame (in seconds). If :max_calls is reached within this time, the call will be halted. Required.
|
20
|
+
# * +wait_time+ - The time to wait if :max_calls has been reached before being able to pass.
|
21
|
+
# * +message+ - The message to render to the client if the call is being limited. The message will be rendered as a correspondingly formatted response with a default status if given a String. If the argument is a symbol, a method with the same name will be invoked with the specified wait_time (in seconds). The called method should take care of rendering the response.
|
22
|
+
# * +status+ - The response status to set when the call is being limited.
|
23
|
+
#
|
24
|
+
# ==== Examples
|
25
|
+
#
|
26
|
+
# class InviteController < ApplicationController
|
27
|
+
#
|
28
|
+
# include Curbit::Controller
|
29
|
+
#
|
30
|
+
# def validate
|
31
|
+
# # validate code
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# rate_limit :validate, :max_calls => 10,
|
35
|
+
# :time_limit => 1.minute,
|
36
|
+
# :wait_time => 1.minute,
|
37
|
+
# :message => 'Too many attempts to validate your invitation code. Please wait 1 minute before trying again.'
|
38
|
+
#
|
39
|
+
#
|
40
|
+
# def invite
|
41
|
+
# # invite code
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# rate_limit :invite, :key => proc {|c| c.session[:userid]},
|
45
|
+
# :max_calls => 2,
|
46
|
+
# :time_limit => 30.seconds,
|
47
|
+
# :wait_time => 1.minute
|
48
|
+
# end
|
49
|
+
#
|
50
|
+
def rate_limit(method, opts)
|
51
|
+
|
52
|
+
return unless rate_limit_opts_valid?(opts)
|
53
|
+
|
54
|
+
self.class_eval do
|
55
|
+
define_method "rate_limit_#{method}" do
|
56
|
+
rate_limit_filter(method, opts)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
self.before_filter("rate_limit_#{method}", :only => method)
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def rate_limit_opts_valid?(opts = {})
|
65
|
+
new_opts = {:status => 503}.merge! opts
|
66
|
+
opts.merge! new_opts
|
67
|
+
if !opts.key?(:max_calls) or !opts.key?(:time_limit) or !opts.key?(:wait_time)
|
68
|
+
raise ":max_calls, :time_limit, and :wait_time are required parameters"
|
69
|
+
end
|
70
|
+
true
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def curbit_cache_key(key, method)
|
78
|
+
# TODO: this won't work if there are more than one controller with
|
79
|
+
# the same name in the same app
|
80
|
+
"#{CacheKeyPrefix}_#{self.class.name}_#{method}_#{key}"
|
81
|
+
end
|
82
|
+
|
83
|
+
def rate_limit_filter(method, opts)
|
84
|
+
key = get_key(opts[:key])
|
85
|
+
unless (key)
|
86
|
+
return true
|
87
|
+
end
|
88
|
+
|
89
|
+
cache_key = curbit_cache_key(key, method)
|
90
|
+
|
91
|
+
val = Rails.cache.read(cache_key)
|
92
|
+
|
93
|
+
if (val)
|
94
|
+
started_at = val[:started]
|
95
|
+
count = val[:count]
|
96
|
+
val[:count] = count + 1
|
97
|
+
started_waiting = val[:started_waiting]
|
98
|
+
|
99
|
+
if started_waiting
|
100
|
+
# did we exceed the wait time?
|
101
|
+
if Time.now.to_i > (started_waiting.to_i + opts[:wait_time])
|
102
|
+
Rails.cache.delete(cache_key)
|
103
|
+
return true
|
104
|
+
else
|
105
|
+
get_message(opts)
|
106
|
+
return false
|
107
|
+
end
|
108
|
+
elsif within_time_limit? started_at, opts[:time_limit]
|
109
|
+
# did we exceed max calls?
|
110
|
+
if val[:count] > opts[:max_calls]
|
111
|
+
# start waiting and render the message
|
112
|
+
val[:started_waiting] = Time.now
|
113
|
+
Rails.cache.write(cache_key, val, :expires_in => opts[:wait_time])
|
114
|
+
|
115
|
+
get_message(opts)
|
116
|
+
|
117
|
+
return false
|
118
|
+
else
|
119
|
+
# just update the count
|
120
|
+
Rails.cache.write(cache_key, val, :expires_in => opts[:wait_time])
|
121
|
+
return true
|
122
|
+
end
|
123
|
+
else
|
124
|
+
# we exceeded the time limit, so just reset
|
125
|
+
val = {:started => Time.now, :count => 1}
|
126
|
+
Rails.cache.write(cache_key, val, :expires_in => opts[:time_limit])
|
127
|
+
return true
|
128
|
+
end
|
129
|
+
else
|
130
|
+
val = {:started => Time.now, :count => 1}
|
131
|
+
Rails.cache.write(cache_key, val, :expires_in => opts[:time_limit])
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def within_time_limit?(started_at, limit)
|
136
|
+
Time.now.to_i < (started_at.to_i + limit)
|
137
|
+
end
|
138
|
+
|
139
|
+
# attempts to get the key based on the given option or
|
140
|
+
# will attempt to use the remote address
|
141
|
+
def get_key(opt)
|
142
|
+
key = nil
|
143
|
+
if (opt)
|
144
|
+
if opt.is_a? Proc
|
145
|
+
key = opt.call(self) # passing it the controller instance
|
146
|
+
elsif opt.is_a? Symbol
|
147
|
+
key = self.send(opt) if self.respond_to? opt
|
148
|
+
end
|
149
|
+
else
|
150
|
+
if request.env['HTTP_X_FORWARDED_FOR']
|
151
|
+
key = request.env['HTTP_X_FORWARDED_FOR']
|
152
|
+
else
|
153
|
+
addr = request.remote_addr
|
154
|
+
if (addr == "0.0.0.0" or addr == "127.0.0.1")
|
155
|
+
Rails.logger.warn "attempting to rate limit with a localhost address. Ignoring."
|
156
|
+
return nil
|
157
|
+
else
|
158
|
+
key = addr
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
key
|
164
|
+
end
|
165
|
+
|
166
|
+
def get_message(opts)
|
167
|
+
message = opts[:message]
|
168
|
+
if message
|
169
|
+
if message.is_a? Proc
|
170
|
+
respond_to do |format|
|
171
|
+
message.call(self, opts[:wait_time])
|
172
|
+
end
|
173
|
+
elsif message.is_a? Symbol
|
174
|
+
self.send(message, opts[:wait_time])
|
175
|
+
elsif message.is_a? String
|
176
|
+
render_curbit_message(message, opts)
|
177
|
+
end
|
178
|
+
else
|
179
|
+
message = "Too many requests within the allowed time. Please wait #{opts[:wait_time]} before submitting your request again."
|
180
|
+
render_curbit_message(message, opts)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def render_curbit_message(message, opts)
|
185
|
+
rendered = false
|
186
|
+
respond_to {|format|
|
187
|
+
format.html {
|
188
|
+
render :text => message, :status => opts[:status]
|
189
|
+
rendered = true
|
190
|
+
}
|
191
|
+
format.json {
|
192
|
+
render :json => %[{"error":"#{message}"}], :status => opts[:status]
|
193
|
+
rendered = true
|
194
|
+
}
|
195
|
+
format.xml {
|
196
|
+
render :xml => "<error>#{message}</error>", :status => opts[:status]
|
197
|
+
rendered = true
|
198
|
+
}
|
199
|
+
}
|
200
|
+
if (!rendered)
|
201
|
+
render :text => message, :status => opts[:status]
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'test_rails_helper'
|
3
|
+
|
4
|
+
class MethodController < ActionController::Base
|
5
|
+
|
6
|
+
include Curbit::Controller
|
7
|
+
|
8
|
+
def index
|
9
|
+
render :text => 'index action'
|
10
|
+
end
|
11
|
+
|
12
|
+
rate_limit :index, :key => :username,
|
13
|
+
:max_calls => 2,
|
14
|
+
:time_limit => 30.seconds,
|
15
|
+
:wait_time => 1.minute
|
16
|
+
|
17
|
+
def show
|
18
|
+
render :text => 'show action'
|
19
|
+
end
|
20
|
+
|
21
|
+
rate_limit :show, :key => proc {|c| "my_key"},
|
22
|
+
:max_calls => 2,
|
23
|
+
:time_limit => 30.seconds,
|
24
|
+
:wait_time => 1.minute
|
25
|
+
|
26
|
+
protected
|
27
|
+
|
28
|
+
def username
|
29
|
+
"codemariner"
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
class MethodControllerTest < ActionController::TestCase
|
36
|
+
tests MethodController
|
37
|
+
|
38
|
+
context "When calling a rate_limited method with a key argument that is a symbol it" do
|
39
|
+
setup {
|
40
|
+
Rails.cache = mock()
|
41
|
+
@env = {'HTTP_X_FORWARDED_FOR' => '192.168.1.123'}
|
42
|
+
@request.stubs(:env).returns(@env)
|
43
|
+
Rails.cache.stubs(:write)
|
44
|
+
}
|
45
|
+
should "call the specified method to use as part of the cache key" do
|
46
|
+
Rails.cache.expects(:read).with(Curbit::Controller::CacheKeyPrefix + "_#{MethodController.name}_index_codemariner").at_least_once
|
47
|
+
get :index
|
48
|
+
assert_equal "index action", @response.body
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
context "When calling a rate_limited method with a key argument that is a Proc it" do
|
53
|
+
setup {
|
54
|
+
Rails.cache = mock()
|
55
|
+
@env = {'HTTP_X_FORWARDED_FOR' => '192.168.1.123'}
|
56
|
+
@request.stubs(:env).returns(@env)
|
57
|
+
Rails.cache.stubs(:write)
|
58
|
+
}
|
59
|
+
should "call the Proc to use the returned value as part of the cache key" do
|
60
|
+
Rails.cache.expects(:read).with(Curbit::Controller::CacheKeyPrefix + "_#{MethodController.name}_show_my_key").at_least_once
|
61
|
+
get :show
|
62
|
+
assert_equal "show action", @response.body
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'test_rails_helper'
|
3
|
+
|
4
|
+
class MessageController < ActionController::Base
|
5
|
+
|
6
|
+
include Curbit::Controller
|
7
|
+
|
8
|
+
attr_accessor :rendered
|
9
|
+
|
10
|
+
def index
|
11
|
+
render :text => 'index action'
|
12
|
+
end
|
13
|
+
|
14
|
+
rate_limit :index, :max_calls => 2,
|
15
|
+
:time_limit => 30.seconds,
|
16
|
+
:wait_time => 2.minute,
|
17
|
+
:message => :limit_message
|
18
|
+
|
19
|
+
protected
|
20
|
+
|
21
|
+
def limit_message(wait_time)
|
22
|
+
respond_to {|format|
|
23
|
+
message = "Please wait #{wait_time/60} minutes before trying again"
|
24
|
+
format.html {
|
25
|
+
render :text => message, :status => 103
|
26
|
+
}
|
27
|
+
format.json {
|
28
|
+
render :json => %[{"error":"#{message}"}], :status => 503
|
29
|
+
}
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
class MessageControllerTest < ActionController::TestCase
|
37
|
+
tests MessageController
|
38
|
+
|
39
|
+
context "When calling a rate limited method using a message value of a" do
|
40
|
+
setup {
|
41
|
+
@env = {'HTTP_X_FORWARDED_FOR' => '192.168.1.123'}
|
42
|
+
@request.stubs(:env).returns(@env)
|
43
|
+
cache_value = {:started => Time.now.to_i - 15.seconds,
|
44
|
+
:count => 2
|
45
|
+
}
|
46
|
+
Rails.cache.stubs(:read).returns(cache_value)
|
47
|
+
Rails.cache.stubs(:write)
|
48
|
+
}
|
49
|
+
context "symbol" do
|
50
|
+
context "for a json request format, it" do
|
51
|
+
should "call a method named by the symbol with the specified wait_time" do
|
52
|
+
get :index, :format => "json"
|
53
|
+
assert_equal true, @response.body.include?("error")
|
54
|
+
assert_equal "503 Service Unavailable", @response.status
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
context "and an html request format, it" do
|
59
|
+
should "call a method named by the symbol with the specified wait_time" do
|
60
|
+
get :index
|
61
|
+
assert_equal true, @response.body.include?("wait")
|
62
|
+
assert_equal "103", @response.status
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
@@ -0,0 +1,119 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'test_rails_helper'
|
3
|
+
|
4
|
+
class TestController < ActionController::Base
|
5
|
+
|
6
|
+
include Curbit::Controller
|
7
|
+
|
8
|
+
def index
|
9
|
+
render :text => 'index action'
|
10
|
+
end
|
11
|
+
|
12
|
+
rate_limit :index, :max_calls => 2,
|
13
|
+
:time_limit => 30.seconds,
|
14
|
+
:wait_time => 1.minute
|
15
|
+
|
16
|
+
def show
|
17
|
+
render :text => 'show action'
|
18
|
+
end
|
19
|
+
|
20
|
+
rate_limit :show, :max_calls => 2,
|
21
|
+
:time_limit => 30.seconds,
|
22
|
+
:wait_time => 1.minute,
|
23
|
+
:status => 200
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
class CurbiControllerTest < ActionController::TestCase
|
28
|
+
tests TestController
|
29
|
+
|
30
|
+
context "A controller including Curbit" do
|
31
|
+
should "have a rate_limit class method" do
|
32
|
+
assert_equal true, TestController.respond_to?(:rate_limit)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "When declaring a rate_limit method, there " do
|
37
|
+
should "be a new rate_limit_method method added to the instance" do
|
38
|
+
assert_equal true, @controller.respond_to?(:rate_limit_index)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context "When calling a rate_limited method" do
|
43
|
+
setup {
|
44
|
+
Rails.cache = mock()
|
45
|
+
}
|
46
|
+
|
47
|
+
context "without a designated key argument" do
|
48
|
+
context "and the remote client address is forwarded from a proxy, it" do
|
49
|
+
setup {
|
50
|
+
@env = {'HTTP_X_FORWARDED_FOR' => '192.168.1.123'}
|
51
|
+
@request.stubs(:env).returns(@env)
|
52
|
+
@cache_key = nil
|
53
|
+
Rails.cache.expects(:write).with() {|key, val, duration|
|
54
|
+
@cache_key = key
|
55
|
+
true
|
56
|
+
}
|
57
|
+
Rails.cache.expects(:read).returns(nil)
|
58
|
+
}
|
59
|
+
|
60
|
+
should "use a default key that is derived from request.env['HTTP_X_FORWARDED_FOR']" do
|
61
|
+
get :index
|
62
|
+
ip = @env['HTTP_X_FORWARDED_FOR']
|
63
|
+
pfx = Curbit::Controller::CacheKeyPrefix
|
64
|
+
assert_equal "#{pfx}_#{TestController.name}_index_#{ip}", @cache_key
|
65
|
+
end
|
66
|
+
end #context: and the remote client address is...
|
67
|
+
|
68
|
+
|
69
|
+
context "and the remote client address is a localhost address, it" do
|
70
|
+
setup {
|
71
|
+
@request.stubs(:remote_addr).returns("0.0.0.0")
|
72
|
+
Rails.cache.expects(:read).never()
|
73
|
+
}
|
74
|
+
should "ignore rate limiting" do
|
75
|
+
get :index
|
76
|
+
assert_equal "index action", @response.body
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
end #context: without a designated key argument...
|
81
|
+
|
82
|
+
context "from a remote client" do
|
83
|
+
setup {
|
84
|
+
@env = {'HTTP_X_FORWARDED_FOR' => '192.168.1.123'}
|
85
|
+
@request.stubs(:env).returns(@env)
|
86
|
+
}
|
87
|
+
context "and max calls has been exceeded for the current time limit" do
|
88
|
+
setup {
|
89
|
+
cache_value = {:started => Time.now.to_i - 15.seconds,
|
90
|
+
:count => 1
|
91
|
+
}
|
92
|
+
Rails.cache.stubs(:read).returns(cache_value)
|
93
|
+
Rails.cache.stubs(:write)
|
94
|
+
}
|
95
|
+
context ", the call" do
|
96
|
+
should "be blocked" do
|
97
|
+
get :index
|
98
|
+
assert_equal "index action", @response.body
|
99
|
+
get :index
|
100
|
+
assert_equal true, @response.body.include?("wait")
|
101
|
+
# default status on a limit
|
102
|
+
assert_equal "503 Service Unavailable", @response.status
|
103
|
+
end
|
104
|
+
|
105
|
+
should "be blocked and render a custom status when specified" do
|
106
|
+
get :show
|
107
|
+
assert_equal "show action", @response.body
|
108
|
+
get :show
|
109
|
+
assert_equal true, @response.body.include?("wait")
|
110
|
+
# default status on a limit
|
111
|
+
assert_equal "200 OK", @response.status
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
end #context: when calling a rate limited method...
|
118
|
+
|
119
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# rails setup
|
2
|
+
ENV["RAILS_ENV"] = "test"
|
3
|
+
RAILS_ROOT = "wherever"
|
4
|
+
|
5
|
+
require 'active_support'
|
6
|
+
require 'action_controller'
|
7
|
+
require 'action_controller/test_case'
|
8
|
+
require 'action_controller/test_process'
|
9
|
+
|
10
|
+
|
11
|
+
class ApplicationController < ActionController::Base; end
|
12
|
+
|
13
|
+
|
14
|
+
# add curbit to load path and init
|
15
|
+
ActiveSupport::Dependencies.load_paths << File.expand_path(File.dirname(__FILE__) + '/../lib')
|
16
|
+
require_dependency 'curbit'
|
17
|
+
|
18
|
+
|
19
|
+
ActionController::Base.view_paths = File.join(File.dirname(__FILE__), 'views')
|
20
|
+
ActionController::Routing::Routes.draw do |map|
|
21
|
+
map.connect ':controller/:action/:id'
|
22
|
+
end
|
23
|
+
|
24
|
+
require 'ostruct'
|
25
|
+
|
26
|
+
# stub out a rails cache object
|
27
|
+
Rails = OpenStruct.new
|
28
|
+
|
29
|
+
Rails.logger = Logger.new("/dev/null")
|
metadata
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: curbit
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Scott Sayles
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-10-25 00:00:00 -04:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: Application level rate limiting for Rails
|
17
|
+
email: ssayles@users.sourceforge.net
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- LICENSE
|
24
|
+
- README.rdoc
|
25
|
+
- lib/curbit.rb
|
26
|
+
files:
|
27
|
+
- LICENSE
|
28
|
+
- README.rdoc
|
29
|
+
- Rakefile
|
30
|
+
- init.rb
|
31
|
+
- lib/curbit.rb
|
32
|
+
- test/custom_key_controller_test.rb
|
33
|
+
- test/custom_message_format_controller.rb
|
34
|
+
- test/standard_controller_test.rb
|
35
|
+
- test/test_helper.rb
|
36
|
+
- test/test_rails_helper.rb
|
37
|
+
- Manifest
|
38
|
+
- curbit.gemspec
|
39
|
+
has_rdoc: true
|
40
|
+
homepage: http://github.com/ssayles/curbit
|
41
|
+
licenses: []
|
42
|
+
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options:
|
45
|
+
- --line-numbers
|
46
|
+
- --inline-source
|
47
|
+
- --title
|
48
|
+
- Curbit
|
49
|
+
- --main
|
50
|
+
- README.rdoc
|
51
|
+
require_paths:
|
52
|
+
- lib
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: "0"
|
58
|
+
version:
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - ">="
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: "1.2"
|
64
|
+
version:
|
65
|
+
requirements: []
|
66
|
+
|
67
|
+
rubyforge_project: curbit
|
68
|
+
rubygems_version: 1.3.5
|
69
|
+
signing_key:
|
70
|
+
specification_version: 3
|
71
|
+
summary: Application level rate limiting for Rails
|
72
|
+
test_files:
|
73
|
+
- test/custom_key_controller_test.rb
|
74
|
+
- test/standard_controller_test.rb
|
75
|
+
- test/test_helper.rb
|
76
|
+
- test/test_rails_helper.rb
|