rack-protection 2.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,126 @@
1
+ require 'rack/protection'
2
+ require 'rack/utils'
3
+ require 'digest'
4
+ require 'logger'
5
+ require 'uri'
6
+
7
+ module Rack
8
+ module Protection
9
+ class Base
10
+ DEFAULT_OPTIONS = {
11
+ :reaction => :default_reaction, :logging => true,
12
+ :message => 'Forbidden', :encryptor => Digest::SHA1,
13
+ :session_key => 'rack.session', :status => 403,
14
+ :allow_empty_referrer => true,
15
+ :report_key => "protection.failed",
16
+ :html_types => %w[text/html application/xhtml text/xml application/xml]
17
+ }
18
+
19
+ attr_reader :app, :options
20
+
21
+ def self.default_options(options)
22
+ define_method(:default_options) { super().merge(options) }
23
+ end
24
+
25
+ def self.default_reaction(reaction)
26
+ alias_method(:default_reaction, reaction)
27
+ end
28
+
29
+ def default_options
30
+ DEFAULT_OPTIONS
31
+ end
32
+
33
+ def initialize(app, options = {})
34
+ @app, @options = app, default_options.merge(options)
35
+ end
36
+
37
+ def safe?(env)
38
+ %w[GET HEAD OPTIONS TRACE].include? env['REQUEST_METHOD']
39
+ end
40
+
41
+ def accepts?(env)
42
+ raise NotImplementedError, "#{self.class} implementation pending"
43
+ end
44
+
45
+ def call(env)
46
+ unless accepts? env
47
+ instrument env
48
+ result = react env
49
+ end
50
+ result or app.call(env)
51
+ end
52
+
53
+ def react(env)
54
+ result = send(options[:reaction], env)
55
+ result if Array === result and result.size == 3
56
+ end
57
+
58
+ def warn(env, message)
59
+ return unless options[:logging]
60
+ l = options[:logger] || env['rack.logger'] || ::Logger.new(env['rack.errors'])
61
+ l.warn(message)
62
+ end
63
+
64
+ def instrument(env)
65
+ return unless i = options[:instrumenter]
66
+ env['rack.protection.attack'] = self.class.name.split('::').last.downcase
67
+ i.instrument('rack.protection', env)
68
+ end
69
+
70
+ def deny(env)
71
+ warn env, "attack prevented by #{self.class}"
72
+ [options[:status], {'Content-Type' => 'text/plain'}, [options[:message]]]
73
+ end
74
+
75
+ def report(env)
76
+ warn env, "attack reported by #{self.class}"
77
+ env[options[:report_key]] = true
78
+ end
79
+
80
+ def session?(env)
81
+ env.include? options[:session_key]
82
+ end
83
+
84
+ def session(env)
85
+ return env[options[:session_key]] if session? env
86
+ fail "you need to set up a session middleware *before* #{self.class}"
87
+ end
88
+
89
+ def drop_session(env)
90
+ session(env).clear if session? env
91
+ end
92
+
93
+ def referrer(env)
94
+ ref = env['HTTP_REFERER'].to_s
95
+ return if !options[:allow_empty_referrer] and ref.empty?
96
+ URI.parse(ref).host || Request.new(env).host
97
+ rescue URI::InvalidURIError
98
+ end
99
+
100
+ def origin(env)
101
+ env['HTTP_ORIGIN'] || env['HTTP_X_ORIGIN']
102
+ end
103
+
104
+ def random_string(secure = defined? SecureRandom)
105
+ secure ? SecureRandom.hex(16) : "%032x" % rand(2**128-1)
106
+ rescue NotImplementedError
107
+ random_string false
108
+ end
109
+
110
+ def encrypt(value)
111
+ options[:encryptor].hexdigest value.to_s
112
+ end
113
+
114
+ def secure_compare(a, b)
115
+ Rack::Utils.secure_compare(a.to_s, b.to_s)
116
+ end
117
+
118
+ alias default_reaction deny
119
+
120
+ def html?(headers)
121
+ return false unless header = headers.detect { |k,v| k.downcase == 'content-type' }
122
+ options[:html_types].include? header.last[/^\w+\/\w+/]
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,80 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'rack/protection'
3
+
4
+ module Rack
5
+ module Protection
6
+ ##
7
+ # Prevented attack:: XSS and others
8
+ # Supported browsers:: Firefox 23+, Safari 7+, Chrome 25+, Opera 15+
9
+ #
10
+ # Description:: Content Security Policy, a mechanism web applications
11
+ # can use to mitigate a broad class of content injection
12
+ # vulnerabilities, such as cross-site scripting (XSS).
13
+ # Content Security Policy is a declarative policy that lets
14
+ # the authors (or server administrators) of a web application
15
+ # inform the client about the sources from which the
16
+ # application expects to load resources.
17
+ #
18
+ # More info:: W3C CSP Level 1 : https://www.w3.org/TR/CSP1/ (deprecated)
19
+ # W3C CSP Level 2 : https://www.w3.org/TR/CSP2/ (current)
20
+ # W3C CSP Level 3 : https://www.w3.org/TR/CSP3/ (draft)
21
+ # https://developer.mozilla.org/en-US/docs/Web/Security/CSP
22
+ # http://caniuse.com/#search=ContentSecurityPolicy
23
+ # http://content-security-policy.com/
24
+ # https://securityheaders.io
25
+ # https://scotthelme.co.uk/csp-cheat-sheet/
26
+ # http://www.html5rocks.com/en/tutorials/security/content-security-policy/
27
+ #
28
+ # Sets the 'Content-Security-Policy[-Report-Only]' header.
29
+ #
30
+ # Options: ContentSecurityPolicy configuration is a complex topic with
31
+ # several levels of support that has evolved over time.
32
+ # See the W3C documentation and the links in the more info
33
+ # section for CSP usage examples and best practices. The
34
+ # CSP3 directives in the 'NO_ARG_DIRECTIVES' constant need to be
35
+ # presented in the options hash with a boolean 'true' in order
36
+ # to be used in a policy.
37
+ #
38
+ class ContentSecurityPolicy < Base
39
+ default_options default_src: :none, script_src: "'self'",
40
+ img_src: "'self'", style_src: "'self'",
41
+ connect_src: "'self'", report_only: false
42
+
43
+ DIRECTIVES = %i(base_uri child_src connect_src default_src
44
+ font_src form_action frame_ancestors frame_src
45
+ img_src manifest_src media_src object_src
46
+ plugin_types referrer reflected_xss report_to
47
+ report_uri require_sri_for sandbox script_src
48
+ style_src worker_src).freeze
49
+
50
+ NO_ARG_DIRECTIVES = %i(block_all_mixed_content disown_opener
51
+ upgrade_insecure_requests).freeze
52
+
53
+ def csp_policy
54
+ directives = []
55
+
56
+ DIRECTIVES.each do |d|
57
+ if options.key?(d)
58
+ directives << "#{d.to_s.sub(/_/, '-')} #{options[d]}"
59
+ end
60
+ end
61
+
62
+ # Set these key values to boolean 'true' to include in policy
63
+ NO_ARG_DIRECTIVES.each do |d|
64
+ if options.key?(d) && options[d].is_a?(TrueClass)
65
+ directives << d.to_s.sub(/_/, '-')
66
+ end
67
+ end
68
+
69
+ directives.compact.sort.join('; ')
70
+ end
71
+
72
+ def call(env)
73
+ status, headers, body = @app.call(env)
74
+ header = options[:report_only] ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'
75
+ headers[header] ||= csp_policy if html? headers
76
+ [status, headers, body]
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,75 @@
1
+ require 'rack/protection'
2
+ require 'pathname'
3
+
4
+ module Rack
5
+ module Protection
6
+ ##
7
+ # Prevented attack:: Cookie Tossing
8
+ # Supported browsers:: all
9
+ # More infos:: https://github.com/blog/1466-yummy-cookies-across-domains
10
+ #
11
+ # Does not accept HTTP requests if the HTTP_COOKIE header contains more than one
12
+ # session cookie. This does not protect against a cookie overflow attack.
13
+ #
14
+ # Options:
15
+ #
16
+ # session_key:: The name of the session cookie (default: 'rack.session')
17
+ class CookieTossing < Base
18
+ default_reaction :deny
19
+
20
+ def call(env)
21
+ status, headers, body = super
22
+ response = Rack::Response.new(body, status, headers)
23
+ request = Rack::Request.new(env)
24
+ remove_bad_cookies(request, response)
25
+ response.finish
26
+ end
27
+
28
+ def accepts?(env)
29
+ cookie_header = env['HTTP_COOKIE']
30
+ cookies = Rack::Utils.parse_query(cookie_header, ';,') { |s| s }
31
+ cookies.each do |k, v|
32
+ if k == session_key && Array(v).size > 1
33
+ bad_cookies << k
34
+ elsif k != session_key && Rack::Utils.unescape(k) == session_key
35
+ bad_cookies << k
36
+ end
37
+ end
38
+ bad_cookies.empty?
39
+ end
40
+
41
+ def remove_bad_cookies(request, response)
42
+ return if bad_cookies.empty?
43
+ paths = cookie_paths(request.path)
44
+ bad_cookies.each do |name|
45
+ paths.each { |path| response.set_cookie name, empty_cookie(request.host, path) }
46
+ end
47
+ end
48
+
49
+ def redirect(env)
50
+ request = Request.new(env)
51
+ warn env, "attack prevented by #{self.class}"
52
+ [302, {'Content-Type' => 'text/html', 'Location' => request.path}, []]
53
+ end
54
+
55
+ def bad_cookies
56
+ @bad_cookies ||= []
57
+ end
58
+
59
+ def cookie_paths(path)
60
+ path = '/' if path.to_s.empty?
61
+ paths = []
62
+ Pathname.new(path).descend { |p| paths << p.to_s }
63
+ paths
64
+ end
65
+
66
+ def empty_cookie(host, path)
67
+ {:value => '', :domain => host, :path => path, :expires => Time.at(0)}
68
+ end
69
+
70
+ def session_key
71
+ @session_key ||= options[:session_key]
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,89 @@
1
+ require 'rack/protection'
2
+ require 'rack/utils'
3
+ require 'tempfile'
4
+
5
+ begin
6
+ require 'escape_utils'
7
+ rescue LoadError
8
+ end
9
+
10
+ module Rack
11
+ module Protection
12
+ ##
13
+ # Prevented attack:: XSS
14
+ # Supported browsers:: all
15
+ # More infos:: http://en.wikipedia.org/wiki/Cross-site_scripting
16
+ #
17
+ # Automatically escapes Rack::Request#params so they can be embedded in HTML
18
+ # or JavaScript without any further issues. Calls +html_safe+ on the escaped
19
+ # strings if defined, to avoid double-escaping in Rails.
20
+ #
21
+ # Options:
22
+ # escape:: What escaping modes to use, should be Symbol or Array of Symbols.
23
+ # Available: :html (default), :javascript, :url
24
+ class EscapedParams < Base
25
+ extend Rack::Utils
26
+
27
+ class << self
28
+ alias escape_url escape
29
+ public :escape_html
30
+ end
31
+
32
+ default_options :escape => :html,
33
+ :escaper => defined?(EscapeUtils) ? EscapeUtils : self
34
+
35
+ def initialize(*)
36
+ super
37
+
38
+ modes = Array options[:escape]
39
+ @escaper = options[:escaper]
40
+ @html = modes.include? :html
41
+ @javascript = modes.include? :javascript
42
+ @url = modes.include? :url
43
+
44
+ if @javascript and not @escaper.respond_to? :escape_javascript
45
+ fail("Use EscapeUtils for JavaScript escaping.")
46
+ end
47
+ end
48
+
49
+ def call(env)
50
+ request = Request.new(env)
51
+ get_was = handle(request.GET)
52
+ post_was = handle(request.POST) rescue nil
53
+ app.call env
54
+ ensure
55
+ request.GET.replace get_was if get_was
56
+ request.POST.replace post_was if post_was
57
+ end
58
+
59
+ def handle(hash)
60
+ was = hash.dup
61
+ hash.replace escape(hash)
62
+ was
63
+ end
64
+
65
+ def escape(object)
66
+ case object
67
+ when Hash then escape_hash(object)
68
+ when Array then object.map { |o| escape(o) }
69
+ when String then escape_string(object)
70
+ when Tempfile then object
71
+ else nil
72
+ end
73
+ end
74
+
75
+ def escape_hash(hash)
76
+ hash = hash.dup
77
+ hash.each { |k,v| hash[k] = escape(v) }
78
+ hash
79
+ end
80
+
81
+ def escape_string(str)
82
+ str = @escaper.escape_url(str) if @url
83
+ str = @escaper.escape_html(str) if @html
84
+ str = @escaper.escape_javascript(str) if @javascript
85
+ str
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,23 @@
1
+ require 'rack/protection'
2
+
3
+ module Rack
4
+ module Protection
5
+ ##
6
+ # Prevented attack:: CSRF
7
+ # Supported browsers:: all
8
+ # More infos:: http://en.wikipedia.org/wiki/Cross-site_request_forgery
9
+ #
10
+ # Only accepts submitted forms if a given access token matches the token
11
+ # included in the session. Does not expect such a token from Ajax request.
12
+ #
13
+ # This middleware is not used when using the Rack::Protection collection,
14
+ # since it might be a security issue, depending on your application
15
+ #
16
+ # Compatible with rack-csrf.
17
+ class FormToken < AuthenticityToken
18
+ def accepts?(env)
19
+ env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest" or super
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ require 'rack/protection'
2
+
3
+ module Rack
4
+ module Protection
5
+ ##
6
+ # Prevented attack:: Clickjacking
7
+ # Supported browsers:: Internet Explorer 8, Firefox 3.6.9, Opera 10.50,
8
+ # Safari 4.0, Chrome 4.1.249.1042 and later
9
+ # More infos:: https://developer.mozilla.org/en/The_X-FRAME-OPTIONS_response_header
10
+ #
11
+ # Sets X-Frame-Options header to tell the browser avoid embedding the page
12
+ # in a frame.
13
+ #
14
+ # Options:
15
+ #
16
+ # frame_options:: Defines who should be allowed to embed the page in a
17
+ # frame. Use :deny to forbid any embedding, :sameorigin
18
+ # to allow embedding from the same origin (default).
19
+ class FrameOptions < Base
20
+ default_options :frame_options => :sameorigin
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]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,40 @@
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
+ #
14
+ # If you want to whitelist a specific domain, you can pass in as the `:origin_whitelist` option:
15
+ #
16
+ # use Rack::Protection, origin_whitelist: ["http://localhost:3000", "http://127.0.01:3000"]
17
+ #
18
+ # The `:allow_if` option can also be set to a proc to use custom allow/deny logic.
19
+ class HttpOrigin < Base
20
+ DEFAULT_PORTS = { 'http' => 80, 'https' => 443, 'coffee' => 80 }
21
+ default_reaction :deny
22
+ default_options :allow_if => nil
23
+
24
+ def base_url(env)
25
+ request = Rack::Request.new(env)
26
+ port = ":#{request.port}" unless request.port == DEFAULT_PORTS[request.scheme]
27
+ "#{request.scheme}://#{request.host}#{port}"
28
+ end
29
+
30
+ def accepts?(env)
31
+ return true if safe? env
32
+ return true unless origin = env['HTTP_ORIGIN']
33
+ return true if base_url(env) == origin
34
+ return true if options[:allow_if] && options[:allow_if].call(env)
35
+ Array(options[:origin_whitelist]).include? origin
36
+ end
37
+
38
+ end
39
+ end
40
+ end