rack-protection 3.2.0 → 4.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +2 -5
- data/README.md +6 -1
- data/lib/rack/protection/authenticity_token.rb +5 -4
- data/lib/rack/protection/base.rb +8 -1
- data/lib/rack/protection/content_security_policy.rb +2 -2
- data/lib/rack/protection/cookie_tossing.rb +1 -1
- data/lib/rack/protection/frame_options.rb +1 -1
- data/lib/rack/protection/host_authorization.rb +110 -0
- data/lib/rack/protection/json_csrf.rb +1 -1
- data/lib/rack/protection/referrer_policy.rb +1 -1
- data/lib/rack/protection/strict_transport.rb +1 -1
- data/lib/rack/protection/version.rb +1 -1
- data/lib/rack/protection/xss_header.rb +2 -2
- data/lib/rack/protection.rb +3 -5
- data/rack-protection.gemspec +3 -2
- metadata +27 -14
- data/lib/rack/protection/encrypted_cookie.rb +0 -273
- data/lib/rack/protection/encryptor.rb +0 -62
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4c04192c3b3ef137443ac72495e4d948a544c1f7c7fd514f422045b870071b54
|
4
|
+
data.tar.gz: 5fc1d9022ef2d26e1c18dd56752883d3d99c6293fbd2a67708628bd414ab2f3f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 26d822dde064625a3520d8bd95b0b2499e4d94d081e8943bfeb4283cab18fd0f70d98b7e5e3cdea5298a75edc70a0a5fc44444e5563fab90db179136901758b6
|
7
|
+
data.tar.gz: d9618b6af6ffc69006e8404a1dc18dc4bd082a516042f25d804613e67438a27830f81b3fc87f2f82d2c7ff5e6607d461e30117515bf2f0be9ea36a0ff094f28d
|
data/Gemfile
CHANGED
@@ -1,16 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
source 'https://rubygems.org'
|
4
|
-
|
4
|
+
gemspec
|
5
5
|
|
6
6
|
gem 'rake'
|
7
7
|
gem 'rspec', '~> 3'
|
8
|
+
gem 'rack-test'
|
8
9
|
|
9
10
|
rack_version = ENV['rack'].to_s
|
10
11
|
rack_version = nil if rack_version.empty? || (rack_version == 'stable')
|
11
12
|
rack_version = { github: 'rack/rack' } if rack_version == 'head'
|
12
13
|
gem 'rack', rack_version
|
13
|
-
|
14
|
-
gemspec
|
15
|
-
|
16
|
-
gem 'rack-test'
|
data/README.md
CHANGED
@@ -34,6 +34,10 @@ run MyApp
|
|
34
34
|
|
35
35
|
# Prevented Attacks
|
36
36
|
|
37
|
+
## DNS rebinding and other Host header attacks
|
38
|
+
|
39
|
+
* [`Rack::Protection::HostAuthorization`][host-authorization] (not included by `use Rack::Protection`)
|
40
|
+
|
37
41
|
## Cross Site Request Forgery
|
38
42
|
|
39
43
|
Prevented by:
|
@@ -69,7 +73,7 @@ Prevented by:
|
|
69
73
|
|
70
74
|
Prevented by:
|
71
75
|
|
72
|
-
* [`Rack::Protection::SessionHijacking`][session-hijacking]
|
76
|
+
* [`Rack::Protection::SessionHijacking`][session-hijacking] (not included by `use Rack::Protection`)
|
73
77
|
|
74
78
|
## Cookie Tossing
|
75
79
|
|
@@ -109,6 +113,7 @@ The instrumenter is passed a namespace (String) and environment (Hash). The name
|
|
109
113
|
[escaped-params]: http://www.sinatrarb.com/protection/escaped_params
|
110
114
|
[form-token]: http://www.sinatrarb.com/protection/form_token
|
111
115
|
[frame-options]: http://www.sinatrarb.com/protection/frame_options
|
116
|
+
[host-authorization]: https://github.com/sinatra/sinatra/blob/main/rack-protection/lib/rack/protection/host_authorization.rb
|
112
117
|
[http-origin]: http://www.sinatrarb.com/protection/http_origin
|
113
118
|
[ip-spoofing]: http://www.sinatrarb.com/protection/ip_spoofing
|
114
119
|
[json-csrf]: http://www.sinatrarb.com/protection/json_csrf
|
@@ -46,14 +46,15 @@ module Rack
|
|
46
46
|
# Install the gem, then run the program:
|
47
47
|
#
|
48
48
|
# gem install 'rack-protection'
|
49
|
-
#
|
49
|
+
# puma server.ru
|
50
50
|
#
|
51
|
-
# Here is <tt>server.
|
51
|
+
# Here is <tt>server.ru</tt>:
|
52
52
|
#
|
53
53
|
# require 'rack/protection'
|
54
|
+
# require 'rack/session'
|
54
55
|
#
|
55
56
|
# app = Rack::Builder.app do
|
56
|
-
# use Rack::Session::Cookie, secret: '
|
57
|
+
# use Rack::Session::Cookie, secret: 'CHANGEMEaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
57
58
|
# use Rack::Protection::AuthenticityToken
|
58
59
|
#
|
59
60
|
# run -> (env) do
|
@@ -87,7 +88,7 @@ module Rack
|
|
87
88
|
# end
|
88
89
|
# end
|
89
90
|
#
|
90
|
-
#
|
91
|
+
# run app
|
91
92
|
#
|
92
93
|
# == Example: Customize which POST parameter holds the token
|
93
94
|
#
|
data/lib/rack/protection/base.rb
CHANGED
@@ -58,6 +58,13 @@ module Rack
|
|
58
58
|
result if (Array === result) && (result.size == 3)
|
59
59
|
end
|
60
60
|
|
61
|
+
def debug(env, message)
|
62
|
+
return unless options[:logging]
|
63
|
+
|
64
|
+
l = options[:logger] || env['rack.logger'] || ::Logger.new(env['rack.errors'])
|
65
|
+
l.debug(message)
|
66
|
+
end
|
67
|
+
|
61
68
|
def warn(env, message)
|
62
69
|
return unless options[:logging]
|
63
70
|
|
@@ -74,7 +81,7 @@ module Rack
|
|
74
81
|
|
75
82
|
def deny(env)
|
76
83
|
warn env, "attack prevented by #{self.class}"
|
77
|
-
[options[:status], { '
|
84
|
+
[options[:status], { 'content-type' => 'text/plain' }, [options[:message]]]
|
78
85
|
end
|
79
86
|
|
80
87
|
def report(env)
|
@@ -26,7 +26,7 @@ module Rack
|
|
26
26
|
# https://scotthelme.co.uk/csp-cheat-sheet/
|
27
27
|
# http://www.html5rocks.com/en/tutorials/security/content-security-policy/
|
28
28
|
#
|
29
|
-
# Sets the '
|
29
|
+
# Sets the 'content-security-policy[-report-only]' header.
|
30
30
|
#
|
31
31
|
# Options: ContentSecurityPolicy configuration is a complex topic with
|
32
32
|
# several levels of support that has evolved over time.
|
@@ -71,7 +71,7 @@ module Rack
|
|
71
71
|
|
72
72
|
def call(env)
|
73
73
|
status, headers, body = @app.call(env)
|
74
|
-
header = options[:report_only] ? '
|
74
|
+
header = options[:report_only] ? 'content-security-policy-report-only' : 'content-security-policy'
|
75
75
|
headers[header] ||= csp_policy if html? headers
|
76
76
|
[status, headers, body]
|
77
77
|
end
|
@@ -51,7 +51,7 @@ module Rack
|
|
51
51
|
def redirect(env)
|
52
52
|
request = Request.new(env)
|
53
53
|
warn env, "attack prevented by #{self.class}"
|
54
|
-
[302, { '
|
54
|
+
[302, { 'content-type' => 'text/html', 'location' => request.path }, []]
|
55
55
|
end
|
56
56
|
|
57
57
|
def bad_cookies
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rack/protection'
|
4
|
+
require 'ipaddr'
|
5
|
+
|
6
|
+
module Rack
|
7
|
+
module Protection
|
8
|
+
##
|
9
|
+
# Prevented attack:: DNS rebinding and other Host header attacks
|
10
|
+
# Supported browsers:: all
|
11
|
+
# More infos:: https://en.wikipedia.org/wiki/DNS_rebinding
|
12
|
+
# https://portswigger.net/web-security/host-header
|
13
|
+
#
|
14
|
+
# Blocks HTTP requests with an unrecognized hostname in any of the following
|
15
|
+
# HTTP headers: Host, X-Forwarded-Host, Forwarded
|
16
|
+
#
|
17
|
+
# If you want to permit a specific hostname, you can pass in as the `:permitted_hosts` option:
|
18
|
+
#
|
19
|
+
# use Rack::Protection::HostAuthorization, permitted_hosts: ["www.example.org", "sinatrarb.com"]
|
20
|
+
#
|
21
|
+
# The `:allow_if` option can also be set to a proc to use custom allow/deny logic.
|
22
|
+
class HostAuthorization < Base
|
23
|
+
DOT = '.'
|
24
|
+
PORT_REGEXP = /:\d+\z/.freeze
|
25
|
+
SUBDOMAINS = /[a-z0-9\-.]+/.freeze
|
26
|
+
private_constant :DOT,
|
27
|
+
:PORT_REGEXP,
|
28
|
+
:SUBDOMAINS
|
29
|
+
default_reaction :deny
|
30
|
+
default_options allow_if: nil,
|
31
|
+
message: 'Host not permitted'
|
32
|
+
|
33
|
+
def initialize(*)
|
34
|
+
super
|
35
|
+
@permitted_hosts = []
|
36
|
+
@domain_hosts = []
|
37
|
+
@ip_hosts = []
|
38
|
+
@all_permitted_hosts = Array(options[:permitted_hosts])
|
39
|
+
|
40
|
+
@all_permitted_hosts.each do |host|
|
41
|
+
case host
|
42
|
+
when String
|
43
|
+
if host.start_with?(DOT)
|
44
|
+
domain = host[1..-1]
|
45
|
+
@permitted_hosts << domain.downcase
|
46
|
+
@domain_hosts << /\A#{SUBDOMAINS}#{Regexp.escape(domain)}\z/i
|
47
|
+
else
|
48
|
+
@permitted_hosts << host.downcase
|
49
|
+
end
|
50
|
+
when IPAddr then @ip_hosts << host
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def accepts?(env)
|
56
|
+
return true if options[:allow_if]&.call(env)
|
57
|
+
return true if @all_permitted_hosts.empty?
|
58
|
+
|
59
|
+
request = Request.new(env)
|
60
|
+
origin_host = extract_host(request.host_authority)
|
61
|
+
forwarded_host = extract_host(request.forwarded_authority)
|
62
|
+
|
63
|
+
debug env, "#{self.class} " \
|
64
|
+
"@all_permitted_hosts=#{@all_permitted_hosts.inspect} " \
|
65
|
+
"@permitted_hosts=#{@permitted_hosts.inspect} " \
|
66
|
+
"@domain_hosts=#{@domain_hosts.inspect} " \
|
67
|
+
"@ip_hosts=#{@ip_hosts.inspect} " \
|
68
|
+
"origin_host=#{origin_host.inspect} " \
|
69
|
+
"forwarded_host=#{forwarded_host.inspect}"
|
70
|
+
|
71
|
+
if host_permitted?(origin_host)
|
72
|
+
if forwarded_host.nil?
|
73
|
+
true
|
74
|
+
else
|
75
|
+
host_permitted?(forwarded_host)
|
76
|
+
end
|
77
|
+
else
|
78
|
+
false
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def extract_host(authority)
|
85
|
+
authority.to_s.split(PORT_REGEXP).first&.downcase
|
86
|
+
end
|
87
|
+
|
88
|
+
def host_permitted?(host)
|
89
|
+
exact_match?(host) || domain_match?(host) || ip_match?(host)
|
90
|
+
end
|
91
|
+
|
92
|
+
def exact_match?(host)
|
93
|
+
@permitted_hosts.include?(host)
|
94
|
+
end
|
95
|
+
|
96
|
+
def domain_match?(host)
|
97
|
+
return false if host.nil?
|
98
|
+
return false if host.start_with?(DOT)
|
99
|
+
|
100
|
+
@domain_hosts.any? { |domain_host| host.match?(domain_host) }
|
101
|
+
end
|
102
|
+
|
103
|
+
def ip_match?(host)
|
104
|
+
@ip_hosts.any? { |ip_host| ip_host.include?(host) }
|
105
|
+
rescue IPAddr::InvalidAddressError
|
106
|
+
false
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -39,7 +39,7 @@ module Rack
|
|
39
39
|
def has_vector?(request, headers)
|
40
40
|
return false if request.xhr?
|
41
41
|
return false if options[:allow_if]&.call(request.env)
|
42
|
-
return false unless headers['
|
42
|
+
return false unless headers['content-type'].to_s.split(';', 2).first =~ %r{^\s*application/json\s*$}
|
43
43
|
|
44
44
|
origin(request.env).nil? and referrer(request.env) != request.host
|
45
45
|
end
|
@@ -18,8 +18,8 @@ module Rack
|
|
18
18
|
|
19
19
|
def call(env)
|
20
20
|
status, headers, body = @app.call(env)
|
21
|
-
headers['
|
22
|
-
headers['
|
21
|
+
headers['x-xss-protection'] ||= "1; mode=#{options[:xss_mode]}" if html? headers
|
22
|
+
headers['x-content-type-options'] ||= 'nosniff' if options[:nosniff]
|
23
23
|
[status, headers, body]
|
24
24
|
end
|
25
25
|
end
|
data/lib/rack/protection.rb
CHANGED
@@ -9,11 +9,10 @@ module Rack
|
|
9
9
|
autoload :Base, 'rack/protection/base'
|
10
10
|
autoload :CookieTossing, 'rack/protection/cookie_tossing'
|
11
11
|
autoload :ContentSecurityPolicy, 'rack/protection/content_security_policy'
|
12
|
-
autoload :Encryptor, 'rack/protection/encryptor'
|
13
|
-
autoload :EncryptedCookie, 'rack/protection/encrypted_cookie'
|
14
12
|
autoload :EscapedParams, 'rack/protection/escaped_params'
|
15
13
|
autoload :FormToken, 'rack/protection/form_token'
|
16
14
|
autoload :FrameOptions, 'rack/protection/frame_options'
|
15
|
+
autoload :HostAuthorization, 'rack/protection/host_authorization'
|
17
16
|
autoload :HttpOrigin, 'rack/protection/http_origin'
|
18
17
|
autoload :IPSpoofing, 'rack/protection/ip_spoofing'
|
19
18
|
autoload :JsonCsrf, 'rack/protection/json_csrf'
|
@@ -26,12 +25,11 @@ module Rack
|
|
26
25
|
autoload :XSSHeader, 'rack/protection/xss_header'
|
27
26
|
|
28
27
|
def self.new(app, options = {})
|
29
|
-
# does not include: RemoteReferrer, AuthenticityToken and FormToken
|
30
28
|
except = Array options[:except]
|
31
29
|
use_these = Array options[:use]
|
32
30
|
|
33
31
|
if options.fetch(:without_session, false)
|
34
|
-
except += %i[
|
32
|
+
except += %i[remote_token]
|
35
33
|
end
|
36
34
|
|
37
35
|
Rack::Builder.new do
|
@@ -43,6 +41,7 @@ module Rack
|
|
43
41
|
use ::Rack::Protection::FormToken, options if use_these.include? :form_token
|
44
42
|
use ::Rack::Protection::ReferrerPolicy, options if use_these.include? :referrer_policy
|
45
43
|
use ::Rack::Protection::RemoteReferrer, options if use_these.include? :remote_referrer
|
44
|
+
use ::Rack::Protection::SessionHijacking, options if use_these.include? :session_hijacking
|
46
45
|
use ::Rack::Protection::StrictTransport, options if use_these.include? :strict_transport
|
47
46
|
|
48
47
|
# On by default, unless skipped
|
@@ -52,7 +51,6 @@ module Rack
|
|
52
51
|
use ::Rack::Protection::JsonCsrf, options unless except.include? :json_csrf
|
53
52
|
use ::Rack::Protection::PathTraversal, options unless except.include? :path_traversal
|
54
53
|
use ::Rack::Protection::RemoteToken, options unless except.include? :remote_token
|
55
|
-
use ::Rack::Protection::SessionHijacking, options unless except.include? :session_hijacking
|
56
54
|
use ::Rack::Protection::XSSHeader, options unless except.include? :xss_header
|
57
55
|
run app
|
58
56
|
end.to_app
|
data/rack-protection.gemspec
CHANGED
@@ -36,9 +36,10 @@ RubyGems 2.0 or newer is required to protect against public gem pushes. You can
|
|
36
36
|
'rubygems_mfa_required' => 'true'
|
37
37
|
}
|
38
38
|
|
39
|
-
s.required_ruby_version = '>= 2.
|
39
|
+
s.required_ruby_version = '>= 2.7.8'
|
40
40
|
|
41
41
|
# dependencies
|
42
42
|
s.add_dependency 'base64', '>= 0.1.0'
|
43
|
-
s.add_dependency '
|
43
|
+
s.add_dependency 'logger', '>= 1.6.0'
|
44
|
+
s.add_dependency 'rack', '>= 3.0.0', '< 4'
|
44
45
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rack-protection
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 4.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- https://github.com/sinatra/sinatra/graphs/contributors
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-11-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: base64
|
@@ -25,25 +25,39 @@ dependencies:
|
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: 0.1.0
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: logger
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
|
-
- - "~>"
|
32
|
-
- !ruby/object:Gem::Version
|
33
|
-
version: '2.2'
|
34
31
|
- - ">="
|
35
32
|
- !ruby/object:Gem::Version
|
36
|
-
version:
|
33
|
+
version: 1.6.0
|
37
34
|
type: :runtime
|
38
35
|
prerelease: false
|
39
36
|
version_requirements: !ruby/object:Gem::Requirement
|
40
37
|
requirements:
|
41
|
-
- - "
|
38
|
+
- - ">="
|
42
39
|
- !ruby/object:Gem::Version
|
43
|
-
version:
|
40
|
+
version: 1.6.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rack
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
44
45
|
- - ">="
|
45
46
|
- !ruby/object:Gem::Version
|
46
|
-
version:
|
47
|
+
version: 3.0.0
|
48
|
+
- - "<"
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: '4'
|
51
|
+
type: :runtime
|
52
|
+
prerelease: false
|
53
|
+
version_requirements: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: 3.0.0
|
58
|
+
- - "<"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '4'
|
47
61
|
description: Protect against typical web attacks, works with all Rack apps, including
|
48
62
|
Rails
|
49
63
|
email: sinatrarb@googlegroups.com
|
@@ -61,11 +75,10 @@ files:
|
|
61
75
|
- lib/rack/protection/base.rb
|
62
76
|
- lib/rack/protection/content_security_policy.rb
|
63
77
|
- lib/rack/protection/cookie_tossing.rb
|
64
|
-
- lib/rack/protection/encrypted_cookie.rb
|
65
|
-
- lib/rack/protection/encryptor.rb
|
66
78
|
- lib/rack/protection/escaped_params.rb
|
67
79
|
- lib/rack/protection/form_token.rb
|
68
80
|
- lib/rack/protection/frame_options.rb
|
81
|
+
- lib/rack/protection/host_authorization.rb
|
69
82
|
- lib/rack/protection/http_origin.rb
|
70
83
|
- lib/rack/protection/ip_spoofing.rb
|
71
84
|
- lib/rack/protection/json_csrf.rb
|
@@ -95,14 +108,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
108
|
requirements:
|
96
109
|
- - ">="
|
97
110
|
- !ruby/object:Gem::Version
|
98
|
-
version: 2.
|
111
|
+
version: 2.7.8
|
99
112
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
100
113
|
requirements:
|
101
114
|
- - ">="
|
102
115
|
- !ruby/object:Gem::Version
|
103
116
|
version: '0'
|
104
117
|
requirements: []
|
105
|
-
rubygems_version: 3.5.
|
118
|
+
rubygems_version: 3.5.22
|
106
119
|
signing_key:
|
107
120
|
specification_version: 4
|
108
121
|
summary: Protect against typical web attacks, works with all Rack apps, including
|
@@ -1,273 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'openssl'
|
4
|
-
require 'zlib'
|
5
|
-
require 'json'
|
6
|
-
require 'rack/request'
|
7
|
-
require 'rack/response'
|
8
|
-
require 'rack/session/abstract/id'
|
9
|
-
|
10
|
-
module Rack
|
11
|
-
module Protection
|
12
|
-
# Rack::Protection::EncryptedCookie provides simple cookie based session management.
|
13
|
-
# By default, the session is a Ruby Hash stored as base64 encoded marshalled
|
14
|
-
# data set to :key (default: rack.session). The object that encodes the
|
15
|
-
# session data is configurable and must respond to +encode+ and +decode+.
|
16
|
-
# Both methods must take a string and return a string.
|
17
|
-
#
|
18
|
-
# When the secret key is set, cookie data is checked for data integrity.
|
19
|
-
# The old_secret key is also accepted and allows graceful secret rotation.
|
20
|
-
# A legacy_hmac_secret is also accepted and is used to upgrade existing
|
21
|
-
# sessions to the new encryption scheme.
|
22
|
-
#
|
23
|
-
# There is also a legacy_hmac_coder option which can be set if a non-default
|
24
|
-
# coder was used for legacy session cookies.
|
25
|
-
#
|
26
|
-
# Example:
|
27
|
-
#
|
28
|
-
# use Rack::Protection::EncryptedCookie,
|
29
|
-
# :key => 'rack.session',
|
30
|
-
# :domain => 'foo.com',
|
31
|
-
# :path => '/',
|
32
|
-
# :expire_after => 2592000,
|
33
|
-
# :secret => 'change_me',
|
34
|
-
# :old_secret => 'old_secret'
|
35
|
-
#
|
36
|
-
# All parameters are optional.
|
37
|
-
#
|
38
|
-
# Example using legacy HMAC options
|
39
|
-
#
|
40
|
-
# Rack::Protection:EncryptedCookie.new(application, {
|
41
|
-
# # The secret used for legacy HMAC cookies
|
42
|
-
# legacy_hmac_secret: 'legacy secret',
|
43
|
-
# # legacy_hmac_coder will default to Rack::Protection::EncryptedCookie::Base64::Marshal
|
44
|
-
# legacy_hmac_coder: Rack::Protection::EncryptedCookie::Identity.new,
|
45
|
-
# # legacy_hmac will default to OpenSSL::Digest::SHA1
|
46
|
-
# legacy_hmac: OpenSSL::Digest::SHA256
|
47
|
-
# })
|
48
|
-
#
|
49
|
-
# Example of a cookie with no encoding:
|
50
|
-
#
|
51
|
-
# Rack::Protection::EncryptedCookie.new(application, {
|
52
|
-
# :coder => Rack::Protection::EncryptedCookie::Identity.new
|
53
|
-
# })
|
54
|
-
#
|
55
|
-
# Example of a cookie with custom encoding:
|
56
|
-
#
|
57
|
-
# Rack::Protection::EncryptedCookie.new(application, {
|
58
|
-
# :coder => Class.new {
|
59
|
-
# def encode(str); str.reverse; end
|
60
|
-
# def decode(str); str.reverse; end
|
61
|
-
# }.new
|
62
|
-
# })
|
63
|
-
#
|
64
|
-
class EncryptedCookie < Rack::Session::Abstract::Persisted
|
65
|
-
# Encode session cookies as Base64
|
66
|
-
class Base64
|
67
|
-
def encode(str)
|
68
|
-
[str].pack('m0')
|
69
|
-
end
|
70
|
-
|
71
|
-
def decode(str)
|
72
|
-
str.unpack1('m')
|
73
|
-
end
|
74
|
-
|
75
|
-
# Encode session cookies as Marshaled Base64 data
|
76
|
-
class Marshal < Base64
|
77
|
-
def encode(str)
|
78
|
-
super(::Marshal.dump(str))
|
79
|
-
end
|
80
|
-
|
81
|
-
def decode(str)
|
82
|
-
return unless str
|
83
|
-
|
84
|
-
begin
|
85
|
-
::Marshal.load(super(str))
|
86
|
-
rescue StandardError
|
87
|
-
nil
|
88
|
-
end
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
# N.B. Unlike other encoding methods, the contained objects must be a
|
93
|
-
# valid JSON composite type, either a Hash or an Array.
|
94
|
-
class JSON < Base64
|
95
|
-
def encode(obj)
|
96
|
-
super(::JSON.dump(obj))
|
97
|
-
end
|
98
|
-
|
99
|
-
def decode(str)
|
100
|
-
return unless str
|
101
|
-
|
102
|
-
begin
|
103
|
-
::JSON.parse(super(str))
|
104
|
-
rescue StandardError
|
105
|
-
nil
|
106
|
-
end
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
|
-
class ZipJSON < Base64
|
111
|
-
def encode(obj)
|
112
|
-
super(Zlib::Deflate.deflate(::JSON.dump(obj)))
|
113
|
-
end
|
114
|
-
|
115
|
-
def decode(str)
|
116
|
-
return unless str
|
117
|
-
|
118
|
-
::JSON.parse(Zlib::Inflate.inflate(super(str)))
|
119
|
-
rescue StandardError
|
120
|
-
nil
|
121
|
-
end
|
122
|
-
end
|
123
|
-
end
|
124
|
-
|
125
|
-
# Use no encoding for session cookies
|
126
|
-
class Identity
|
127
|
-
def encode(str); str; end
|
128
|
-
def decode(str); str; end
|
129
|
-
end
|
130
|
-
|
131
|
-
class Marshal
|
132
|
-
def encode(str)
|
133
|
-
::Marshal.dump(str)
|
134
|
-
end
|
135
|
-
|
136
|
-
def decode(str)
|
137
|
-
::Marshal.load(str) if str
|
138
|
-
end
|
139
|
-
end
|
140
|
-
|
141
|
-
attr_reader :coder
|
142
|
-
|
143
|
-
def initialize(app, options = {})
|
144
|
-
# Assume keys are hex strings and convert them to raw byte strings for
|
145
|
-
# actual key material
|
146
|
-
@secrets = options.values_at(:secret, :old_secret).compact.map do |secret|
|
147
|
-
[secret].pack('H*')
|
148
|
-
end
|
149
|
-
|
150
|
-
warn <<-MSG unless secure?(options)
|
151
|
-
SECURITY WARNING: No secret option provided to Rack::Protection::EncryptedCookie.
|
152
|
-
This poses a security threat. It is strongly recommended that you
|
153
|
-
provide a secret to prevent exploits that may be possible from crafted
|
154
|
-
cookies. This will not be supported in future versions of Rack, and
|
155
|
-
future versions will even invalidate your existing user cookies.
|
156
|
-
|
157
|
-
Called from: #{caller[0]}.
|
158
|
-
MSG
|
159
|
-
|
160
|
-
warn <<-MSG if @secrets.first && @secrets.first.length < 32
|
161
|
-
SECURITY WARNING: Your secret is not long enough. It must be at least
|
162
|
-
32 bytes long and securely random. To generate such a key for use
|
163
|
-
you can run the following command:
|
164
|
-
|
165
|
-
ruby -rsecurerandom -e 'p SecureRandom.hex(32)'
|
166
|
-
|
167
|
-
Called from: #{caller[0]}.
|
168
|
-
MSG
|
169
|
-
|
170
|
-
if options.key?(:legacy_hmac_secret)
|
171
|
-
@legacy_hmac = options.fetch(:legacy_hmac, OpenSSL::Digest::SHA1)
|
172
|
-
|
173
|
-
# Multiply the :digest_length: by 2 because this value is the length of
|
174
|
-
# the digest in bytes but session digest strings are encoded as hex
|
175
|
-
# strings
|
176
|
-
@legacy_hmac_length = @legacy_hmac.new.digest_length * 2
|
177
|
-
@legacy_hmac_secret = options[:legacy_hmac_secret]
|
178
|
-
@legacy_hmac_coder = (options[:legacy_hmac_coder] ||= Base64::Marshal.new)
|
179
|
-
else
|
180
|
-
@legacy_hmac = false
|
181
|
-
end
|
182
|
-
|
183
|
-
# If encryption is used we can just use a default Marshal encoder
|
184
|
-
# without Base64 encoding the results.
|
185
|
-
#
|
186
|
-
# If no encryption is used, rely on the previous default (Base64::Marshal)
|
187
|
-
@coder = (options[:coder] ||= (@secrets.any? ? Marshal.new : Base64::Marshal.new))
|
188
|
-
|
189
|
-
super(app, options.merge!(cookie_only: true))
|
190
|
-
end
|
191
|
-
|
192
|
-
private
|
193
|
-
|
194
|
-
def find_session(req, _sid)
|
195
|
-
data = unpacked_cookie_data(req)
|
196
|
-
data = persistent_session_id!(data)
|
197
|
-
[data['session_id'], data]
|
198
|
-
end
|
199
|
-
|
200
|
-
def extract_session_id(request)
|
201
|
-
unpacked_cookie_data(request)['session_id']
|
202
|
-
end
|
203
|
-
|
204
|
-
def unpacked_cookie_data(request)
|
205
|
-
request.fetch_header(RACK_SESSION_UNPACKED_COOKIE_DATA) do |k|
|
206
|
-
session_data = cookie_data = request.cookies[@key]
|
207
|
-
|
208
|
-
# Try to decrypt with the first secret, if that returns nil, try
|
209
|
-
# with old_secret
|
210
|
-
unless @secrets.empty?
|
211
|
-
session_data = Rack::Protection::Encryptor.decrypt_message(cookie_data, @secrets.first)
|
212
|
-
session_data ||= Rack::Protection::Encryptor.decrypt_message(cookie_data, @secrets[1]) if @secrets.size > 1
|
213
|
-
end
|
214
|
-
|
215
|
-
# If session_data is still nil, are there is a legacy HMAC
|
216
|
-
# configured, try verify and parse the cookie that way
|
217
|
-
if !session_data && @legacy_hmac
|
218
|
-
digest = cookie_data.slice!(-@legacy_hmac_length..-1)
|
219
|
-
cookie_data.slice!(-2..-1) # remove double dash
|
220
|
-
session_data = cookie_data if digest_match?(cookie_data, digest)
|
221
|
-
|
222
|
-
# Decode using legacy HMAC decoder
|
223
|
-
request.set_header(k, @legacy_hmac_coder.decode(session_data) || {})
|
224
|
-
else
|
225
|
-
request.set_header(k, coder.decode(session_data) || {})
|
226
|
-
end
|
227
|
-
end
|
228
|
-
end
|
229
|
-
|
230
|
-
def persistent_session_id!(data, sid = nil)
|
231
|
-
data ||= {}
|
232
|
-
data['session_id'] ||= sid || generate_sid
|
233
|
-
data
|
234
|
-
end
|
235
|
-
|
236
|
-
def write_session(req, session_id, session, _options)
|
237
|
-
session = session.merge('session_id' => session_id)
|
238
|
-
session_data = coder.encode(session)
|
239
|
-
|
240
|
-
unless @secrets.empty?
|
241
|
-
session_data = Rack::Protection::Encryptor.encrypt_message(session_data, @secrets.first)
|
242
|
-
end
|
243
|
-
|
244
|
-
if session_data.size > (4096 - @key.size)
|
245
|
-
req.get_header(RACK_ERRORS).puts('Warning! Rack::Protection::EncryptedCookie data size exceeds 4K.')
|
246
|
-
nil
|
247
|
-
else
|
248
|
-
session_data
|
249
|
-
end
|
250
|
-
end
|
251
|
-
|
252
|
-
def delete_session(_req, _session_id, options)
|
253
|
-
# Nothing to do here, data is in the client
|
254
|
-
generate_sid unless options[:drop]
|
255
|
-
end
|
256
|
-
|
257
|
-
def digest_match?(data, digest)
|
258
|
-
return false unless data && digest
|
259
|
-
|
260
|
-
Rack::Utils.secure_compare(digest, generate_hmac(data))
|
261
|
-
end
|
262
|
-
|
263
|
-
def generate_hmac(data)
|
264
|
-
OpenSSL::HMAC.hexdigest(@legacy_hmac.new, @legacy_hmac_secret, data)
|
265
|
-
end
|
266
|
-
|
267
|
-
def secure?(options)
|
268
|
-
@secrets.size >= 1 ||
|
269
|
-
(options[:coder] && options[:let_coder_handle_secure_encoding])
|
270
|
-
end
|
271
|
-
end
|
272
|
-
end
|
273
|
-
end
|
@@ -1,62 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'openssl'
|
4
|
-
|
5
|
-
module Rack
|
6
|
-
module Protection
|
7
|
-
module Encryptor
|
8
|
-
CIPHER = 'aes-256-gcm'
|
9
|
-
DELIMITER = '--'
|
10
|
-
|
11
|
-
def self.base64_encode(str)
|
12
|
-
[str].pack('m0')
|
13
|
-
end
|
14
|
-
|
15
|
-
def self.base64_decode(str)
|
16
|
-
str.unpack1('m0')
|
17
|
-
end
|
18
|
-
|
19
|
-
def self.encrypt_message(data, secret, auth_data = '')
|
20
|
-
raise ArgumentError, 'data cannot be nil' if data.nil?
|
21
|
-
|
22
|
-
cipher = OpenSSL::Cipher.new(CIPHER)
|
23
|
-
cipher.encrypt
|
24
|
-
cipher.key = secret[0, cipher.key_len]
|
25
|
-
|
26
|
-
# Rely on OpenSSL for the initialization vector
|
27
|
-
iv = cipher.random_iv
|
28
|
-
|
29
|
-
# This must be set to properly use AES GCM for the OpenSSL module
|
30
|
-
cipher.auth_data = auth_data
|
31
|
-
|
32
|
-
cipher_text = cipher.update(data)
|
33
|
-
cipher_text << cipher.final
|
34
|
-
|
35
|
-
"#{base64_encode cipher_text}#{DELIMITER}#{base64_encode iv}#{DELIMITER}#{base64_encode cipher.auth_tag}"
|
36
|
-
end
|
37
|
-
|
38
|
-
def self.decrypt_message(data, secret)
|
39
|
-
return unless data
|
40
|
-
|
41
|
-
cipher = OpenSSL::Cipher.new(CIPHER)
|
42
|
-
cipher_text, iv, auth_tag = data.split(DELIMITER, 3).map! { |v| base64_decode(v) }
|
43
|
-
|
44
|
-
# This check is from ActiveSupport::MessageEncryptor
|
45
|
-
# see: https://github.com/ruby/openssl/issues/63
|
46
|
-
return if auth_tag.nil? || auth_tag.bytes.length != 16
|
47
|
-
|
48
|
-
cipher.decrypt
|
49
|
-
cipher.key = secret[0, cipher.key_len]
|
50
|
-
cipher.iv = iv
|
51
|
-
cipher.auth_tag = auth_tag
|
52
|
-
cipher.auth_data = ''
|
53
|
-
|
54
|
-
decrypted_data = cipher.update(cipher_text)
|
55
|
-
decrypted_data << cipher.final
|
56
|
-
decrypted_data
|
57
|
-
rescue OpenSSL::Cipher::CipherError, TypeError, ArgumentError
|
58
|
-
nil
|
59
|
-
end
|
60
|
-
end
|
61
|
-
end
|
62
|
-
end
|