rack-protection 2.0.8.1
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 +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
|