rack-protection 2.0.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +13 -0
- data/License +23 -0
- data/README.md +118 -0
- data/Rakefile +72 -0
- data/lib/rack-protection.rb +1 -0
- data/lib/rack/protection.rb +54 -0
- data/lib/rack/protection/authenticity_token.rb +196 -0
- data/lib/rack/protection/base.rb +126 -0
- data/lib/rack/protection/content_security_policy.rb +80 -0
- data/lib/rack/protection/cookie_tossing.rb +75 -0
- data/lib/rack/protection/escaped_params.rb +89 -0
- data/lib/rack/protection/form_token.rb +23 -0
- data/lib/rack/protection/frame_options.rb +37 -0
- data/lib/rack/protection/http_origin.rb +40 -0
- data/lib/rack/protection/ip_spoofing.rb +23 -0
- data/lib/rack/protection/json_csrf.rb +57 -0
- data/lib/rack/protection/path_traversal.rb +42 -0
- data/lib/rack/protection/remote_referrer.rb +20 -0
- data/lib/rack/protection/remote_token.rb +22 -0
- data/lib/rack/protection/session_hijacking.rb +36 -0
- data/lib/rack/protection/strict_transport.rb +39 -0
- data/lib/rack/protection/version.rb +5 -0
- data/lib/rack/protection/xss_header.rb +25 -0
- data/rack-protection.gemspec +40 -0
- metadata +114 -0
@@ -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
|