rack-protection 1.0.0 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2b7d78da301d9f7fc81ae73e46a389c2b8ce10ab8121f169fd760018ac506d47
4
+ data.tar.gz: a91bd28f8624f325d6714262ac11d83e0a347405f14e600780cb1bfd846e5b34
5
+ SHA512:
6
+ metadata.gz: 0c5de92c0283313c00d50c1f9a219c808ad587caabff81c4d1530abd8f0e7d9c0f3753ad9bab7c06a29ce97b2a717fddc04ced642adff058f3431419286e4da6
7
+ data.tar.gz: d3bf5830bf30475871b73ba54ee38f962bd93c2e1f420b59b649d6dcb7f97d89f091d3add3f692ba847ffe5f5c6cade665d522c86220087d977fb3706e41bd58
data/README.md CHANGED
@@ -43,13 +43,14 @@ Prevented by:
43
43
  * `Rack::Protection::JsonCsrf`
44
44
  * `Rack::Protection::RemoteReferrer` (not included by `use Rack::Protection`)
45
45
  * `Rack::Protection::RemoteToken`
46
+ * `Rack::Protection::HttpOrigin`
46
47
 
47
48
  ## Cross Site Scripting
48
49
 
49
50
  Prevented by:
50
51
 
51
- * `Rack::Protection::EscapedParams`
52
- * `Rack::Protection::XssHeader` (Internet Explorer only)
52
+ * `Rack::Protection::EscapedParams` (not included by `use Rack::Protection`)
53
+ * `Rack::Protection::XSSHeader` (Internet Explorer only)
53
54
 
54
55
  ## Clickjacking
55
56
 
@@ -79,16 +80,11 @@ Prevented by:
79
80
 
80
81
  gem install rack-protection
81
82
 
82
- # History
83
+ # Instrumentation
83
84
 
84
- ## v0.1.0 (2011/06/20)
85
-
86
- First public release.
87
-
88
- ## v1.0.0 (2011/09/02)
89
-
90
- First stable release.
91
-
92
- Changes:
85
+ Instrumentation is enabled by passing in an instrumenter as an option.
86
+ ```
87
+ use Rack::Protection, instrumenter: ActiveSupport::Notifications
88
+ ```
93
89
 
94
- * Fix bug in JsonCsrf
90
+ The instrumenter is passed a namespace (String) and environment (Hash). The namespace is 'rack.protection' and the attack type can be obtained from the environment key 'rack.protection.attack'.
data/Rakefile CHANGED
@@ -1,3 +1,4 @@
1
+ # encoding: utf-8
1
2
  $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
2
3
 
3
4
  begin
@@ -13,14 +14,19 @@ task(:spec) { ruby '-S rspec spec' }
13
14
  desc "generate gemspec"
14
15
  task 'rack-protection.gemspec' do
15
16
  require 'rack/protection/version'
16
- content = File.read 'rack-protection.gemspec'
17
+ content = File.binread 'rack-protection.gemspec'
17
18
 
19
+ # fetch data
18
20
  fields = {
19
- :authors => `git shortlog -sn`.scan(/[^\d\s].*/),
20
- :email => `git shortlog -sne`.scan(/[^<]+@[^>]+/),
21
- :files => `git ls-files`.split("\n").reject { |f| f =~ /^(\.|Gemfile)/ }
21
+ :authors => `git shortlog -sn`.force_encoding('utf-8').scan(/[^\d\s].*/),
22
+ :email => `git shortlog -sne`.force_encoding('utf-8').scan(/[^<]+@[^>]+/),
23
+ :files => `git ls-files`.force_encoding('utf-8').split("\n").reject { |f| f =~ /^(\.|Gemfile)/ }
22
24
  }
23
25
 
26
+ # double email :(
27
+ fields[:email].delete("konstantin.haase@gmail.com")
28
+
29
+ # insert data
24
30
  fields.each do |field, values|
25
31
  updated = " s.#{field} = ["
26
32
  updated << values.map { |v| "\n %p" % v }.join(',')
@@ -28,7 +34,12 @@ task 'rack-protection.gemspec' do
28
34
  content.sub!(/ s\.#{field} = \[\n( .*\n)* \]/, updated)
29
35
  end
30
36
 
37
+ # set version
31
38
  content.sub! /(s\.version.*=\s+).*/, "\\1\"#{Rack::Protection::VERSION}\""
39
+
40
+ # escape unicode
41
+ content.gsub!(/./) { |c| c.bytesize > 1 ? "\\u{#{c.codepoints.first.to_s(16)}}" : c }
42
+
32
43
  File.open('rack-protection.gemspec', 'w') { |f| f << content }
33
44
  end
34
45
 
@@ -11,13 +11,20 @@ module Rack
11
11
  # included in the session.
12
12
  #
13
13
  # Compatible with Rails and rack-csrf.
14
+ #
15
+ # Options:
16
+ #
17
+ # authenticity_param: Defines the param's name that should contain the token on a request.
18
+ #
14
19
  class AuthenticityToken < Base
20
+ default_options :authenticity_param => 'authenticity_token'
21
+
15
22
  def accepts?(env)
16
- return true if safe? env
17
23
  session = session env
18
24
  token = session[:csrf] ||= session['_csrf_token'] || random_string
19
- env['HTTP_X_CSRF_TOKEN'] == token or
20
- Request.new(env).params['authenticity_token'] == token
25
+ safe?(env) ||
26
+ secure_compare(env['HTTP_X_CSRF_TOKEN'].to_s, token) ||
27
+ secure_compare(Request.new(env).params[options[:authenticity_param]].to_s, token)
21
28
  end
22
29
  end
23
30
  end
@@ -10,7 +10,9 @@ module Rack
10
10
  :reaction => :default_reaction, :logging => true,
11
11
  :message => 'Forbidden', :encryptor => Digest::SHA1,
12
12
  :session_key => 'rack.session', :status => 403,
13
- :allow_empty_referrer => true
13
+ :allow_empty_referrer => true,
14
+ :report_key => "protection.failed",
15
+ :html_types => %w[text/html application/xhtml]
14
16
  }
15
17
 
16
18
  attr_reader :app, :options
@@ -41,7 +43,7 @@ module Rack
41
43
 
42
44
  def call(env)
43
45
  unless accepts? env
44
- warn env, "attack prevented by #{self.class}"
46
+ instrument env
45
47
  result = react env
46
48
  end
47
49
  result or app.call(env)
@@ -58,10 +60,22 @@ module Rack
58
60
  l.warn(message)
59
61
  end
60
62
 
63
+ def instrument(env)
64
+ return unless i = options[:instrumenter]
65
+ env['rack.protection.attack'] = self.class.name.split('::').last.downcase
66
+ i.instrument('rack.protection', env)
67
+ end
68
+
61
69
  def deny(env)
70
+ warn env, "attack prevented by #{self.class}"
62
71
  [options[:status], {'Content-Type' => 'text/plain'}, [options[:message]]]
63
72
  end
64
73
 
74
+ def report(env)
75
+ warn env, "attack reported by #{self.class}"
76
+ env[options[:report_key]] = true
77
+ end
78
+
65
79
  def session?(env)
66
80
  env.include? options[:session_key]
67
81
  end
@@ -79,11 +93,16 @@ module Rack
79
93
  ref = env['HTTP_REFERER'].to_s
80
94
  return if !options[:allow_empty_referrer] and ref.empty?
81
95
  URI.parse(ref).host || Request.new(env).host
96
+ rescue URI::InvalidURIError
97
+ end
98
+
99
+ def origin(env)
100
+ env['HTTP_ORIGIN'] || env['HTTP_X_ORIGIN']
82
101
  end
83
102
 
84
103
  def random_string(secure = defined? SecureRandom)
85
- secure ? SecureRandom.hex(32) : "%032x" % rand(2**128-1)
86
- rescue NotImpelentedError
104
+ secure ? SecureRandom.hex(16) : "%032x" % rand(2**128-1)
105
+ rescue NotImplementedError
87
106
  random_string false
88
107
  end
89
108
 
@@ -91,7 +110,36 @@ module Rack
91
110
  options[:encryptor].hexdigest value.to_s
92
111
  end
93
112
 
113
+ # The implementations of secure_compare and bytesize are taken from
114
+ # Rack::Utils to be able to support rack older than XXXX.
115
+ def secure_compare(a, b)
116
+ return false unless bytesize(a) == bytesize(b)
117
+
118
+ l = a.unpack("C*")
119
+
120
+ r, i = 0, -1
121
+ b.each_byte { |v| r |= v ^ l[i+=1] }
122
+ r == 0
123
+ end
124
+
125
+ # Return the bytesize of String; uses String#size under Ruby 1.8 and
126
+ # String#bytesize under 1.9.
127
+ if ''.respond_to?(:bytesize)
128
+ def bytesize(string)
129
+ string.bytesize
130
+ end
131
+ else
132
+ def bytesize(string)
133
+ string.size
134
+ end
135
+ end
136
+
94
137
  alias default_reaction deny
138
+
139
+ def html?(headers)
140
+ return false unless header = headers.detect { |k,v| k.downcase == 'content-type' }
141
+ options[:html_types].include? header.last[/^\w+\/\w+/]
142
+ end
95
143
  end
96
144
  end
97
145
  end
@@ -1,5 +1,10 @@
1
1
  require 'rack/protection'
2
- require 'escape_utils'
2
+ require 'rack/utils'
3
+
4
+ begin
5
+ require 'escape_utils'
6
+ rescue LoadError
7
+ end
3
8
 
4
9
  module Rack
5
10
  module Protection
@@ -16,14 +21,28 @@ module Rack
16
21
  # escape:: What escaping modes to use, should be Symbol or Array of Symbols.
17
22
  # Available: :html (default), :javascript, :url
18
23
  class EscapedParams < Base
19
- default_options :escape => :html
24
+ extend Rack::Utils
25
+
26
+ class << self
27
+ alias escape_url escape
28
+ public :escape_html
29
+ end
30
+
31
+ default_options :escape => :html,
32
+ :escaper => defined?(EscapeUtils) ? EscapeUtils : self
20
33
 
21
34
  def initialize(*)
22
35
  super
23
- modes = Array options[:escape]
24
- code = "def self.escape_string(str) %s end"
25
- modes.each { |m| code %= "EscapeUtils.escape_#{m}(%s)"}
26
- eval code % 'str'
36
+
37
+ modes = Array options[:escape]
38
+ @escaper = options[:escaper]
39
+ @html = modes.include? :html
40
+ @javascript = modes.include? :javascript
41
+ @url = modes.include? :url
42
+
43
+ if @javascript and not @escaper.respond_to? :escape_javascript
44
+ fail("Use EscapeUtils for JavaScript escaping.")
45
+ end
27
46
  end
28
47
 
29
48
  def call(env)
@@ -32,7 +51,7 @@ module Rack
32
51
  post_was = handle(request.POST) rescue nil
33
52
  app.call env
34
53
  ensure
35
- request.GET.replace get_was
54
+ request.GET.replace get_was if get_was
36
55
  request.POST.replace post_was if post_was
37
56
  end
38
57
 
@@ -47,7 +66,7 @@ module Rack
47
66
  when Hash then escape_hash(object)
48
67
  when Array then object.map { |o| escape(o) }
49
68
  when String then escape_string(object)
50
- else raise ArgumentError, "cannot escape #{object.inspect}"
69
+ else nil
51
70
  end
52
71
  end
53
72
 
@@ -56,6 +75,13 @@ module Rack
56
75
  hash.each { |k,v| hash[k] = escape(v) }
57
76
  hash
58
77
  end
78
+
79
+ def escape_string(str)
80
+ str = @escaper.escape_url(str) if @url
81
+ str = @escaper.escape_html(str) if @html
82
+ str = @escaper.escape_javascript(str) if @javascript
83
+ str
84
+ end
59
85
  end
60
86
  end
61
87
  end
@@ -16,10 +16,21 @@ module Rack
16
16
  # frame_options:: Defines who should be allowed to embed the page in a
17
17
  # frame. Use :deny to forbid any embedding, :sameorigin
18
18
  # to allow embedding from the same origin (default).
19
- class FrameOptions < XSSHeader
19
+ class FrameOptions < Base
20
20
  default_options :frame_options => :sameorigin
21
- def header
22
- { 'X-Frame-Options' => options[:frame_options].to_s }
21
+
22
+ def frame_options
23
+ @frame_options ||= begin
24
+ frame_options = options[:frame_options]
25
+ frame_options = options[:frame_options].to_s.upcase unless frame_options.respond_to? :to_str
26
+ frame_options.to_str
27
+ end
28
+ end
29
+
30
+ def call(env)
31
+ status, headers, body = @app.call(env)
32
+ headers['X-Frame-Options'] ||= frame_options if html? headers
33
+ [status, headers, body]
23
34
  end
24
35
  end
25
36
  end
@@ -0,0 +1,32 @@
1
+ require 'rack/protection'
2
+
3
+ module Rack
4
+ module Protection
5
+ ##
6
+ # Prevented attack:: CSRF
7
+ # Supported browsers:: Google Chrome 2, Safari 4 and later
8
+ # More infos:: http://en.wikipedia.org/wiki/Cross-site_request_forgery
9
+ # http://tools.ietf.org/html/draft-abarth-origin
10
+ #
11
+ # Does not accept unsafe HTTP requests when value of Origin HTTP request header
12
+ # does not match default or whitelisted URIs.
13
+ class HttpOrigin < Base
14
+ DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 }
15
+ default_reaction :deny
16
+
17
+ def base_url(env)
18
+ request = Rack::Request.new(env)
19
+ port = ":#{request.port}" unless request.port == DEFAULT_PORTS[request.scheme]
20
+ "#{request.scheme}://#{request.host}#{port}"
21
+ end
22
+
23
+ def accepts?(env)
24
+ return true if safe? env
25
+ return true unless origin = env['HTTP_ORIGIN']
26
+ return true if base_url(env) == origin
27
+ Array(options[:origin_whitelist]).include? origin
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -13,7 +13,7 @@ module Rack
13
13
 
14
14
  def accepts?(env)
15
15
  return true unless env.include? 'HTTP_X_FORWARDED_FOR'
16
- ips = env['HTTP_X_FORWARDED_FOR'].split /\s*,\s*/
16
+ ips = env['HTTP_X_FORWARDED_FOR'].split(/\s*,\s*/)
17
17
  return false if env.include? 'HTTP_CLIENT_IP' and not ips.include? env['HTTP_CLIENT_IP']
18
18
  return false if env.include? 'HTTP_X_REAL_IP' and not ips.include? env['HTTP_X_REAL_IP']
19
19
  true
@@ -11,14 +11,24 @@ module Rack
11
11
  # Array prototype has been patched to track data. Checks the referrer
12
12
  # even on GET requests if the content type is JSON.
13
13
  class JsonCsrf < Base
14
- default_reaction :deny
14
+ alias react deny
15
15
 
16
16
  def call(env)
17
+ request = Request.new(env)
17
18
  status, headers, body = app.call(env)
18
- if headers['Content-Type'].to_s.split(';', 2).first =~ /^\s*application\/json\s*$/
19
- result = react(env) if referrer(env) != Request.new(env).host
19
+
20
+ if has_vector? request, headers
21
+ warn env, "attack prevented by #{self.class}"
22
+ react(env) or [status, headers, body]
23
+ else
24
+ [status, headers, body]
20
25
  end
21
- result or [status, headers, body]
26
+ end
27
+
28
+ def has_vector?(request, headers)
29
+ return false if request.xhr?
30
+ return false unless headers['Content-Type'].to_s.split(';', 2).first =~ /^\s*application\/json\s*$/
31
+ origin(request.env).nil? and referrer(request.env) != request.host
22
32
  end
23
33
  end
24
34
  end
@@ -12,17 +12,38 @@ module Rack
12
12
  class PathTraversal < Base
13
13
  def call(env)
14
14
  path_was = env["PATH_INFO"]
15
- env["PATH_INFO"] = cleanup path_was
15
+ env["PATH_INFO"] = cleanup path_was if path_was && !path_was.empty?
16
16
  app.call env
17
17
  ensure
18
18
  env["PATH_INFO"] = path_was
19
19
  end
20
20
 
21
21
  def cleanup(path)
22
- return cleanup("/" << path)[1..-1] unless path[0] == ?/
23
- escaped = ::File.expand_path path.gsub('%2e', '.').gsub('%2f', '/')
24
- escaped << '/' if escaped[-1] != ?/ and path =~ /\/\.{0,2}$/
25
- escaped.gsub /\/\/+/, '/'
22
+ if path.respond_to?(:encoding)
23
+ # Ruby 1.9+ M17N
24
+ encoding = path.encoding
25
+ dot = '.'.encode(encoding)
26
+ slash = '/'.encode(encoding)
27
+ backslash = '\\'.encode(encoding)
28
+ else
29
+ # Ruby 1.8
30
+ dot = '.'
31
+ slash = '/'
32
+ backslash = '\\'
33
+ end
34
+
35
+ parts = []
36
+ unescaped = path.gsub(/%2e/i, dot).gsub(/%2f/i, slash).gsub(/%5c/i, backslash)
37
+ unescaped = unescaped.gsub(backslash, slash)
38
+
39
+ unescaped.split(slash).each do |part|
40
+ next if part.empty? or part == dot
41
+ part == '..' ? parts.pop : parts << part
42
+ end
43
+
44
+ cleaned = slash + parts.join(slash)
45
+ cleaned << slash if parts.any? and unescaped =~ %r{/\.{0,2}$}
46
+ cleaned
26
47
  end
27
48
  end
28
49
  end
@@ -9,9 +9,6 @@ module Rack
9
9
  #
10
10
  # Does not accept unsafe HTTP requests if the Referer [sic] header is set to
11
11
  # a different host.
12
- #
13
- # Combine with NoReferrer to also block remote requests from non-HTTP pages
14
- # (FTP/HTTPS/...).
15
12
  class RemoteReferrer < Base
16
13
  default_reaction :deny
17
14
 
@@ -9,13 +9,12 @@ module Rack
9
9
  #
10
10
  # Tracks request properties like the user agent in the session and empties
11
11
  # the session if those properties change. This essentially prevents attacks
12
- # from Firesheep. Since all headers taken into consideration might be
13
- # spoofed, too, this will not prevent all hijacking attempts.
12
+ # from Firesheep. Since all headers taken into consideration can be
13
+ # spoofed, too, this will not prevent determined hijacking attempts.
14
14
  class SessionHijacking < Base
15
15
  default_reaction :drop_session
16
16
  default_options :tracking_key => :tracking, :encrypt_tracking => true,
17
- :track => %w[HTTP_USER_AGENT HTTP_ACCEPT_ENCODING HTTP_ACCEPT_LANGUAGE
18
- HTTP_VERSION]
17
+ :track => %w[HTTP_USER_AGENT HTTP_ACCEPT_LANGUAGE]
19
18
 
20
19
  def accepts?(env)
21
20
  session = session env
@@ -29,7 +28,8 @@ module Rack
29
28
  end
30
29
 
31
30
  def encrypt(value)
32
- options[:encrypt_tracking] ? super(value) : value.to_s
31
+ value = value.to_s.downcase
32
+ options[:encrypt_tracking] ? super(value) : value
33
33
  end
34
34
  end
35
35
  end
@@ -4,41 +4,13 @@ module Rack
4
4
  VERSION
5
5
  end
6
6
 
7
- module VERSION
8
- extend Comparable
7
+ SIGNATURE = [1, 5, 5]
8
+ VERSION = SIGNATURE.join('.')
9
9
 
10
- MAJOR = 1
11
- MINOR = 0
12
- TINY = 0
13
- SIGNATURE = [MAJOR, MINOR, TINY]
14
- STRING = SIGNATURE.join '.'
15
-
16
- def self.major; MAJOR end
17
- def self.minor; MINOR end
18
- def self.tiny; TINY end
19
- def self.to_s; STRING end
20
-
21
- def self.hash
22
- STRING.hash
23
- end
24
-
25
- def self.<=>(other)
26
- other = other.split('.').map { |i| i.to_i } if other.respond_to? :split
27
- SIGNATURE <=> Array(other)
28
- end
29
-
30
- def self.inspect
31
- STRING.inspect
32
- end
33
-
34
- def self.respond_to?(meth, *)
35
- meth.to_s !~ /^__|^to_str$/ and STRING.respond_to? meth unless super
36
- end
37
-
38
- def self.method_missing(meth, *args, &block)
39
- return super unless STRING.respond_to?(meth)
40
- STRING.send(meth, *args, &block)
41
- end
10
+ VERSION.extend Comparable
11
+ def VERSION.<=>(other)
12
+ other = other.split('.').map { |i| i.to_i } if other.respond_to? :split
13
+ SIGNATURE <=> Array(other)
42
14
  end
43
15
  end
44
16
  end
@@ -12,15 +12,13 @@ module Rack
12
12
  # Options:
13
13
  # xss_mode:: How the browser should prevent the attack (default: :block)
14
14
  class XSSHeader < Base
15
- default_options :xss_mode => :block
16
-
17
- def header
18
- { 'X-XSS-Protection' => "1; mode=#{options[:xss_mode]}" }
19
- end
15
+ default_options :xss_mode => :block, :nosniff => true
20
16
 
21
17
  def call(env)
22
18
  status, headers, body = @app.call(env)
23
- [status, header.merge(headers), body]
19
+ headers['X-XSS-Protection'] ||= "1; mode=#{options[:xss_mode]}" if html? headers
20
+ headers['X-Content-Type-Options'] ||= 'nosniff' if options[:nosniff]
21
+ [status, headers, body]
24
22
  end
25
23
  end
26
24
  end
@@ -8,6 +8,7 @@ module Rack
8
8
  autoload :EscapedParams, 'rack/protection/escaped_params'
9
9
  autoload :FormToken, 'rack/protection/form_token'
10
10
  autoload :FrameOptions, 'rack/protection/frame_options'
11
+ autoload :HttpOrigin, 'rack/protection/http_origin'
11
12
  autoload :IPSpoofing, 'rack/protection/ip_spoofing'
12
13
  autoload :JsonCsrf, 'rack/protection/json_csrf'
13
14
  autoload :PathTraversal, 'rack/protection/path_traversal'
@@ -19,15 +20,19 @@ module Rack
19
20
  def self.new(app, options = {})
20
21
  # does not include: RemoteReferrer, AuthenticityToken and FormToken
21
22
  except = Array options[:except]
23
+ use_these = Array options[:use]
22
24
  Rack::Builder.new do
23
- use EscapedParams, options unless except.include? :escaped_params
24
- use FrameOptions, options unless except.include? :frame_options
25
- use IPSpoofing, options unless except.include? :ip_spoofing
26
- use JsonCsrf, options unless except.include? :json_csrf
27
- use PathTraversal, options unless except.include? :path_traversal
28
- use RemoteToken, options unless except.include? :remote_token
29
- use SessionHijacking, options unless except.include? :session_hijacking
30
- use XSSHeader, options unless except.include? :xss_header
25
+ use ::Rack::Protection::RemoteReferrer, options if use_these.include? :remote_referrer
26
+ use ::Rack::Protection::AuthenticityToken,options if use_these.include? :authenticity_token
27
+ use ::Rack::Protection::FormToken, options if use_these.include? :form_token
28
+ use ::Rack::Protection::FrameOptions, options unless except.include? :frame_options
29
+ use ::Rack::Protection::HttpOrigin, options unless except.include? :http_origin
30
+ use ::Rack::Protection::IPSpoofing, options unless except.include? :ip_spoofing
31
+ use ::Rack::Protection::JsonCsrf, options unless except.include? :json_csrf
32
+ use ::Rack::Protection::PathTraversal, options unless except.include? :path_traversal
33
+ use ::Rack::Protection::RemoteToken, options unless except.include? :remote_token
34
+ use ::Rack::Protection::SessionHijacking, options unless except.include? :session_hijacking
35
+ use ::Rack::Protection::XSSHeader, options unless except.include? :xss_header
31
36
  run app
32
37
  end.to_app
33
38
  end