secure_headers 3.0.3 → 4.0.0
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 +4 -4
- data/.github/ISSUE_TEMPLATE.md +41 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +20 -0
- data/.gitignore +0 -9
- data/.rspec +2 -0
- data/.rubocop.yml +3 -0
- data/.ruby-version +1 -1
- data/.travis.yml +17 -5
- data/CHANGELOG.md +287 -0
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +41 -0
- data/Gemfile +13 -5
- data/Guardfile +1 -0
- data/LICENSE +4 -199
- data/README.md +85 -265
- data/Rakefile +22 -18
- data/docs/HPKP.md +17 -0
- data/docs/cookies.md +64 -0
- data/docs/hashes.md +64 -0
- data/docs/named_overrides_and_appends.md +107 -0
- data/docs/per_action_configuration.md +133 -0
- data/docs/sinatra.md +25 -0
- data/lib/secure_headers/configuration.rb +188 -59
- data/lib/secure_headers/hash_helper.rb +11 -0
- data/lib/secure_headers/headers/clear_site_data.rb +55 -0
- data/lib/secure_headers/headers/content_security_policy.rb +136 -325
- data/lib/secure_headers/headers/content_security_policy_config.rb +162 -0
- data/lib/secure_headers/headers/cookie.rb +144 -0
- data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
- data/lib/secure_headers/headers/policy_management.rb +410 -0
- data/lib/secure_headers/headers/public_key_pins.rb +5 -4
- data/lib/secure_headers/headers/referrer_policy.rb +37 -0
- data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
- data/lib/secure_headers/headers/x_content_type_options.rb +3 -2
- data/lib/secure_headers/headers/x_download_options.rb +3 -2
- data/lib/secure_headers/headers/x_frame_options.rb +2 -1
- data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +3 -2
- data/lib/secure_headers/headers/x_xss_protection.rb +2 -1
- data/lib/secure_headers/middleware.rb +44 -0
- data/lib/secure_headers/railtie.rb +11 -6
- data/lib/secure_headers/utils/cookies_config.rb +95 -0
- data/lib/secure_headers/view_helper.rb +76 -5
- data/lib/secure_headers.rb +159 -99
- data/lib/tasks/tasks.rake +83 -0
- data/secure_headers.gemspec +13 -3
- data/spec/lib/secure_headers/configuration_spec.rb +24 -9
- data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +87 -0
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +88 -189
- data/spec/lib/secure_headers/headers/cookie_spec.rb +182 -0
- data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +228 -0
- data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
- data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +73 -0
- data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +6 -5
- data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +2 -1
- data/spec/lib/secure_headers/headers/x_download_options_spec.rb +3 -2
- data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +2 -1
- data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +4 -3
- data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +4 -3
- data/spec/lib/secure_headers/middleware_spec.rb +96 -6
- data/spec/lib/secure_headers/view_helpers_spec.rb +138 -0
- data/spec/lib/secure_headers_spec.rb +407 -58
- data/spec/spec_helper.rb +19 -12
- data/upgrading-to-3-0.md +13 -9
- data/upgrading-to-4-0.md +55 -0
- metadata +45 -9
- data/lib/secure_headers/padrino.rb +0 -13
- data/travis.sh +0 -10
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
module DynamicConfig
|
|
4
|
+
def self.included(base)
|
|
5
|
+
base.send(:attr_writer, :modified)
|
|
6
|
+
base.send(:attr_reader, *base.attrs)
|
|
7
|
+
base.attrs.each do |attr|
|
|
8
|
+
base.send(:define_method, "#{attr}=") do |value|
|
|
9
|
+
if self.class.attrs.include?(attr)
|
|
10
|
+
write_attribute(attr, value)
|
|
11
|
+
else
|
|
12
|
+
raise ContentSecurityPolicyConfigError, "Unknown config directive: #{attr}=#{value}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(hash)
|
|
19
|
+
@base_uri = nil
|
|
20
|
+
@block_all_mixed_content = nil
|
|
21
|
+
@child_src = nil
|
|
22
|
+
@connect_src = nil
|
|
23
|
+
@default_src = nil
|
|
24
|
+
@font_src = nil
|
|
25
|
+
@form_action = nil
|
|
26
|
+
@frame_ancestors = nil
|
|
27
|
+
@frame_src = nil
|
|
28
|
+
@img_src = nil
|
|
29
|
+
@manifest_src = nil
|
|
30
|
+
@media_src = nil
|
|
31
|
+
@object_src = nil
|
|
32
|
+
@plugin_types = nil
|
|
33
|
+
@preserve_schemes = nil
|
|
34
|
+
@report_only = nil
|
|
35
|
+
@report_uri = nil
|
|
36
|
+
@sandbox = nil
|
|
37
|
+
@script_nonce = nil
|
|
38
|
+
@script_src = nil
|
|
39
|
+
@style_nonce = nil
|
|
40
|
+
@style_src = nil
|
|
41
|
+
@upgrade_insecure_requests = nil
|
|
42
|
+
|
|
43
|
+
from_hash(hash)
|
|
44
|
+
@modified = false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def update_directive(directive, value)
|
|
48
|
+
self.send("#{directive}=", value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def directive_value(directive)
|
|
52
|
+
if self.class.attrs.include?(directive)
|
|
53
|
+
self.send(directive)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def modified?
|
|
58
|
+
@modified
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def merge(new_hash)
|
|
62
|
+
ContentSecurityPolicy.combine_policies(self.to_h, new_hash)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def merge!(new_hash)
|
|
66
|
+
from_hash(new_hash)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def append(new_hash)
|
|
70
|
+
from_hash(ContentSecurityPolicy.combine_policies(self.to_h, new_hash))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_h
|
|
74
|
+
self.class.attrs.each_with_object({}) do |key, hash|
|
|
75
|
+
value = self.send(key)
|
|
76
|
+
hash[key] = value unless value.nil?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def dup
|
|
81
|
+
self.class.new(self.to_h)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def opt_out?
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ==(o)
|
|
89
|
+
self.class == o.class && self.to_h == o.to_h
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
alias_method :[], :directive_value
|
|
93
|
+
alias_method :[]=, :update_directive
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
def from_hash(hash)
|
|
97
|
+
hash.each_pair do |k, v|
|
|
98
|
+
next if v.nil?
|
|
99
|
+
|
|
100
|
+
if self.class.attrs.include?(k)
|
|
101
|
+
write_attribute(k, v)
|
|
102
|
+
else
|
|
103
|
+
raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{v}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def write_attribute(attr, value)
|
|
109
|
+
value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list
|
|
110
|
+
attr_variable = "@#{attr}"
|
|
111
|
+
prev_value = self.instance_variable_get(attr_variable)
|
|
112
|
+
self.instance_variable_set(attr_variable, value)
|
|
113
|
+
if prev_value != value
|
|
114
|
+
@modified = true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class ContentSecurityPolicyConfigError < StandardError; end
|
|
120
|
+
class ContentSecurityPolicyConfig
|
|
121
|
+
CONFIG_KEY = :csp
|
|
122
|
+
HEADER_NAME = "Content-Security-Policy".freeze
|
|
123
|
+
|
|
124
|
+
ATTRS = PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES
|
|
125
|
+
def self.attrs
|
|
126
|
+
ATTRS
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
include DynamicConfig
|
|
130
|
+
|
|
131
|
+
# based on what was suggested in https://github.com/rails/rails/pull/24961/files
|
|
132
|
+
DEFAULT = {
|
|
133
|
+
default_src: %w('self' https:),
|
|
134
|
+
font_src: %w('self' https: data:),
|
|
135
|
+
img_src: %w('self' https: data:),
|
|
136
|
+
object_src: %w('none'),
|
|
137
|
+
script_src: %w(https:),
|
|
138
|
+
style_src: %w('self' https: 'unsafe-inline')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def report_only?
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def make_report_only
|
|
146
|
+
ContentSecurityPolicyReportOnlyConfig.new(self.to_h)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class ContentSecurityPolicyReportOnlyConfig < ContentSecurityPolicyConfig
|
|
151
|
+
CONFIG_KEY = :csp_report_only
|
|
152
|
+
HEADER_NAME = "Content-Security-Policy-Report-Only".freeze
|
|
153
|
+
|
|
154
|
+
def report_only?
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def make_report_only
|
|
159
|
+
self
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "cgi"
|
|
3
|
+
require "secure_headers/utils/cookies_config"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
module SecureHeaders
|
|
7
|
+
class CookiesConfigError < StandardError; end
|
|
8
|
+
class Cookie
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def validate_config!(config)
|
|
12
|
+
CookiesConfig.new(config).validate!
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :raw_cookie, :config
|
|
17
|
+
|
|
18
|
+
COOKIE_DEFAULTS = {
|
|
19
|
+
httponly: true,
|
|
20
|
+
secure: true,
|
|
21
|
+
samesite: { lax: true },
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(cookie, config)
|
|
25
|
+
@raw_cookie = cookie
|
|
26
|
+
unless config == OPT_OUT
|
|
27
|
+
config ||= {}
|
|
28
|
+
config = COOKIE_DEFAULTS.merge(config)
|
|
29
|
+
end
|
|
30
|
+
@config = config
|
|
31
|
+
@attributes = {
|
|
32
|
+
httponly: nil,
|
|
33
|
+
samesite: nil,
|
|
34
|
+
secure: nil,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
parse(cookie)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_s
|
|
41
|
+
@raw_cookie.dup.tap do |c|
|
|
42
|
+
c << "; secure" if secure?
|
|
43
|
+
c << "; HttpOnly" if httponly?
|
|
44
|
+
c << "; #{samesite_cookie}" if samesite?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def secure?
|
|
49
|
+
flag_cookie?(:secure) && !already_flagged?(:secure)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def httponly?
|
|
53
|
+
flag_cookie?(:httponly) && !already_flagged?(:httponly)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def samesite?
|
|
57
|
+
flag_samesite? && !already_flagged?(:samesite)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def parsed_cookie
|
|
63
|
+
@parsed_cookie ||= CGI::Cookie.parse(raw_cookie)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def already_flagged?(attribute)
|
|
67
|
+
@attributes[attribute]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def flag_cookie?(attribute)
|
|
71
|
+
return false if config == OPT_OUT
|
|
72
|
+
case config[attribute]
|
|
73
|
+
when TrueClass
|
|
74
|
+
true
|
|
75
|
+
when Hash
|
|
76
|
+
conditionally_flag?(config[attribute])
|
|
77
|
+
else
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def conditionally_flag?(configuration)
|
|
83
|
+
if(Array(configuration[:only]).any? && (Array(configuration[:only]) & parsed_cookie.keys).any?)
|
|
84
|
+
true
|
|
85
|
+
elsif(Array(configuration[:except]).any? && (Array(configuration[:except]) & parsed_cookie.keys).none?)
|
|
86
|
+
true
|
|
87
|
+
else
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def samesite_cookie
|
|
93
|
+
if flag_samesite_lax?
|
|
94
|
+
"SameSite=Lax"
|
|
95
|
+
elsif flag_samesite_strict?
|
|
96
|
+
"SameSite=Strict"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def flag_samesite?
|
|
101
|
+
return false if config == OPT_OUT || config[:samesite] == OPT_OUT
|
|
102
|
+
flag_samesite_lax? || flag_samesite_strict?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def flag_samesite_lax?
|
|
106
|
+
flag_samesite_enforcement?(:lax)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def flag_samesite_strict?
|
|
110
|
+
flag_samesite_enforcement?(:strict)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def flag_samesite_enforcement?(mode)
|
|
114
|
+
return unless config[:samesite]
|
|
115
|
+
|
|
116
|
+
if config[:samesite].is_a?(TrueClass) && mode == :lax
|
|
117
|
+
return true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
case config[:samesite][mode]
|
|
121
|
+
when Hash
|
|
122
|
+
conditionally_flag?(config[:samesite][mode])
|
|
123
|
+
when TrueClass
|
|
124
|
+
true
|
|
125
|
+
else
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def parse(cookie)
|
|
131
|
+
return unless cookie
|
|
132
|
+
|
|
133
|
+
cookie.split(/[;,]\s?/).each do |pairs|
|
|
134
|
+
name, values = pairs.split("=", 2)
|
|
135
|
+
name = CGI.unescape(name)
|
|
136
|
+
|
|
137
|
+
attribute = name.downcase.to_sym
|
|
138
|
+
if @attributes.has_key?(attribute)
|
|
139
|
+
@attributes[attribute] = values || true
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
class ExpectCertificateTransparencyConfigError < StandardError; end
|
|
4
|
+
|
|
5
|
+
class ExpectCertificateTransparency
|
|
6
|
+
HEADER_NAME = "Expect-CT".freeze
|
|
7
|
+
CONFIG_KEY = :expect_certificate_transparency
|
|
8
|
+
INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze
|
|
9
|
+
INVALID_ENFORCE_VALUE_ERROR = "enforce must be a boolean".freeze
|
|
10
|
+
REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze
|
|
11
|
+
INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Public: Generate a Expect-CT header.
|
|
15
|
+
#
|
|
16
|
+
# Returns nil if not configured, returns header name and value if
|
|
17
|
+
# configured.
|
|
18
|
+
def make_header(config)
|
|
19
|
+
return if config.nil?
|
|
20
|
+
|
|
21
|
+
header = new(config)
|
|
22
|
+
[HEADER_NAME, header.value]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_config!(config)
|
|
26
|
+
return if config.nil? || config == OPT_OUT
|
|
27
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash
|
|
28
|
+
|
|
29
|
+
unless [true, false, nil].include?(config[:enforce])
|
|
30
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if !config[:max_age]
|
|
34
|
+
raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR)
|
|
35
|
+
elsif config[:max_age].to_s !~ /\A\d+\z/
|
|
36
|
+
raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(config)
|
|
42
|
+
@enforced = config.fetch(:enforce, nil)
|
|
43
|
+
@max_age = config.fetch(:max_age, nil)
|
|
44
|
+
@report_uri = config.fetch(:report_uri, nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def value
|
|
48
|
+
[
|
|
49
|
+
enforced_directive,
|
|
50
|
+
max_age_directive,
|
|
51
|
+
report_uri_directive
|
|
52
|
+
].compact.join("; ").strip
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enforced_directive
|
|
56
|
+
# Unfortunately `if @enforced` isn't enough here in case someone
|
|
57
|
+
# passes in a random string so let's be specific with it to prevent
|
|
58
|
+
# accidental enforcement.
|
|
59
|
+
"enforce" if @enforced == true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def max_age_directive
|
|
63
|
+
"max-age=#{@max_age}" if @max_age
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def report_uri_directive
|
|
67
|
+
"report-uri=\"#{@report_uri}\"" if @report_uri
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|