curbit 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ Manifest
3
+ README.rdoc
4
+ Rakefile
5
+ curbit.gemspec
6
+ init.rb
7
+ lib/curbit.rb
8
+ test/custom_key_controller_test.rb
9
+ test/custom_message_format_controller.rb
10
+ test/standard_controller_test.rb
11
+ test/test_helper.rb
12
+ test/test_rails_helper.rb
@@ -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.
@@ -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
@@ -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)
@@ -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
@@ -0,0 +1,9 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'mocha'
5
+
6
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
7
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
8
+ require 'curbit'
9
+
@@ -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