bot-away 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,4 @@
1
+ === 0.0.1 2010-03-24
2
+
3
+ * 1 major enhancement:
4
+ * Initial release
@@ -0,0 +1,23 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.rdoc
4
+ Rakefile
5
+ lib/bot-away.rb
6
+ lib/bot-away/action_controller/request.rb
7
+ lib/bot-away/action_view/helpers/instance_tag.rb
8
+ lib/bot-away/param_parser.rb
9
+ lib/bot-away/spinner.rb
10
+ script/console
11
+ script/destroy
12
+ script/generate
13
+ spec/controllers/test_controller_spec.rb
14
+ spec/lib/action_view/helpers/instance_tag_spec.rb
15
+ spec/lib/builder_spec.rb
16
+ spec/lib/param_parser_spec.rb
17
+ spec/spec_helper.rb
18
+ spec/support/controllers/test_controller.rb
19
+ spec/support/honeypot_matcher.rb
20
+ spec/support/obfuscation_helper.rb
21
+ spec/support/obfuscation_matcher.rb
22
+ spec/support/views/test/index.html.erb
23
+ spec/support/views/test/model_form.html.erb
@@ -0,0 +1,88 @@
1
+ = bot-away
2
+
3
+ * http://github.com/sinisterchipmunk/bot-away
4
+
5
+ == DESCRIPTION:
6
+
7
+ Unobtrusively detects form submissions made by spambots, and silently drops those submissions. The key word here is
8
+ "unobtrusive" -- this is NOT a CAPTCHA. This is transparent, modular implementation of the bot-catching techniques
9
+ discussed by Ned Batchelder at http://nedbatchelder.com/text/stopbots.html
10
+
11
+ If a submission is detected, the params hash is cleared, so the data can't be used. Since this includes the authenticity
12
+ token, Rails should barf due to an invalid or missing authenticity token. Congrats, spam blocked.
13
+
14
+ The specifics of the techniques employed for filtering spambots are discussed Ned's site at the above location; however,
15
+ here's a brief run-down of what's going on:
16
+
17
+ * Your code stays the same. After the bot-away gem has been activated, all Rails-generated forms on your site
18
+ will automatically be transformed into bot-resistent forms.
19
+ * All of the form elements that you create (for instance, a "comment" model with a "body" field) are turned into
20
+ dummy elements, or honeypots, and are made invisible to the end user. This is done using div elements and inline CSS
21
+ stylesheets. There are several ways an element can be hidden, and these approaches are chosen at random to help
22
+ minimize predictability. In the rare event that a real user actually can see the element, it has a label next to it
23
+ along the lines of "Leave this blank" -- though the exact message is randomized to help prevent detection.
24
+ * All of the form elements are mirrored by hashes. The hashes are generated using the session's authenticity token,
25
+ so they can't be predicted.
26
+ * When data is submitted, bot-away steps in and 1.) validates that no honeypots have been filled in; and 2)
27
+ converts the hashed elements back into the field names that you are expecting (replacing the honeypot fields).
28
+ * If a honeypot has been filled in, or a hashed element is missing where it was expected, then the request is
29
+ considered to be either spam, or tampered with; and the entire params hash is emptied. Since this happens at the
30
+ lowest level, the most likely result is that Rails will complain that the user's authenticity token is invalid. If
31
+ that does not happen, then your code will be passed a params hash containing only a "suspected_bot" key, and an error
32
+ will result. Either way, the spambot has been foiled!
33
+
34
+ == FEATURES/PROBLEMS:
35
+
36
+ * Wherever protection from forgery is not enabled in your Rails app, the Rails forms will be generated as if this gem
37
+ did not exist. That means hashed elements won't be generated, honeypots won't be generated, and posted forms will not
38
+ be intercepted.
39
+
40
+ * By default, protection from forgery is enabled for all Rails controllers, so by default the above-mentioned checks
41
+ will also be triggered. For more details on forgery protection, see:
42
+ http://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection/ClassMethods.html
43
+
44
+ * The techniques implemented by this library will be very difficult for a spambot to circumvent. However, keep in mind
45
+ that since the pages have to be machine-readable by definition, and since this gem has to follow certain protocols
46
+ in order to avoid confusing lots of humans (such as hiding the honeypots), it is always theoretically possible for
47
+ a spambot to get around it. It's just very, very difficult.
48
+
49
+ == REQUIREMENTS:
50
+
51
+ * Rails 2.3.5 or better.
52
+
53
+ == INSTALL:
54
+
55
+ * sudo gem install bot-away
56
+
57
+ == USAGE:
58
+
59
+ In your Rails config/environment.rb:
60
+
61
+ * config.gem 'bot-away'
62
+
63
+ That's it.
64
+
65
+ == LICENSE:
66
+
67
+ (The MIT License)
68
+
69
+ Copyright (c) 2010 Colin MacKenzie IV
70
+
71
+ Permission is hereby granted, free of charge, to any person obtaining
72
+ a copy of this software and associated documentation files (the
73
+ 'Software'), to deal in the Software without restriction, including
74
+ without limitation the rights to use, copy, modify, merge, publish,
75
+ distribute, sublicense, and/or sell copies of the Software, and to
76
+ permit persons to whom the Software is furnished to do so, subject to
77
+ the following conditions:
78
+
79
+ The above copyright notice and this permission notice shall be
80
+ included in all copies or substantial portions of the Software.
81
+
82
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
83
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
84
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
85
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
86
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
87
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
88
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,45 @@
1
+ require 'rubygems'
2
+ gem 'hoe', '>= 2.1.0'
3
+ require 'hoe'
4
+ require 'fileutils'
5
+ require './lib/bot-away'
6
+
7
+ Hoe.plugin :newgem
8
+ # Hoe.plugin :website
9
+ # Hoe.plugin :cucumberfeatures
10
+
11
+ # Generate all the Rake tasks
12
+ # Run 'rake -T' to see list of generated tasks (from gem root directory)
13
+ $hoe = Hoe.spec 'bot-away' do
14
+ self.developer 'Colin MacKenzie IV', 'sinisterchipmunk@gmail.com'
15
+ self.extra_deps = [['actionpack','>= 2.3.5'],['sc-core-ext','>= 1.1.1']]
16
+ self.readme_file = "README.rdoc"
17
+ end
18
+
19
+ Rake::RDocTask.new(:docs) do |rdoc|
20
+ files = ['README.rdoc', # 'LICENSE', 'CHANGELOG',
21
+ 'lib/**/*.rb', 'doc/**/*.rdoc']#, 'spec/*.rb']
22
+ rdoc.rdoc_files.add(files)
23
+ rdoc.main = 'README.rdoc'
24
+ rdoc.title = 'EVE Documentation'
25
+ #rdoc.template = '/path/to/gems/allison-2.0/lib/allison'
26
+ rdoc.rdoc_dir = 'doc'
27
+ rdoc.options << '--line-numbers' << '--inline-source'
28
+ end
29
+
30
+
31
+ require 'newgem/tasks'
32
+ Dir['tasks/**/*.rake'].each { |t| load t }
33
+
34
+ require 'spec/rake/spectask'
35
+
36
+ desc "Run all examples with RCov"
37
+ Spec::Rake::SpecTask.new('rcov') do |t|
38
+ t.spec_files = FileList['spec/**/*_spec.rb']
39
+ t.rcov = true
40
+ t.rcov_opts = ['--exclude', 'spec,/home/*']
41
+ end
42
+
43
+ # TODO - want other tests/tasks run by default? Add them to the list
44
+ # remove_task :default
45
+ # task :default => [:spec, :features]
@@ -0,0 +1,19 @@
1
+ $:.unshift(File.dirname(__FILE__)) unless
2
+ $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
3
+
4
+ require 'rubygems' unless defined?(Gem)
5
+ require 'action_controller'
6
+ require 'action_view'
7
+ require 'sc-core-ext'
8
+
9
+ require 'bot-away/param_parser'
10
+ require 'bot-away/action_controller/request'
11
+ require 'bot-away/action_view/helpers/instance_tag'
12
+ require 'bot-away/spinner'
13
+
14
+ module BotAway
15
+ VERSION = '1.0.0'
16
+ end
17
+
18
+ # WHY do I have to do this???
19
+ ActionView::Base.send :include, ActionView::Helpers
@@ -0,0 +1,8 @@
1
+ class ActionController::Request < Rack::Request
2
+ def parameters_with_deobfuscation
3
+ @parameters ||= BotAway::ParamParser.new(ip, parameters_without_deobfuscation).params
4
+ end
5
+
6
+ alias_method_chain :parameters, :deobfuscation
7
+ alias_method :params, :parameters
8
+ end
@@ -0,0 +1,92 @@
1
+ class ActionView::Helpers::InstanceTag
2
+ attr_reader :spinner
3
+
4
+ def initialize_with_spinner(object_name, method_name, template_object, object = nil)
5
+ initialize_without_spinner(object_name, method_name, template_object, object)
6
+ if template_object.controller.send(:protect_against_forgery?)
7
+ @spinner = BotAway::Spinner.new(template_object.request.ip, object_name, template_object.form_authenticity_token)
8
+ end
9
+ end
10
+
11
+ def obfuscate_options(options)
12
+ add_default_name_and_id(options)
13
+ assuming(spinner && options) do
14
+ options['name'] &&= spinner.encode(options['name'])
15
+ options['id'] &&= spinner.encode(options['id'])
16
+ end
17
+ end
18
+
19
+ def honeypot_options(options)
20
+ add_default_name_and_id(options)
21
+ assuming(spinner && options) do
22
+ options['value'] &&= ''
23
+ options['autocomplete'] = 'off'
24
+ end
25
+ end
26
+
27
+ def assuming(object)
28
+ yield if object
29
+ object
30
+ end
31
+
32
+ def honeypot_tag(name, options = nil, *args)
33
+ tag_without_honeypot(name, honeypot_options(options.dup? || {}), *args)
34
+ end
35
+
36
+ def obfuscated_tag(name, options = nil, *args)
37
+ tag_without_honeypot(name, obfuscate_options(options.dup? || {}), *args)
38
+ end
39
+
40
+ def tag_with_honeypot(name, options = nil, *args)
41
+ if spinner
42
+ obfuscated_tag(name, options, *args) + disguise(honeypot_tag(name, options, *args))
43
+ else
44
+ tag_without_honeypot(name, options, *args)
45
+ end
46
+ end
47
+
48
+ # Special case
49
+ def to_label_tag_with_obfuscation(text = nil, options = {})
50
+ # TODO: Can this be simplified? It's pretty similar to to_label_tag_without_obfuscation...
51
+ options = options.stringify_keys
52
+ tag_value = options.delete("value")
53
+ name_and_id = options.dup
54
+ name_and_id["id"] = name_and_id["for"]
55
+ add_default_name_and_id_for_value(tag_value, name_and_id)
56
+ options["for"] ||= name_and_id["id"]
57
+ options["for"] = spinner.encode(options["for"]) if spinner && options["for"]
58
+ to_label_tag_without_obfuscation(text, options)
59
+ end
60
+
61
+ def content_tag_with_obfuscation(name, content_or_options_with_block = nil, options = nil, *args, &block)
62
+ if block_given?
63
+ content_tag_without_obfuscation(name, content_or_options_with_block, options, *args, &block)
64
+ else
65
+ # this should cover all Rails selects.
66
+ if spinner && options && (options.keys.include?('id') || options.keys.include?('name'))
67
+ disguise(content_tag_without_obfuscation(name, '', honeypot_options(options), *args)) +
68
+ content_tag_without_obfuscation(name, content_or_options_with_block, obfuscate_options(options), *args)
69
+ else
70
+ content_tag_without_obfuscation(name, content_or_options_with_block, options, *args)
71
+ end
72
+ end
73
+ end
74
+
75
+ alias_method_chain :initialize, :spinner
76
+ alias_method_chain :tag, :honeypot
77
+ alias_method_chain :to_label_tag, :obfuscation
78
+ alias_method_chain :content_tag, :obfuscation
79
+
80
+ def disguise(element)
81
+ case rand(3)
82
+ when 0 # Hidden
83
+ "<div style='display:none;'>Leave this empty: #{element}</div>"
84
+ when 1 # Off-screen
85
+ "<div style='position:absolute;left:-1000px;top:-1000px;'>Don't fill this in: #{element}</div>"
86
+ when 2 # Negligible size
87
+ "<div style='position:absolute;width:0px;height:1px;z-index:-1;color:transparent;overflow:hidden;'>Keep this blank: #{element}</div>"
88
+ else # this should never happen?
89
+ disguise(element)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,36 @@
1
+ module BotAway
2
+ class ParamParser
3
+ class ObfuscationMissing < StandardError; end #:nodoc:
4
+
5
+ attr_reader :params, :ip, :authenticity_token
6
+
7
+ def initialize(ip, params, authenticity_token = params[:authenticity_token])
8
+ @ip, @params, @authenticity_token = ip, params, authenticity_token
9
+ if authenticity_token
10
+ if catch(:bastard) { deobfuscate! } == :took_the_bait
11
+ params.clear
12
+ params[:suspected_bot] = true
13
+ end
14
+ end
15
+ end
16
+
17
+ def deobfuscate!(current = params, object_name = nil)
18
+ if object_name
19
+ spinner = BotAway::Spinner.new(ip, object_name, authenticity_token)
20
+ end
21
+
22
+ current.each do |key, value|
23
+ if object_name
24
+ if value.blank? && params.keys.include?(spun_key = spinner.encode("#{object_name}[#{key}]"))
25
+ current[key] = params.delete(spun_key)
26
+ else
27
+ throw :bastard, :took_the_bait
28
+ end
29
+ end
30
+ if value.kind_of?(Hash)
31
+ deobfuscate!(value, object_name ? "#{object_name}[#{key}]" : key)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ class BotAway::Spinner
2
+ def initialize(ip, key, secret)
3
+ raise "Shouldn't have a nil ip" unless ip
4
+ raise "Shouldn't have a nil secret" unless secret
5
+ secret = File.join(#Time.now.to_i.to_s,
6
+ ip,
7
+ key.to_s,
8
+ secret)
9
+
10
+ #puts secret
11
+ @spinner = Digest::MD5.hexdigest(secret)
12
+ #puts @spinner
13
+ end
14
+
15
+ def spinner
16
+ @spinner
17
+ end
18
+
19
+ def encode(real_field_name)
20
+ Digest::MD5.hexdigest(File.join(real_field_name.to_s, spinner))
21
+ end
22
+ end
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # File: script/console
3
+ irb = RUBY_PLATFORM =~ /(:?mswin|mingw)/ ? 'irb.bat' : 'irb'
4
+
5
+ libs = " -r irb/completion"
6
+ # Perhaps use a console_lib to store any extra methods I may want available in the cosole
7
+ # libs << " -r #{File.dirname(__FILE__) + '/../lib/console_lib/console_logger.rb'}"
8
+ libs << " -r #{File.dirname(__FILE__) + '/../lib/bot-away.rb'}"
9
+ puts "Loading bot-away gem"
10
+ exec "#{irb} #{libs} --simple-prompt"
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
3
+
4
+ begin
5
+ require 'rubigen'
6
+ rescue LoadError
7
+ require 'rubygems'
8
+ require 'rubigen'
9
+ end
10
+ require 'rubigen/scripts/destroy'
11
+
12
+ ARGV.shift if ['--help', '-h'].include?(ARGV[0])
13
+ RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
14
+ RubiGen::Scripts::Destroy.new.run(ARGV)
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+ APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..'))
3
+
4
+ begin
5
+ require 'rubigen'
6
+ rescue LoadError
7
+ require 'rubygems'
8
+ require 'rubigen'
9
+ end
10
+ require 'rubigen/scripts/generate'
11
+
12
+ ARGV.shift if ['--help', '-h'].include?(ARGV[0])
13
+ RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme, :test_unit]
14
+ RubiGen::Scripts::Generate.new.run(ARGV)
@@ -0,0 +1,128 @@
1
+ require 'spec_helper'
2
+
3
+ describe TestController do
4
+ include ActionController::TestProcess
5
+
6
+ def prepare!(action = 'index', method = 'get')
7
+ @controller.request = @request
8
+ @controller.params = {}
9
+ @controller.send(:initialize_current_url)
10
+ send(method, action)
11
+ if @response.template.instance_variable_get("@exception")
12
+ raise @response.template.instance_variable_get("@exception")
13
+ end
14
+ end
15
+
16
+ before :each do
17
+ @request = ActionController::TestRequest.new
18
+ # can the authenticity token so that we can predict the generated element names
19
+ @request.session[:_csrf_token] = 'aVjGViz+pIphXt2pxrWfXgRXShOI0KXOILR23yw0WBo='
20
+ @request.remote_addr = '208.77.188.166' # example.com
21
+ @response = ActionController::TestResponse.new
22
+ @controller = TestController.new
23
+ end
24
+
25
+ after :each do
26
+ # effectively disables forgery protection.
27
+ TestController.request_forgery_protection_token = nil
28
+ end
29
+
30
+ context "with a model" do
31
+ context "with forgery protection" do
32
+ before :each do
33
+ (class << @controller; self; end).send(:protect_from_forgery)
34
+ prepare!('model_form')
35
+ end
36
+
37
+ it "should work?" do
38
+ puts @response.body
39
+ end
40
+ end
41
+
42
+ context "without forgery protection" do
43
+ before :each do
44
+ prepare!('model_form')
45
+ end
46
+
47
+ it "should work?" do
48
+ puts @response.body
49
+ end
50
+ end
51
+ end
52
+
53
+ context "with forgery protection" do
54
+ before :each do
55
+ (class << @controller; self; end).send(:protect_from_forgery)
56
+ prepare!
57
+ end
58
+ #"object_name_method_name" name="object_name[method_name]" size="30" type="text" value="" /></div>
59
+ #<input id="e21372563297c728093bf74c3cb6b96c" name="a0844d45bf150668ff1d86a6eb491969" size="30" type="text" value="method_value" />
60
+
61
+ it "processes valid obfuscated form post" do
62
+ form = { 'authenticity_token' => '1234',
63
+ 'object_name' => { 'method_name' => '' },
64
+ '842d8d1c80014ce9f3d974614338605c' => 'some_value'
65
+ }
66
+ post 'proc_form', form
67
+ puts @response.body
68
+ @response.template.controller.params[:object_name].should == { 'method_name' => 'some_value' }
69
+ end
70
+
71
+ it "drops invalid obfuscated form post" do
72
+ form = { 'authenticity_token' => '1234',
73
+ 'object_name' => { 'method_name' => 'test' },
74
+ '842d8d1c80014ce9f3d974614338605c' => 'some_value'
75
+ }
76
+ post 'proc_form', form
77
+ puts @response.body
78
+ @response.template.controller.params.should == { 'suspected_bot' => true }
79
+ end
80
+
81
+ it "does not drop valid authentication request" do
82
+ #@request.session[:_csrf_token] = 'yPgTAsngzpBO8k1v83RGH26sTrQYD50Ou2oiMT4r/iw='
83
+ form = { 'authenticity_token' => 'yPgTAsngzpBO8k1v83RGH26sTrQYD50Ou2oiMT4r/iw=',
84
+ 'user_session' => {
85
+ 'login' => '',
86
+ 'password' => '',
87
+ 'remember_me' => ''
88
+ },
89
+ 'commit' => 'Log In',
90
+ '89dce8f562b119a2f88da6d29f535a0d' => 'admin',
91
+ '4b9bab79bc1b1cd5229041c357750e0c' => 'pwpwpw',
92
+ '256307a36284445cc84014dae651f2ed' => '1'
93
+ }
94
+ @request.remote_addr = '127.0.0.1'
95
+ post 'proc_form', form
96
+ puts @response.template.controller.params.inspect
97
+ @response.template.controller.params.should == { 'action' => 'proc_form', 'controller' => 'test',
98
+ 'authenticity_token' => 'yPgTAsngzpBO8k1v83RGH26sTrQYD50Ou2oiMT4r/iw=',
99
+ 'user_session' => {
100
+ 'login' => 'admin',
101
+ 'password' => 'pwpwpw',
102
+ 'remember_me' => '1'
103
+ },
104
+ 'commit' => 'Log In'
105
+ }
106
+ end
107
+ end
108
+
109
+ context "without forgery protection" do
110
+ before :each do
111
+ prepare!
112
+ end
113
+
114
+ it "processes non-obfuscated form post" do
115
+ form = { #'authenticity_token' => '1234',
116
+ 'object_name' => { 'method_name' => 'test' }
117
+ }
118
+ post 'proc_form', form
119
+ puts @response.body
120
+ @response.template.controller.params.should_not == { 'suspected_bot' => true }
121
+ @response.template.controller.params[:object_name].should == { 'method_name' => 'test' }
122
+ end
123
+
124
+ it "produces non-obfuscated form elements" do
125
+ @response.body.should_not match(/<\/div><input/)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,69 @@
1
+ require 'spec_helper'
2
+
3
+ def template
4
+ return @response if @response
5
+ @response = TestController.call(Rack::MockRequest.env_for('/').merge({'REQUEST_URI' => '/',
6
+ 'REMOTE_ADDR' => '127.0.0.1'}))
7
+ @response.template.controller.request_forgery_protection_token = :authenticity_token
8
+ @response.template.controller.session[:_csrf_token] = '1234'
9
+ @response.template
10
+ end
11
+
12
+ def mock_object
13
+ @mock_object ||= MockObject.new
14
+ end
15
+
16
+ def default_instance_tag
17
+ ActionView::Helpers::InstanceTag.new("object_name", "method_name", template, mock_object)
18
+ end
19
+
20
+ describe ActionView::Helpers::InstanceTag do
21
+ subject { default_instance_tag }
22
+
23
+ context "with a valid text area tag" do
24
+ subject do
25
+ dump { default_instance_tag.to_text_area_tag }
26
+ end
27
+
28
+ it "should produce blank honeypot value" do
29
+ subject.should_not =~ /name="object_name\[method_name\]"[^>]+>method_value<\/textarea>/
30
+ end
31
+ end
32
+
33
+ context "with a valid input type=text tag" do
34
+ before(:each) { @tag_options = ["input", {:type => 'text', 'name' => 'object_name[method_name]', 'id' => 'object_name_method_name', 'value' => 'method_value'}] }
35
+ #subject { dump { default_instance_tag.tag(*@tag_options) } }
36
+
37
+ it "should turn off autocomplete for honeypots" do
38
+ subject.honeypot_tag(*@tag_options).should =~ /autocomplete="off"/
39
+ end
40
+
41
+ it "should obfuscate tag name" do
42
+ subject.obfuscated_tag(*@tag_options).should =~ /name="a0844d45bf150668ff1d86a6eb491969"/
43
+ end
44
+
45
+ it "should obfuscate tag id" do
46
+ subject.obfuscated_tag(*@tag_options).should =~ /id="e21372563297c728093bf74c3cb6b96c"/
47
+ end
48
+
49
+ it "should not obfuscate tag value" do
50
+ subject.obfuscated_tag(*@tag_options).should_not =~ /value="5a6a50d5fd0b5c8b1190d87eb0057e47"/
51
+ end
52
+
53
+ it "should include unobfuscated tag value" do
54
+ subject.obfuscated_tag(*@tag_options).should =~ /value="method_value"/
55
+ end
56
+
57
+ it "should create honeypot name" do
58
+ subject.honeypot_tag(*@tag_options).should =~ /name="object_name\[method_name\]"/
59
+ end
60
+
61
+ it "should create honeypot id" do
62
+ subject.honeypot_tag(*@tag_options).should =~ /id="object_name_method_name"/
63
+ end
64
+
65
+ it "should create empty honeypot tag value" do
66
+ subject.honeypot_tag(*@tag_options).should =~ /value=""/
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,48 @@
1
+ ###
2
+ # The original implementation of BotAway extended ActionView::Helpers::FormBuilder, and these tests were written
3
+ # for it. This approach has since been abandoned in favor of a direct override of ActionView::Helpers::InstanceTag for
4
+ # reasons of efficiency. The FormBuilder tests have been kept around simply for an extra layer of functional testing.
5
+ ###
6
+
7
+ require 'spec_helper'
8
+
9
+ class MockObject; attr_accessor :method_name; def initialize; @method_name = 'method_value'; end; end
10
+
11
+ describe ActionView::Helpers::FormBuilder do
12
+ subject { builder }
13
+
14
+ it "should not create honeypots with default values" do
15
+ builder.text_field(:method_name).should match(/name="object_name\[method_name\]"[^>]*?value=""/)
16
+ end
17
+
18
+ # select(method, choices, options = {}, html_options = {})
19
+ obfuscates(:select) { builder.select(:method_name, {1 => :a, 2 => :b }) }
20
+
21
+ #collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
22
+ obfuscates(:collection_select) { builder.collection_select method_name, [MockObject.new], :method_name, :method_name }
23
+
24
+ #grouped_collection_select(method, collection, group_method, group_label_method, option_key_method,
25
+ # option_value_method, options = {}, html_options = {})
26
+ obfuscates(:grouped_collection_select) do
27
+ builder.grouped_collection_select method_name, [MockObject.new], :method_name, :method_name, :to_s, :to_s
28
+ end
29
+
30
+ #time_zone_select(method, priority_zones = nil, options = {}, html_options = {})
31
+ obfuscates(:time_zone_select) do
32
+ builder.time_zone_select method_name
33
+ end
34
+
35
+ %w(hidden_field text_field text_area file_field password_field check_box).each do |field|
36
+ obfuscates(field) { builder.send(field, method_name) }
37
+ end
38
+
39
+ obfuscates(:radio_button, '53640013be550817d040597218884288') { builder.radio_button method_name, :value }
40
+
41
+ context "#label" do
42
+ subject { dump { builder.label(method_name) } }
43
+
44
+ it "links labels to their obfuscated elements" do
45
+ subject.should match(/for=\"e21372563297c728093bf74c3cb6b96c\"/)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ describe BotAway::ParamParser do
2
+ def params(honeypots)
3
+ @params = { 'authenticity_token' => '1234',
4
+ 'bdf3e1964ac3a82c5f1c7385ed0100e4' => 'colin',
5
+ 'ed8fc4fe67d66bc7aeaf0b4817bd1311' => [1, 2]
6
+ }.merge(honeypots).with_indifferent_access
7
+ end
8
+
9
+ before(:each) do
10
+ # Root level is encoded with 208.77.188.166/test/1234
11
+ # which resolves to a spinner digest of 86ba3fd99e851587a849ad9ed9817f9b
12
+ @ip = '208.77.188.166'
13
+ @params = params('test' => { 'name' => '', 'posts' => [] })
14
+ end
15
+
16
+ subject { r = BotAway::ParamParser.new(@ip, @params); puts r.params.to_yaml; r }
17
+
18
+ context "with blank honeypots" do
19
+ it "drops obfuscated params" do
20
+ subject.params.keys.should_not include('bdf3e1964ac3a82c5f1c7385ed0100e4')
21
+ end
22
+
23
+ it "drops obfuscated subparams" do
24
+ subject.params.keys.should_not include('ed8fc4fe67d66bc7aeaf0b4817bd1311')
25
+ end
26
+
27
+ it "replaces honeypots" do
28
+ subject.params[:test].should_not be_blank
29
+ end
30
+
31
+ it "replaces subhoneypots" do
32
+ subject.params.keys.should include('test')
33
+ subject.params[:test][:name].should == 'colin'
34
+ subject.params[:test][:posts].should == [1,2]
35
+ end
36
+ end
37
+
38
+ context "with a filled honeypot" do
39
+ before(:each) { @params = params({'test' => {'name' => 'colin', 'posts' => []}}) }
40
+ subject { r = BotAway::ParamParser.new(@ip, @params); puts r.params.to_yaml; r }
41
+
42
+ it "drops all parameters" do
43
+ subject.params.should == { "suspected_bot" => true }
44
+ end
45
+ end
46
+
47
+ context "with a filled sub-honeypot" do
48
+ before(:each) { @params = params({'test' => {'name' => '', 'posts' => [1, 2]}}) }
49
+ subject { r = BotAway::ParamParser.new(@ip, @params); puts r.params.to_yaml; r }
50
+
51
+ it "drops all parameters" do
52
+ subject.params.should == { "suspected_bot" => true }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,12 @@
1
+ require 'rubygems'
2
+ require 'action_controller'
3
+ require 'action_view'
4
+
5
+ ActionController::Routing::Routes.load!
6
+ ActionController::Base.session = { :key => "_myapp_session", :secret => "12345"*6 }
7
+
8
+ require File.join(File.dirname(__FILE__), '../lib/bot-away')
9
+
10
+ Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each do |fi|
11
+ require fi
12
+ end
@@ -0,0 +1,18 @@
1
+ class Post
2
+ attr_reader :subject, :body, :subscribers
3
+ end
4
+
5
+ class TestController < ActionController::Base
6
+ view_paths << File.expand_path(File.join(File.dirname(__FILE__), "../views"))
7
+
8
+ def index
9
+ end
10
+
11
+ def model_form
12
+ @post = Post.new
13
+ end
14
+
15
+ def proc_form
16
+ render :text => params.to_yaml
17
+ end
18
+ end
@@ -0,0 +1,26 @@
1
+ class HoneypotMatcher
2
+ def initialize(object_name, method_name)
3
+ @object_name, @method_name = object_name, method_name
4
+ end
5
+
6
+ def matches?(target)
7
+ target = target.call if target.kind_of?(Proc)
8
+ @target = target
9
+ @rx = /name="#{Regexp::escape @object_name}\[#{Regexp::escape @method_name}/m
10
+ @target[@rx]
11
+ end
12
+
13
+ def failure_message
14
+ "expected #{@target.inspect}\n to match #{@rx.to_s}"
15
+ end
16
+
17
+ def negative_failure_message
18
+ "expected #{@target.inspect}\n to not match #{@rx.to_s}"
19
+ end
20
+ end
21
+
22
+ def include_honeypot(object_name, method_name)
23
+ HoneypotMatcher.new(object_name, method_name)
24
+ end
25
+
26
+ alias contain_honeypot include_honeypot
@@ -0,0 +1,57 @@
1
+ module ObfuscationHelper
2
+ def includes_honeypot(object_name, method_name)
3
+ it "includes a honeypot called #{object_name}[#{method_name}]" do
4
+ subject.should include_honeypot(object_name, method_name)
5
+ end
6
+ end
7
+
8
+ def is_obfuscated_as(id, name)
9
+ it "is obfuscated as #{id}, #{name}" do
10
+ subject.should be_obfuscated_as(id, name)
11
+ end
12
+ end
13
+
14
+ def dump
15
+ returning(yield) { |x| puts x }
16
+ end
17
+
18
+ def builder
19
+ return @builder if @builder
20
+ response = TestController.call(Rack::MockRequest.env_for('/').merge({'REQUEST_URI' => '/',
21
+ 'REMOTE_ADDR' => '127.0.0.1'}))
22
+ response.template.controller.request_forgery_protection_token = :authenticity_token
23
+ response.template.controller.session[:_csrf_token] = '1234'
24
+ @builder = ActionView::Helpers::FormBuilder.new(:object_name, MockObject.new, response.template, {}, proc {})
25
+ end
26
+
27
+ def obfuscates(method, obfuscated_id = self.obfuscated_id, obfuscated_name = self.obfuscated_name)
28
+ value = yield
29
+ context "##{method}" do
30
+ subject { proc { dump { value } } }
31
+
32
+ includes_honeypot(object_name, method_name)
33
+ is_obfuscated_as(obfuscated_id, obfuscated_name)
34
+ end
35
+ end
36
+
37
+ def obfuscated_id
38
+ "e21372563297c728093bf74c3cb6b96c"
39
+ end
40
+
41
+ def obfuscated_name
42
+ "a0844d45bf150668ff1d86a6eb491969"
43
+ end
44
+
45
+ def object_name
46
+ "object_name"
47
+ end
48
+
49
+ def method_name
50
+ "method_name"
51
+ end
52
+ end
53
+
54
+ Spec::Runner.configure do |config|
55
+ config.extend ObfuscationHelper
56
+ config.include ObfuscationHelper
57
+ end
@@ -0,0 +1,28 @@
1
+ class ObfuscationMatcher
2
+ def initialize(id, name)
3
+ @id, @name = id, name
4
+ end
5
+
6
+ def matches?(target)
7
+ target = target.call if target.kind_of?(Proc)
8
+ @target = target
9
+ match(:id) && match(:name)
10
+ end
11
+
12
+ def match(which)
13
+ @rx = /#{which}=['"]#{Regexp::escape instance_variable_get("@#{which}")}/
14
+ @target[@rx]
15
+ end
16
+
17
+ def failure_message
18
+ "expected #{@target.inspect}\n to match #{@rx.inspect}"
19
+ end
20
+
21
+ def negative_failure_message
22
+ "expected #{@target.inspect}\n to not match #{@rx.inspect}"
23
+ end
24
+ end
25
+
26
+ def be_obfuscated_as(id, name)
27
+ ObfuscationMatcher.new(id, name)
28
+ end
@@ -0,0 +1,4 @@
1
+ <%form_for :test, :url => {:action => 'post_to'} do |f|%>
2
+ <%=f.text_field :name%>
3
+ <%end%>
4
+
@@ -0,0 +1,6 @@
1
+ <%form_for @post, :url => url_for('proc_form') do |f|%>
2
+ <p>
3
+ <%=f.label :subject%><br/>
4
+ <%=f.text_field :subject%>
5
+ </p>
6
+ <%end%>
metadata ADDED
@@ -0,0 +1,182 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bot-away
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 1
7
+ - 0
8
+ - 0
9
+ version: 1.0.0
10
+ platform: ruby
11
+ authors:
12
+ - Colin MacKenzie IV
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-04-01 00:00:00 -04:00
18
+ default_executable:
19
+ dependencies:
20
+ - !ruby/object:Gem::Dependency
21
+ name: actionpack
22
+ prerelease: false
23
+ requirement: &id001 !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ segments:
28
+ - 2
29
+ - 3
30
+ - 5
31
+ version: 2.3.5
32
+ type: :runtime
33
+ version_requirements: *id001
34
+ - !ruby/object:Gem::Dependency
35
+ name: sc-core-ext
36
+ prerelease: false
37
+ requirement: &id002 !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ segments:
42
+ - 1
43
+ - 1
44
+ - 1
45
+ version: 1.1.1
46
+ type: :runtime
47
+ version_requirements: *id002
48
+ - !ruby/object:Gem::Dependency
49
+ name: rubyforge
50
+ prerelease: false
51
+ requirement: &id003 !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ segments:
56
+ - 2
57
+ - 0
58
+ - 3
59
+ version: 2.0.3
60
+ type: :development
61
+ version_requirements: *id003
62
+ - !ruby/object:Gem::Dependency
63
+ name: gemcutter
64
+ prerelease: false
65
+ requirement: &id004 !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ segments:
70
+ - 0
71
+ - 5
72
+ - 0
73
+ version: 0.5.0
74
+ type: :development
75
+ version_requirements: *id004
76
+ - !ruby/object:Gem::Dependency
77
+ name: hoe
78
+ prerelease: false
79
+ requirement: &id005 !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ segments:
84
+ - 2
85
+ - 5
86
+ - 0
87
+ version: 2.5.0
88
+ type: :development
89
+ version_requirements: *id005
90
+ description: |-
91
+ Unobtrusively detects form submissions made by spambots, and silently drops those submissions. The key word here is
92
+ "unobtrusive" -- this is NOT a CAPTCHA. This is transparent, modular implementation of the bot-catching techniques
93
+ discussed by Ned Batchelder at http://nedbatchelder.com/text/stopbots.html
94
+
95
+ If a submission is detected, the params hash is cleared, so the data can't be used. Since this includes the authenticity
96
+ token, Rails should barf due to an invalid or missing authenticity token. Congrats, spam blocked.
97
+
98
+ The specifics of the techniques employed for filtering spambots are discussed Ned's site at the above location; however,
99
+ here's a brief run-down of what's going on:
100
+
101
+ * Your code stays the same. After the bot-away gem has been activated, all Rails-generated forms on your site
102
+ will automatically be transformed into bot-resistent forms.
103
+ * All of the form elements that you create (for instance, a "comment" model with a "body" field) are turned into
104
+ dummy elements, or honeypots, and are made invisible to the end user. This is done using div elements and inline CSS
105
+ stylesheets. There are several ways an element can be hidden, and these approaches are chosen at random to help
106
+ minimize predictability. In the rare event that a real user actually can see the element, it has a label next to it
107
+ along the lines of "Leave this blank" -- though the exact message is randomized to help prevent detection.
108
+ * All of the form elements are mirrored by hashes. The hashes are generated using the session's authenticity token,
109
+ so they can't be predicted.
110
+ * When data is submitted, bot-away steps in and 1.) validates that no honeypots have been filled in; and 2)
111
+ converts the hashed elements back into the field names that you are expecting (replacing the honeypot fields).
112
+ * If a honeypot has been filled in, or a hashed element is missing where it was expected, then the request is
113
+ considered to be either spam, or tampered with; and the entire params hash is emptied. Since this happens at the
114
+ lowest level, the most likely result is that Rails will complain that the user's authenticity token is invalid. If
115
+ that does not happen, then your code will be passed a params hash containing only a "suspected_bot" key, and an error
116
+ will result. Either way, the spambot has been foiled!
117
+ email:
118
+ - sinisterchipmunk@gmail.com
119
+ executables: []
120
+
121
+ extensions: []
122
+
123
+ extra_rdoc_files:
124
+ - History.txt
125
+ - Manifest.txt
126
+ files:
127
+ - History.txt
128
+ - Manifest.txt
129
+ - README.rdoc
130
+ - Rakefile
131
+ - lib/bot-away.rb
132
+ - lib/bot-away/action_controller/request.rb
133
+ - lib/bot-away/action_view/helpers/instance_tag.rb
134
+ - lib/bot-away/param_parser.rb
135
+ - lib/bot-away/spinner.rb
136
+ - script/console
137
+ - script/destroy
138
+ - script/generate
139
+ - spec/controllers/test_controller_spec.rb
140
+ - spec/lib/action_view/helpers/instance_tag_spec.rb
141
+ - spec/lib/builder_spec.rb
142
+ - spec/lib/param_parser_spec.rb
143
+ - spec/spec_helper.rb
144
+ - spec/support/controllers/test_controller.rb
145
+ - spec/support/honeypot_matcher.rb
146
+ - spec/support/obfuscation_helper.rb
147
+ - spec/support/obfuscation_matcher.rb
148
+ - spec/support/views/test/index.html.erb
149
+ - spec/support/views/test/model_form.html.erb
150
+ has_rdoc: true
151
+ homepage: http://github.com/sinisterchipmunk/bot-away
152
+ licenses: []
153
+
154
+ post_install_message:
155
+ rdoc_options:
156
+ - --main
157
+ - README.rdoc
158
+ require_paths:
159
+ - lib
160
+ required_ruby_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ segments:
165
+ - 0
166
+ version: "0"
167
+ required_rubygems_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ segments:
172
+ - 0
173
+ version: "0"
174
+ requirements: []
175
+
176
+ rubyforge_project: bot-away
177
+ rubygems_version: 1.3.6
178
+ signing_key:
179
+ specification_version: 3
180
+ summary: Unobtrusively detects form submissions made by spambots, and silently drops those submissions
181
+ test_files: []
182
+