tricycle-rack-contrib 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. data/COPYING +18 -0
  2. data/README.rdoc +78 -0
  3. data/Rakefile +97 -0
  4. data/lib/rack/contrib.rb +37 -0
  5. data/lib/rack/contrib/accept_format.rb +46 -0
  6. data/lib/rack/contrib/backstage.rb +20 -0
  7. data/lib/rack/contrib/bounce_favicon.rb +16 -0
  8. data/lib/rack/contrib/callbacks.rb +37 -0
  9. data/lib/rack/contrib/config.rb +16 -0
  10. data/lib/rack/contrib/cookies.rb +50 -0
  11. data/lib/rack/contrib/csshttprequest.rb +39 -0
  12. data/lib/rack/contrib/deflect.rb +137 -0
  13. data/lib/rack/contrib/etag.rb +20 -0
  14. data/lib/rack/contrib/evil.rb +12 -0
  15. data/lib/rack/contrib/garbagecollector.rb +14 -0
  16. data/lib/rack/contrib/jsonp.rb +41 -0
  17. data/lib/rack/contrib/lighttpd_script_name_fix.rb +16 -0
  18. data/lib/rack/contrib/locale.rb +31 -0
  19. data/lib/rack/contrib/mailexceptions.rb +120 -0
  20. data/lib/rack/contrib/nested_params.rb +143 -0
  21. data/lib/rack/contrib/not_found.rb +18 -0
  22. data/lib/rack/contrib/post_body_content_type_parser.rb +40 -0
  23. data/lib/rack/contrib/proctitle.rb +30 -0
  24. data/lib/rack/contrib/profiler.rb +108 -0
  25. data/lib/rack/contrib/relative_redirect.rb +44 -0
  26. data/lib/rack/contrib/response_cache.rb +116 -0
  27. data/lib/rack/contrib/route_exceptions.rb +49 -0
  28. data/lib/rack/contrib/sendfile.rb +142 -0
  29. data/lib/rack/contrib/signals.rb +63 -0
  30. data/lib/rack/contrib/time_zone.rb +25 -0
  31. data/test/404.html +1 -0
  32. data/test/Maintenance.html +1 -0
  33. data/test/mail_settings.rb +12 -0
  34. data/test/spec_rack_accept_format.rb +72 -0
  35. data/test/spec_rack_backstage.rb +26 -0
  36. data/test/spec_rack_callbacks.rb +65 -0
  37. data/test/spec_rack_config.rb +22 -0
  38. data/test/spec_rack_contrib.rb +8 -0
  39. data/test/spec_rack_csshttprequest.rb +66 -0
  40. data/test/spec_rack_deflect.rb +107 -0
  41. data/test/spec_rack_etag.rb +23 -0
  42. data/test/spec_rack_evil.rb +19 -0
  43. data/test/spec_rack_garbagecollector.rb +13 -0
  44. data/test/spec_rack_jsonp.rb +34 -0
  45. data/test/spec_rack_lighttpd_script_name_fix.rb +16 -0
  46. data/test/spec_rack_mailexceptions.rb +97 -0
  47. data/test/spec_rack_nested_params.rb +46 -0
  48. data/test/spec_rack_not_found.rb +17 -0
  49. data/test/spec_rack_post_body_content_type_parser.rb +32 -0
  50. data/test/spec_rack_proctitle.rb +26 -0
  51. data/test/spec_rack_profiler.rb +37 -0
  52. data/test/spec_rack_relative_redirect.rb +78 -0
  53. data/test/spec_rack_response_cache.rb +181 -0
  54. data/test/spec_rack_sendfile.rb +86 -0
  55. data/tricycle-rack-contrib.gemspec +88 -0
  56. metadata +175 -0
@@ -0,0 +1,137 @@
1
+ require 'thread'
2
+
3
+ # TODO: optional stats
4
+ # TODO: performance
5
+ # TODO: clean up tests
6
+
7
+ module Rack
8
+
9
+ ##
10
+ # Rack middleware for protecting against Denial-of-service attacks
11
+ # http://en.wikipedia.org/wiki/Denial-of-service_attack.
12
+ #
13
+ # This middleware is designed for small deployments, which most likely
14
+ # are not utilizing load balancing from other software or hardware. Deflect
15
+ # current supports the following functionality:
16
+ #
17
+ # * Saturation prevention (small DoS attacks, or request abuse)
18
+ # * Blacklisting of remote addresses
19
+ # * Whitelisting of remote addresses
20
+ # * Logging
21
+ #
22
+ # === Options:
23
+ #
24
+ # :log When false logging will be bypassed, otherwise pass an object responding to #puts
25
+ # :log_format Alter the logging format
26
+ # :log_date_format Alter the logging date format
27
+ # :request_threshold Number of requests allowed within the set :interval. Defaults to 100
28
+ # :interval Duration in seconds until the request counter is reset. Defaults to 5
29
+ # :block_duration Duration in seconds that a remote address will be blocked. Defaults to 900 (15 minutes)
30
+ # :whitelist Array of remote addresses which bypass Deflect. NOTE: this does not block others
31
+ # :blacklist Array of remote addresses immediately considered malicious
32
+ #
33
+ # === Examples:
34
+ #
35
+ # use Rack::Deflect, :log => $stdout, :request_threshold => 20, :interval => 2, :block_duration => 60
36
+ #
37
+ # CREDIT: TJ Holowaychuk <tj@vision-media.ca>
38
+ #
39
+
40
+ class Deflect
41
+
42
+ attr_reader :options
43
+
44
+ def initialize app, options = {}
45
+ @mutex = Mutex.new
46
+ @remote_addr_map = {}
47
+ @app, @options = app, {
48
+ :log => false,
49
+ :log_format => 'deflect(%s): %s',
50
+ :log_date_format => '%m/%d/%Y',
51
+ :request_threshold => 100,
52
+ :interval => 5,
53
+ :block_duration => 900,
54
+ :whitelist => [],
55
+ :blacklist => []
56
+ }.merge(options)
57
+ end
58
+
59
+ def call env
60
+ return deflect! if deflect? env
61
+ status, headers, body = @app.call env
62
+ [status, headers, body]
63
+ end
64
+
65
+ def deflect!
66
+ [403, { 'Content-Type' => 'text/html', 'Content-Length' => '0' }, '']
67
+ end
68
+
69
+ def deflect? env
70
+ @remote_addr = env['REMOTE_ADDR']
71
+ return false if options[:whitelist].include? @remote_addr
72
+ return true if options[:blacklist].include? @remote_addr
73
+ sync { watch }
74
+ end
75
+
76
+ def log message
77
+ return unless options[:log]
78
+ options[:log].puts(options[:log_format] % [Time.now.strftime(options[:log_date_format]), message])
79
+ end
80
+
81
+ def sync &block
82
+ @mutex.synchronize(&block)
83
+ end
84
+
85
+ def map
86
+ @remote_addr_map[@remote_addr] ||= {
87
+ :expires => Time.now + options[:interval],
88
+ :requests => 0
89
+ }
90
+ end
91
+
92
+ def watch
93
+ increment_requests
94
+ clear! if watch_expired? and not blocked?
95
+ clear! if blocked? and block_expired?
96
+ block! if watching? and exceeded_request_threshold?
97
+ blocked?
98
+ end
99
+
100
+ def block!
101
+ return if blocked?
102
+ log "blocked #{@remote_addr}"
103
+ map[:block_expires] = Time.now + options[:block_duration]
104
+ end
105
+
106
+ def blocked?
107
+ map.has_key? :block_expires
108
+ end
109
+
110
+ def block_expired?
111
+ map[:block_expires] < Time.now rescue false
112
+ end
113
+
114
+ def watching?
115
+ @remote_addr_map.has_key? @remote_addr
116
+ end
117
+
118
+ def clear!
119
+ return unless watching?
120
+ log "released #{@remote_addr}" if blocked?
121
+ @remote_addr_map.delete @remote_addr
122
+ end
123
+
124
+ def increment_requests
125
+ map[:requests] += 1
126
+ end
127
+
128
+ def exceeded_request_threshold?
129
+ map[:requests] > options[:request_threshold]
130
+ end
131
+
132
+ def watch_expired?
133
+ map[:expires] <= Time.now
134
+ end
135
+
136
+ end
137
+ end
@@ -0,0 +1,20 @@
1
+ require 'digest/md5'
2
+
3
+ module Rack
4
+ # Automatically sets the ETag header on all String bodies
5
+ class ETag
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ status, headers, body = @app.call(env)
12
+
13
+ if !headers.has_key?('ETag') && body.is_a?(String)
14
+ headers['ETag'] = %("#{Digest::MD5.hexdigest(body)}")
15
+ end
16
+
17
+ [status, headers, body]
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ module Rack
2
+ class Evil
3
+ # Lets you return a response to the client immediately from anywhere ( M V or C ) in the code.
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ catch(:response) { @app.call(env) }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ module Rack
2
+ # Forces garbage collection after each request.
3
+ class GarbageCollector
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ @app.call(env)
10
+ ensure
11
+ GC.start
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,41 @@
1
+ module Rack
2
+
3
+ # A Rack middleware for providing JSON-P support.
4
+ #
5
+ # Full credit to Flinn Mueller (http://actsasflinn.com/) for this contribution.
6
+ #
7
+ class JSONP
8
+
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ # Proxies the request to the application, stripping out the JSON-P callback
14
+ # method and padding the response with the appropriate callback format.
15
+ #
16
+ # Changes nothing if no <tt>callback</tt> param is specified.
17
+ #
18
+ def call(env)
19
+ status, headers, response = @app.call(env)
20
+ request = Rack::Request.new(env)
21
+ if request.params.include?('callback')
22
+ response = pad(request.params.delete('callback'), response)
23
+ headers['Content-Length'] = response.length.to_s
24
+ end
25
+ [status, headers, response]
26
+ end
27
+
28
+ # Pads the response with the appropriate callback format according to the
29
+ # JSON-P spec/requirements.
30
+ #
31
+ # The Rack response spec indicates that it should be enumerable. The method
32
+ # of combining all of the data into a single string makes sense since JSON
33
+ # is returned as a full string.
34
+ #
35
+ def pad(callback, response, body = "")
36
+ response.each{ |s| body << s.to_s }
37
+ "#{callback}(#{body})"
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,16 @@
1
+ module Rack
2
+ # Lighttpd sets the wrong SCRIPT_NAME and PATH_INFO if you mount your
3
+ # FastCGI app at "/". This middleware fixes this issue.
4
+
5
+ class LighttpdScriptNameFix
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ env["PATH_INFO"] = env["SCRIPT_NAME"].to_s + env["PATH_INFO"].to_s
12
+ env["SCRIPT_NAME"] = ""
13
+ @app.call(env)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ require 'i18n'
2
+
3
+ module Rack
4
+ class Locale
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ old_locale = I18n.locale
11
+ locale = nil
12
+
13
+ # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4
14
+ if lang = env["HTTP_ACCEPT_LANGUAGE"]
15
+ lang = lang.split(",").map { |l|
16
+ l += ';q=1.0' unless l =~ /;q=\d+\.\d+$/
17
+ l.split(';q=')
18
+ }.first
19
+ locale = lang.first.split("-").first
20
+ else
21
+ locale = I18n.default_locale
22
+ end
23
+
24
+ locale = env['rack.locale'] = I18n.locale = locale.to_s
25
+ status, headers, body = @app.call(env)
26
+ headers['Content-Language'] = locale
27
+ I18n.locale = old_locale
28
+ [status, headers, body]
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,120 @@
1
+ require 'net/smtp'
2
+ require 'tmail'
3
+ require 'erb'
4
+
5
+ module Rack
6
+ # Catches all exceptions raised from the app it wraps and
7
+ # sends a useful email with the exception, stacktrace, and
8
+ # contents of the environment.
9
+
10
+ class MailExceptions
11
+ attr_reader :config
12
+
13
+ def initialize(app)
14
+ @app = app
15
+ @config = {
16
+ :to => nil,
17
+ :from => ENV['USER'] || 'rack',
18
+ :subject => '[exception] %s',
19
+ :smtp => {
20
+ :server => 'localhost',
21
+ :domain => 'localhost',
22
+ :port => 25,
23
+ :authentication => :login,
24
+ :user_name => nil,
25
+ :password => nil
26
+ }
27
+ }
28
+ @template = ERB.new(TEMPLATE)
29
+ yield self if block_given?
30
+ end
31
+
32
+ def call(env)
33
+ status, headers, body =
34
+ begin
35
+ @app.call(env)
36
+ rescue => boom
37
+ # TODO don't allow exceptions from send_notification to
38
+ # propogate
39
+ send_notification boom, env
40
+ raise
41
+ end
42
+ send_notification env['mail.exception'], env if env['mail.exception']
43
+ [status, headers, body]
44
+ end
45
+
46
+ %w[to from subject].each do |meth|
47
+ define_method(meth) { |value| @config[meth.to_sym] = value }
48
+ end
49
+
50
+ def smtp(settings={})
51
+ @config[:smtp].merge! settings
52
+ end
53
+
54
+ private
55
+ def generate_mail(exception, env)
56
+ mail = TMail::Mail.new
57
+ mail.to = Array(config[:to])
58
+ mail.from = config[:from]
59
+ mail.subject = config[:subject] % [exception.to_s]
60
+ mail.date = Time.now
61
+ mail.set_content_type 'text/plain'
62
+ mail.charset = 'UTF-8'
63
+ mail.body = @template.result(binding)
64
+ mail
65
+ end
66
+
67
+ def send_notification(exception, env)
68
+ mail = generate_mail(exception, env)
69
+ smtp = config[:smtp]
70
+ env['mail.sent'] = true
71
+ return if smtp[:server] == 'example.com'
72
+
73
+ Net::SMTP.start smtp[:server], smtp[:port], smtp[:domain], smtp[:user_name], smtp[:password], smtp[:authentication] do |server|
74
+ mail.to.each do |recipient|
75
+ server.send_message mail.to_s, mail.from, recipient
76
+ end
77
+ end
78
+ end
79
+
80
+ def extract_body(env)
81
+ if io = env['rack.input']
82
+ io.rewind if io.respond_to?(:rewind)
83
+ io.read
84
+ end
85
+ end
86
+
87
+ TEMPLATE = (<<-'EMAIL').gsub(/^ {4}/, '')
88
+ A <%= exception.class.to_s %> occured: <%= exception.to_s %>
89
+ <% if body = extract_body(env) %>
90
+
91
+ ===================================================================
92
+ Request Body:
93
+ ===================================================================
94
+
95
+ <%= body.gsub(/^/, ' ') %>
96
+ <% end %>
97
+
98
+ ===================================================================
99
+ Rack Environment:
100
+ ===================================================================
101
+
102
+ PID: <%= $$ %>
103
+ PWD: <%= Dir.getwd %>
104
+
105
+ <%= env.to_a.
106
+ sort{|a,b| a.first <=> b.first}.
107
+ map{ |k,v| "%-25s%p" % [k+':', v] }.
108
+ join("\n ") %>
109
+
110
+ <% if exception.respond_to?(:backtrace) %>
111
+ ===================================================================
112
+ Backtrace:
113
+ ===================================================================
114
+
115
+ <%= exception.backtrace.join("\n ") %>
116
+ <% end %>
117
+ EMAIL
118
+
119
+ end
120
+ end
@@ -0,0 +1,143 @@
1
+ require 'cgi'
2
+ require 'strscan'
3
+
4
+ module Rack
5
+ # Rack middleware for parsing POST/PUT body data into nested parameters
6
+ class NestedParams
7
+
8
+ CONTENT_TYPE = 'CONTENT_TYPE'.freeze
9
+ POST_BODY = 'rack.input'.freeze
10
+ FORM_INPUT = 'rack.request.form_input'.freeze
11
+ FORM_HASH = 'rack.request.form_hash'.freeze
12
+ FORM_VARS = 'rack.request.form_vars'.freeze
13
+
14
+ # supported content type
15
+ URL_ENCODED = 'application/x-www-form-urlencoded'.freeze
16
+
17
+ def initialize(app)
18
+ @app = app
19
+ end
20
+
21
+ def call(env)
22
+ if form_vars = env[FORM_VARS]
23
+ env[FORM_HASH] = parse_query_parameters(form_vars)
24
+ elsif env[CONTENT_TYPE] == URL_ENCODED
25
+ post_body = env[POST_BODY]
26
+ env[FORM_INPUT] = post_body
27
+ env[FORM_HASH] = parse_query_parameters(post_body.read)
28
+ post_body.rewind if post_body.respond_to?(:rewind)
29
+ end
30
+ @app.call(env)
31
+ end
32
+
33
+ ## the rest is nabbed from Rails ##
34
+
35
+ def parse_query_parameters(query_string)
36
+ return {} if query_string.nil? or query_string.empty?
37
+
38
+ pairs = query_string.split('&').collect do |chunk|
39
+ next if chunk.empty?
40
+ key, value = chunk.split('=', 2)
41
+ next if key.empty?
42
+ value = value.nil? ? nil : CGI.unescape(value)
43
+ [ CGI.unescape(key), value ]
44
+ end.compact
45
+
46
+ UrlEncodedPairParser.new(pairs).result
47
+ end
48
+
49
+ class UrlEncodedPairParser < StringScanner
50
+ attr_reader :top, :parent, :result
51
+
52
+ def initialize(pairs = [])
53
+ super('')
54
+ @result = {}
55
+ pairs.each { |key, value| parse(key, value) }
56
+ end
57
+
58
+ KEY_REGEXP = %r{([^\[\]=&]+)}
59
+ BRACKETED_KEY_REGEXP = %r{\[([^\[\]=&]+)\]}
60
+
61
+ # Parse the query string
62
+ def parse(key, value)
63
+ self.string = key
64
+ @top, @parent = result, nil
65
+
66
+ # First scan the bare key
67
+ key = scan(KEY_REGEXP) or return
68
+ key = post_key_check(key)
69
+
70
+ # Then scan as many nestings as present
71
+ until eos?
72
+ r = scan(BRACKETED_KEY_REGEXP) or return
73
+ key = self[1]
74
+ key = post_key_check(key)
75
+ end
76
+
77
+ bind(key, value)
78
+ end
79
+
80
+ private
81
+ # After we see a key, we must look ahead to determine our next action. Cases:
82
+ #
83
+ # [] follows the key. Then the value must be an array.
84
+ # = follows the key. (A value comes next)
85
+ # & or the end of string follows the key. Then the key is a flag.
86
+ # otherwise, a hash follows the key.
87
+ def post_key_check(key)
88
+ if scan(/\[\]/) # a[b][] indicates that b is an array
89
+ container(key, Array)
90
+ nil
91
+ elsif check(/\[[^\]]/) # a[b] indicates that a is a hash
92
+ container(key, Hash)
93
+ nil
94
+ else # End of key? We do nothing.
95
+ key
96
+ end
97
+ end
98
+
99
+ # Add a container to the stack.
100
+ def container(key, klass)
101
+ type_conflict! klass, top[key] if top.is_a?(Hash) && top.key?(key) && ! top[key].is_a?(klass)
102
+ value = bind(key, klass.new)
103
+ type_conflict! klass, value unless value.is_a?(klass)
104
+ push(value)
105
+ end
106
+
107
+ # Push a value onto the 'stack', which is actually only the top 2 items.
108
+ def push(value)
109
+ @parent, @top = @top, value
110
+ end
111
+
112
+ # Bind a key (which may be nil for items in an array) to the provided value.
113
+ def bind(key, value)
114
+ if top.is_a? Array
115
+ if key
116
+ if top[-1].is_a?(Hash) && ! top[-1].key?(key)
117
+ top[-1][key] = value
118
+ else
119
+ top << {key => value}
120
+ end
121
+ push top.last
122
+ return top[key]
123
+ else
124
+ top << value
125
+ return value
126
+ end
127
+ elsif top.is_a? Hash
128
+ key = CGI.unescape(key)
129
+ parent << (@top = {}) if top.key?(key) && parent.is_a?(Array)
130
+ top[key] ||= value
131
+ return top[key]
132
+ else
133
+ raise ArgumentError, "Don't know what to do: top is #{top.inspect}"
134
+ end
135
+ end
136
+
137
+ def type_conflict!(klass, value)
138
+ raise TypeError, "Conflicting types for parameter containers. Expected an instance of #{klass} but found an instance of #{value.class}. This can be caused by colliding Array and Hash parameters like qs[]=value&qs[key]=value. (The parameters received were #{value.inspect}.)"
139
+ end
140
+ end
141
+
142
+ end
143
+ end