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,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
class CookiesConfig
|
|
4
|
+
|
|
5
|
+
attr_reader :config
|
|
6
|
+
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@config = config
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def validate!
|
|
12
|
+
return if config.nil? || config == SecureHeaders::OPT_OUT
|
|
13
|
+
|
|
14
|
+
validate_config!
|
|
15
|
+
validate_secure_config! unless config[:secure].nil?
|
|
16
|
+
validate_httponly_config! unless config[:httponly].nil?
|
|
17
|
+
validate_samesite_config! unless config[:samesite].nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def validate_config!
|
|
23
|
+
raise CookiesConfigError.new("config must be a hash.") unless is_hash?(config)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_secure_config!
|
|
27
|
+
validate_hash_or_true_or_opt_out!(:secure)
|
|
28
|
+
validate_exclusive_use_of_hash_constraints!(config[:secure], :secure)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_httponly_config!
|
|
32
|
+
validate_hash_or_true_or_opt_out!(:httponly)
|
|
33
|
+
validate_exclusive_use_of_hash_constraints!(config[:httponly], :httponly)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_samesite_config!
|
|
37
|
+
return if config[:samesite] == OPT_OUT
|
|
38
|
+
raise CookiesConfigError.new("samesite cookie config must be a hash") unless is_hash?(config[:samesite])
|
|
39
|
+
|
|
40
|
+
validate_samesite_boolean_config!
|
|
41
|
+
validate_samesite_hash_config!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# when configuring with booleans, only one enforcement is permitted
|
|
45
|
+
def validate_samesite_boolean_config!
|
|
46
|
+
if config[:samesite].key?(:lax) && config[:samesite][:lax].is_a?(TrueClass) && config[:samesite].key?(:strict)
|
|
47
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax and strict enforcement is not permitted.")
|
|
48
|
+
elsif config[:samesite].key?(:strict) && config[:samesite][:strict].is_a?(TrueClass) && config[:samesite].key?(:lax)
|
|
49
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax and strict enforcement is not permitted.")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_samesite_hash_config!
|
|
54
|
+
# validate Hash-based samesite configuration
|
|
55
|
+
if is_hash?(config[:samesite][:lax])
|
|
56
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax], "samesite lax")
|
|
57
|
+
|
|
58
|
+
if is_hash?(config[:samesite][:strict])
|
|
59
|
+
validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict], "samesite strict")
|
|
60
|
+
validate_exclusive_use_of_samesite_enforcement!(:only)
|
|
61
|
+
validate_exclusive_use_of_samesite_enforcement!(:except)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_hash_or_true_or_opt_out!(attribute)
|
|
67
|
+
if !(is_hash?(config[attribute]) || is_true_or_opt_out?(config[attribute]))
|
|
68
|
+
raise CookiesConfigError.new("#{attribute} cookie config must be a hash or boolean")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# validate exclusive use of only or except but not both at the same time
|
|
73
|
+
def validate_exclusive_use_of_hash_constraints!(conf, attribute)
|
|
74
|
+
return unless is_hash?(conf)
|
|
75
|
+
if conf.key?(:only) && conf.key?(:except)
|
|
76
|
+
raise CookiesConfigError.new("#{attribute} cookie config is invalid, simultaneous use of conditional arguments `only` and `except` is not permitted.")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# validate exclusivity of only and except members within strict and lax
|
|
81
|
+
def validate_exclusive_use_of_samesite_enforcement!(attribute)
|
|
82
|
+
if (intersection = (config[:samesite][:lax].fetch(attribute, []) & config[:samesite][:strict].fetch(attribute, []))).any?
|
|
83
|
+
raise CookiesConfigError.new("samesite cookie config is invalid, cookie(s) #{intersection.join(', ')} cannot be enforced as lax and strict")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def is_hash?(obj)
|
|
88
|
+
obj && obj.is_a?(Hash)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def is_true_or_opt_out?(obj)
|
|
92
|
+
obj && (obj.is_a?(TrueClass) || obj == OPT_OUT)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SecureHeaders
|
|
2
3
|
module ViewHelpers
|
|
4
|
+
include SecureHeaders::HashHelper
|
|
5
|
+
SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes"
|
|
6
|
+
|
|
7
|
+
class UnexpectedHashedScriptException < StandardError; end
|
|
8
|
+
|
|
3
9
|
# Public: create a style tag using the content security policy nonce.
|
|
4
10
|
# Instructs secure_headers to append a nonce to style/script-src directives.
|
|
5
11
|
#
|
|
@@ -29,8 +35,75 @@ module SecureHeaders
|
|
|
29
35
|
end
|
|
30
36
|
end
|
|
31
37
|
|
|
38
|
+
def content_security_policy_script_nonce
|
|
39
|
+
content_security_policy_nonce(:script)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def content_security_policy_style_nonce
|
|
43
|
+
content_security_policy_nonce(:style)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# Checks to see if the hashed code is expected and adds the hash source
|
|
48
|
+
# value to the current CSP.
|
|
49
|
+
#
|
|
50
|
+
# By default, in development/test/etc. an exception will be raised.
|
|
51
|
+
def hashed_javascript_tag(raise_error_on_unrecognized_hash = nil, &block)
|
|
52
|
+
hashed_tag(
|
|
53
|
+
:script,
|
|
54
|
+
:script_src,
|
|
55
|
+
Configuration.instance_variable_get(:@script_hashes),
|
|
56
|
+
raise_error_on_unrecognized_hash,
|
|
57
|
+
block
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def hashed_style_tag(raise_error_on_unrecognized_hash = nil, &block)
|
|
62
|
+
hashed_tag(
|
|
63
|
+
:style,
|
|
64
|
+
:style_src,
|
|
65
|
+
Configuration.instance_variable_get(:@style_hashes),
|
|
66
|
+
raise_error_on_unrecognized_hash,
|
|
67
|
+
block
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
32
71
|
private
|
|
33
72
|
|
|
73
|
+
def hashed_tag(type, directive, hashes, raise_error_on_unrecognized_hash, block)
|
|
74
|
+
if raise_error_on_unrecognized_hash.nil?
|
|
75
|
+
raise_error_on_unrecognized_hash = ENV["RAILS_ENV"] != "production"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
content = capture(&block)
|
|
79
|
+
file_path = File.join("app", "views", self.instance_variable_get(:@virtual_path) + ".html.erb")
|
|
80
|
+
|
|
81
|
+
if raise_error_on_unrecognized_hash
|
|
82
|
+
hash_value = hash_source(content)
|
|
83
|
+
message = unexpected_hash_error_message(file_path, content, hash_value)
|
|
84
|
+
|
|
85
|
+
if hashes.nil? || hashes[file_path].nil? || !hashes[file_path].include?(hash_value)
|
|
86
|
+
raise UnexpectedHashedScriptException.new(message)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
SecureHeaders.append_content_security_policy_directives(request, directive => hashes[file_path])
|
|
91
|
+
|
|
92
|
+
content_tag type, content
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def unexpected_hash_error_message(file_path, content, hash_value)
|
|
96
|
+
<<-EOF
|
|
97
|
+
\n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
|
|
98
|
+
#{content}
|
|
99
|
+
*** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/secure_headers_generated_hashes.yml:***
|
|
100
|
+
#{file_path}:
|
|
101
|
+
- \"#{hash_value}\"\n\n
|
|
102
|
+
NOTE: dynamic javascript is not supported using script hash integration
|
|
103
|
+
on purpose. It defeats the point of using it in the first place.
|
|
104
|
+
EOF
|
|
105
|
+
end
|
|
106
|
+
|
|
34
107
|
def nonced_tag(type, content_or_options, block)
|
|
35
108
|
options = {}
|
|
36
109
|
content = if block
|
|
@@ -44,8 +117,6 @@ module SecureHeaders
|
|
|
44
117
|
end
|
|
45
118
|
end
|
|
46
119
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
end
|
|
51
|
-
end
|
|
120
|
+
ActiveSupport.on_load :action_view do
|
|
121
|
+
include SecureHeaders::ViewHelpers
|
|
122
|
+
end if defined?(ActiveSupport)
|
data/lib/secure_headers.rb
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
require "secure_headers/configuration"
|
|
3
|
+
require "secure_headers/hash_helper"
|
|
4
|
+
require "secure_headers/headers/cookie"
|
|
2
5
|
require "secure_headers/headers/public_key_pins"
|
|
3
6
|
require "secure_headers/headers/content_security_policy"
|
|
4
7
|
require "secure_headers/headers/x_frame_options"
|
|
@@ -7,24 +10,56 @@ require "secure_headers/headers/x_xss_protection"
|
|
|
7
10
|
require "secure_headers/headers/x_content_type_options"
|
|
8
11
|
require "secure_headers/headers/x_download_options"
|
|
9
12
|
require "secure_headers/headers/x_permitted_cross_domain_policies"
|
|
13
|
+
require "secure_headers/headers/referrer_policy"
|
|
14
|
+
require "secure_headers/headers/clear_site_data"
|
|
15
|
+
require "secure_headers/headers/expect_certificate_transparency"
|
|
10
16
|
require "secure_headers/middleware"
|
|
11
17
|
require "secure_headers/railtie"
|
|
12
18
|
require "secure_headers/view_helper"
|
|
13
19
|
require "useragent"
|
|
20
|
+
require "singleton"
|
|
14
21
|
|
|
15
22
|
# All headers (except for hpkp) have a default value. Provide SecureHeaders::OPT_OUT
|
|
16
23
|
# or ":optout_of_protection" as a config value to disable a given header
|
|
17
24
|
module SecureHeaders
|
|
18
|
-
|
|
25
|
+
class NoOpHeaderConfig
|
|
26
|
+
include Singleton
|
|
27
|
+
|
|
28
|
+
def boom(*args)
|
|
29
|
+
raise "Illegal State: attempted to modify NoOpHeaderConfig. Create a new config instead."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
{}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dup
|
|
37
|
+
self.class.instance
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def opt_out?
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
alias_method :[], :boom
|
|
45
|
+
alias_method :[]=, :boom
|
|
46
|
+
alias_method :keys, :boom
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
OPT_OUT = NoOpHeaderConfig.instance
|
|
19
50
|
SECURE_HEADERS_CONFIG = "secure_headers_request_config".freeze
|
|
20
51
|
NONCE_KEY = "secure_headers_content_security_policy_nonce".freeze
|
|
21
52
|
HTTPS = "https".freeze
|
|
22
53
|
CSP = ContentSecurityPolicy
|
|
23
54
|
|
|
24
55
|
ALL_HEADER_CLASSES = [
|
|
25
|
-
|
|
56
|
+
ExpectCertificateTransparency,
|
|
57
|
+
ClearSiteData,
|
|
58
|
+
ContentSecurityPolicyConfig,
|
|
59
|
+
ContentSecurityPolicyReportOnlyConfig,
|
|
26
60
|
StrictTransportSecurity,
|
|
27
61
|
PublicKeyPins,
|
|
62
|
+
ReferrerPolicy,
|
|
28
63
|
XContentTypeOptions,
|
|
29
64
|
XDownloadOptions,
|
|
30
65
|
XFrameOptions,
|
|
@@ -32,7 +67,10 @@ module SecureHeaders
|
|
|
32
67
|
XXssProtection
|
|
33
68
|
].freeze
|
|
34
69
|
|
|
35
|
-
ALL_HEADERS_BESIDES_CSP = (
|
|
70
|
+
ALL_HEADERS_BESIDES_CSP = (
|
|
71
|
+
ALL_HEADER_CLASSES -
|
|
72
|
+
[ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig]
|
|
73
|
+
).freeze
|
|
36
74
|
|
|
37
75
|
# Headers set on http requests (excludes STS and HPKP)
|
|
38
76
|
HTTP_HEADER_CLASSES =
|
|
@@ -46,16 +84,26 @@ module SecureHeaders
|
|
|
46
84
|
#
|
|
47
85
|
# additions - a hash containing directives. e.g.
|
|
48
86
|
# script_src: %w(another-host.com)
|
|
49
|
-
def override_content_security_policy_directives(request, additions)
|
|
50
|
-
config =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if config.csp
|
|
54
|
-
config.csp = {}
|
|
87
|
+
def override_content_security_policy_directives(request, additions, target = nil)
|
|
88
|
+
config, target = config_and_target(request, target)
|
|
89
|
+
|
|
90
|
+
if [:both, :enforced].include?(target)
|
|
91
|
+
if config.csp.opt_out?
|
|
92
|
+
config.csp = ContentSecurityPolicyConfig.new({})
|
|
55
93
|
end
|
|
94
|
+
|
|
56
95
|
config.csp.merge!(additions)
|
|
57
|
-
override_secure_headers_request_config(request, config)
|
|
58
96
|
end
|
|
97
|
+
|
|
98
|
+
if [:both, :report_only].include?(target)
|
|
99
|
+
if config.csp_report_only.opt_out?
|
|
100
|
+
config.csp_report_only = ContentSecurityPolicyReportOnlyConfig.new({})
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
config.csp_report_only.merge!(additions)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
override_secure_headers_request_config(request, config)
|
|
59
107
|
end
|
|
60
108
|
|
|
61
109
|
# Public: appends source values to the current configuration. If no value
|
|
@@ -64,13 +112,23 @@ module SecureHeaders
|
|
|
64
112
|
#
|
|
65
113
|
# additions - a hash containing directives. e.g.
|
|
66
114
|
# script_src: %w(another-host.com)
|
|
67
|
-
def append_content_security_policy_directives(request, additions)
|
|
68
|
-
config =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
config.csp
|
|
72
|
-
override_secure_headers_request_config(request, config)
|
|
115
|
+
def append_content_security_policy_directives(request, additions, target = nil)
|
|
116
|
+
config, target = config_and_target(request, target)
|
|
117
|
+
|
|
118
|
+
if [:both, :enforced].include?(target) && !config.csp.opt_out?
|
|
119
|
+
config.csp.append(additions)
|
|
73
120
|
end
|
|
121
|
+
|
|
122
|
+
if [:both, :report_only].include?(target) && !config.csp_report_only.opt_out?
|
|
123
|
+
config.csp_report_only.append(additions)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
override_secure_headers_request_config(request, config)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def use_content_security_policy_named_append(request, name)
|
|
130
|
+
additions = SecureHeaders::Configuration.named_appends(name).call(request)
|
|
131
|
+
append_content_security_policy_directives(request, additions)
|
|
74
132
|
end
|
|
75
133
|
|
|
76
134
|
# Public: override X-Frame-Options settings for this request.
|
|
@@ -79,16 +137,16 @@ module SecureHeaders
|
|
|
79
137
|
#
|
|
80
138
|
# Returns the current config
|
|
81
139
|
def override_x_frame_options(request, value)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
override_secure_headers_request_config(request,
|
|
140
|
+
config = config_for(request)
|
|
141
|
+
config.update_x_frame_options(value)
|
|
142
|
+
override_secure_headers_request_config(request, config)
|
|
85
143
|
end
|
|
86
144
|
|
|
87
145
|
# Public: opts out of setting a given header by creating a temporary config
|
|
88
146
|
# and setting the given headers config to OPT_OUT.
|
|
89
147
|
def opt_out_of_header(request, header_key)
|
|
90
|
-
config = config_for(request)
|
|
91
|
-
config.
|
|
148
|
+
config = config_for(request)
|
|
149
|
+
config.opt_out(header_key)
|
|
92
150
|
override_secure_headers_request_config(request, config)
|
|
93
151
|
end
|
|
94
152
|
|
|
@@ -108,15 +166,29 @@ module SecureHeaders
|
|
|
108
166
|
# returned is meant to be merged into the header value from `@app.call(env)`
|
|
109
167
|
# in Rack middleware.
|
|
110
168
|
def header_hash_for(request)
|
|
111
|
-
|
|
169
|
+
prevent_dup = true
|
|
170
|
+
config = config_for(request, prevent_dup)
|
|
171
|
+
headers = config.cached_headers
|
|
172
|
+
user_agent = UserAgent.parse(request.user_agent)
|
|
112
173
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
else
|
|
116
|
-
build_headers(config, request)
|
|
174
|
+
if !config.csp.opt_out? && config.csp.modified?
|
|
175
|
+
headers = update_cached_csp(config.csp, headers, user_agent)
|
|
117
176
|
end
|
|
118
177
|
|
|
119
|
-
|
|
178
|
+
if !config.csp_report_only.opt_out? && config.csp_report_only.modified?
|
|
179
|
+
headers = update_cached_csp(config.csp_report_only, headers, user_agent)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
header_classes_for(request).each_with_object({}) do |klass, hash|
|
|
183
|
+
if header = headers[klass::CONFIG_KEY]
|
|
184
|
+
header_name, value = if klass == ContentSecurityPolicyConfig || klass == ContentSecurityPolicyReportOnlyConfig
|
|
185
|
+
csp_header_for_ua(header, user_agent)
|
|
186
|
+
else
|
|
187
|
+
header
|
|
188
|
+
end
|
|
189
|
+
hash[header_name] = value
|
|
190
|
+
end
|
|
191
|
+
end
|
|
120
192
|
end
|
|
121
193
|
|
|
122
194
|
# Public: specify which named override will be used for this request.
|
|
@@ -137,7 +209,7 @@ module SecureHeaders
|
|
|
137
209
|
#
|
|
138
210
|
# Returns the nonce
|
|
139
211
|
def content_security_policy_script_nonce(request)
|
|
140
|
-
content_security_policy_nonce(request,
|
|
212
|
+
content_security_policy_nonce(request, ContentSecurityPolicy::SCRIPT_SRC)
|
|
141
213
|
end
|
|
142
214
|
|
|
143
215
|
# Public: gets or creates a nonce for CSP.
|
|
@@ -146,17 +218,62 @@ module SecureHeaders
|
|
|
146
218
|
#
|
|
147
219
|
# Returns the nonce
|
|
148
220
|
def content_security_policy_style_nonce(request)
|
|
149
|
-
content_security_policy_nonce(request,
|
|
221
|
+
content_security_policy_nonce(request, ContentSecurityPolicy::STYLE_SRC)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Public: Retreives the config for a given header type:
|
|
225
|
+
#
|
|
226
|
+
# Checks to see if there is an override for this request, then
|
|
227
|
+
# Checks to see if a named override is used for this request, then
|
|
228
|
+
# Falls back to the global config
|
|
229
|
+
def config_for(request, prevent_dup = false)
|
|
230
|
+
config = request.env[SECURE_HEADERS_CONFIG] ||
|
|
231
|
+
Configuration.get(Configuration::DEFAULT_CONFIG)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# Global configs are frozen, per-request configs are not. When we're not
|
|
235
|
+
# making modifications to the config, prevent_dup ensures we don't dup
|
|
236
|
+
# the object unnecessarily. It's not necessarily frozen to begin with.
|
|
237
|
+
if config.frozen? && !prevent_dup
|
|
238
|
+
config.dup
|
|
239
|
+
else
|
|
240
|
+
config
|
|
241
|
+
end
|
|
150
242
|
end
|
|
151
243
|
|
|
152
244
|
private
|
|
245
|
+
TARGETS = [:both, :enforced, :report_only]
|
|
246
|
+
def raise_on_unknown_target(target)
|
|
247
|
+
unless TARGETS.include?(target)
|
|
248
|
+
raise "Unrecognized target: #{target}. Must be [:both, :enforced, :report_only]"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def config_and_target(request, target)
|
|
253
|
+
config = config_for(request)
|
|
254
|
+
target = guess_target(config) unless target
|
|
255
|
+
raise_on_unknown_target(target)
|
|
256
|
+
[config, target]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def guess_target(config)
|
|
260
|
+
if !config.csp.opt_out? && !config.csp_report_only.opt_out?
|
|
261
|
+
:both
|
|
262
|
+
elsif !config.csp.opt_out?
|
|
263
|
+
:enforced
|
|
264
|
+
elsif !config.csp_report_only.opt_out?
|
|
265
|
+
:report_only
|
|
266
|
+
else
|
|
267
|
+
:both
|
|
268
|
+
end
|
|
269
|
+
end
|
|
153
270
|
|
|
154
271
|
# Private: gets or creates a nonce for CSP.
|
|
155
272
|
#
|
|
156
273
|
# Returns the nonce
|
|
157
274
|
def content_security_policy_nonce(request, script_or_style)
|
|
158
275
|
request.env[NONCE_KEY] ||= SecureRandom.base64(32).chomp
|
|
159
|
-
nonce_key = script_or_style ==
|
|
276
|
+
nonce_key = script_or_style == ContentSecurityPolicy::SCRIPT_SRC ? :script_nonce : :style_nonce
|
|
160
277
|
append_content_security_policy_directives(request, nonce_key => request.env[NONCE_KEY])
|
|
161
278
|
request.env[NONCE_KEY]
|
|
162
279
|
end
|
|
@@ -181,50 +298,12 @@ module SecureHeaders
|
|
|
181
298
|
end
|
|
182
299
|
end
|
|
183
300
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
header_config = if config
|
|
191
|
-
config.fetch(klass::CONFIG_KEY)
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
header_name, value = if klass == CSP
|
|
195
|
-
make_header(klass, header_config, request.user_agent)
|
|
196
|
-
else
|
|
197
|
-
make_header(klass, header_config)
|
|
198
|
-
end
|
|
199
|
-
hash[header_name] = value if value
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
# Private: takes a precomputed hash of headers and returns the Headers
|
|
204
|
-
# customized for the request.
|
|
205
|
-
#
|
|
206
|
-
# Returns a hash of header names / values valid for a given request.
|
|
207
|
-
def use_cached_headers(default_headers, request)
|
|
208
|
-
header_classes_for(request).each_with_object({}) do |klass, hash|
|
|
209
|
-
if default_header = default_headers[klass::CONFIG_KEY]
|
|
210
|
-
header_name, value = if klass == CSP
|
|
211
|
-
default_csp_header_for_ua(default_header, request)
|
|
212
|
-
else
|
|
213
|
-
default_header
|
|
214
|
-
end
|
|
215
|
-
hash[header_name] = value
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
# Private: Retreives the config for a given header type:
|
|
221
|
-
#
|
|
222
|
-
# Checks to see if there is an override for this request, then
|
|
223
|
-
# Checks to see if a named override is used for this request, then
|
|
224
|
-
# Falls back to the global config
|
|
225
|
-
def config_for(request)
|
|
226
|
-
request.env[SECURE_HEADERS_CONFIG] ||
|
|
227
|
-
Configuration.get(Configuration::DEFAULT_CONFIG)
|
|
301
|
+
def update_cached_csp(config, headers, user_agent)
|
|
302
|
+
headers = Configuration.send(:deep_copy, headers)
|
|
303
|
+
headers[config.class::CONFIG_KEY] = {}
|
|
304
|
+
variation = ContentSecurityPolicy.ua_to_variation(user_agent)
|
|
305
|
+
headers[config.class::CONFIG_KEY][variation] = ContentSecurityPolicy.make_header(config, user_agent)
|
|
306
|
+
headers
|
|
228
307
|
end
|
|
229
308
|
|
|
230
309
|
# Private: chooses the applicable CSP header for the provided user agent.
|
|
@@ -232,31 +311,8 @@ module SecureHeaders
|
|
|
232
311
|
# headers - a hash of header_config_key => [header_name, header_value]
|
|
233
312
|
#
|
|
234
313
|
# Returns a CSP [header, value] array
|
|
235
|
-
def
|
|
236
|
-
|
|
237
|
-
if CSP::VARIATIONS.key?(family)
|
|
238
|
-
headers[family]
|
|
239
|
-
else
|
|
240
|
-
headers[CSP::OTHER]
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# Private: optionally build a header with a given configure
|
|
245
|
-
#
|
|
246
|
-
# klass - corresponding Class for a given header
|
|
247
|
-
# config - A string, symbol, or hash config for the header
|
|
248
|
-
# user_agent - A string representing the UA (only used for CSP feature sniffing)
|
|
249
|
-
#
|
|
250
|
-
# Returns a 2 element array [header_name, header_value] or nil if config
|
|
251
|
-
# is OPT_OUT
|
|
252
|
-
def make_header(klass, header_config, user_agent = nil)
|
|
253
|
-
unless header_config == OPT_OUT
|
|
254
|
-
if klass == CSP
|
|
255
|
-
klass.make_header(header_config, user_agent)
|
|
256
|
-
else
|
|
257
|
-
klass.make_header(header_config)
|
|
258
|
-
end
|
|
259
|
-
end
|
|
314
|
+
def csp_header_for_ua(headers, user_agent)
|
|
315
|
+
headers[ContentSecurityPolicy.ua_to_variation(user_agent)]
|
|
260
316
|
end
|
|
261
317
|
end
|
|
262
318
|
|
|
@@ -289,4 +345,8 @@ module SecureHeaders
|
|
|
289
345
|
def override_x_frame_options(value)
|
|
290
346
|
SecureHeaders.override_x_frame_options(request, value)
|
|
291
347
|
end
|
|
348
|
+
|
|
349
|
+
def use_content_security_policy_named_append(name)
|
|
350
|
+
SecureHeaders.use_content_security_policy_named_append(request, name)
|
|
351
|
+
end
|
|
292
352
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx unless defined? INLINE_SCRIPT_REGEX
|
|
3
|
+
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx unless defined? INLINE_STYLE_REGEX
|
|
4
|
+
INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_SCRIPT_HELPER_REGEX
|
|
5
|
+
INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_STYLE_HELPER_REGEX
|
|
6
|
+
|
|
7
|
+
namespace :secure_headers do
|
|
8
|
+
include SecureHeaders::HashHelper
|
|
9
|
+
|
|
10
|
+
def is_erb?(filename)
|
|
11
|
+
filename =~ /\.erb\Z/
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def is_mustache?(filename)
|
|
15
|
+
filename =~ /\.mustache\Z/
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dynamic_content?(filename, inline_script)
|
|
19
|
+
(is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
|
|
20
|
+
(is_erb?(filename) && inline_script =~ /<%.*%>/)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find_inline_content(filename, regex, hashes)
|
|
24
|
+
file = File.read(filename)
|
|
25
|
+
file.scan(regex) do # TODO don't use gsub
|
|
26
|
+
inline_script = Regexp.last_match.captures.last
|
|
27
|
+
if dynamic_content?(filename, inline_script)
|
|
28
|
+
puts "Looks like there's some dynamic content inside of a tag :-/"
|
|
29
|
+
puts "That pretty much means the hash value will never match."
|
|
30
|
+
puts "Code: " + inline_script
|
|
31
|
+
puts "=" * 20
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
hashes << hash_source(inline_script)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate_inline_script_hashes(filename)
|
|
39
|
+
hashes = []
|
|
40
|
+
|
|
41
|
+
[INLINE_SCRIPT_REGEX, INLINE_HASH_SCRIPT_HELPER_REGEX].each do |regex|
|
|
42
|
+
find_inline_content(filename, regex, hashes)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
hashes
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def generate_inline_style_hashes(filename)
|
|
49
|
+
hashes = []
|
|
50
|
+
|
|
51
|
+
[INLINE_STYLE_REGEX, INLINE_HASH_STYLE_HELPER_REGEX].each do |regex|
|
|
52
|
+
find_inline_content(filename, regex, hashes)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
hashes
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "Generate #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
|
|
59
|
+
task :generate_hashes do |t, args|
|
|
60
|
+
script_hashes = {
|
|
61
|
+
"scripts" => {},
|
|
62
|
+
"styles" => {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Dir.glob("app/{views,templates}/**/*.{erb,mustache}") do |filename|
|
|
66
|
+
hashes = generate_inline_script_hashes(filename)
|
|
67
|
+
if hashes.any?
|
|
68
|
+
script_hashes["scripts"][filename] = hashes
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
hashes = generate_inline_style_hashes(filename)
|
|
72
|
+
if hashes.any?
|
|
73
|
+
script_hashes["styles"][filename] = hashes
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
File.open(SecureHeaders::Configuration::HASH_CONFIG_FILE, "w") do |file|
|
|
78
|
+
file.write(script_hashes.to_yaml)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
|
|
82
|
+
end
|
|
83
|
+
end
|
data/secure_headers.gemspec
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
Gem::Specification.new do |gem|
|
|
3
4
|
gem.name = "secure_headers"
|
|
4
|
-
gem.version = "
|
|
5
|
+
gem.version = "4.0.0"
|
|
5
6
|
gem.authors = ["Neil Matatall"]
|
|
6
7
|
gem.email = ["neil.matatall@gmail.com"]
|
|
7
|
-
gem.description =
|
|
8
|
+
gem.description = "Manages application of security headers with many safe defaults."
|
|
8
9
|
gem.summary = 'Add easily configured security headers to responses
|
|
9
10
|
including content-security-policy, x-frame-options,
|
|
10
11
|
strict-transport-security, etc.'
|
|
@@ -15,5 +16,14 @@ Gem::Specification.new do |gem|
|
|
|
15
16
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
16
17
|
gem.require_paths = ["lib"]
|
|
17
18
|
gem.add_development_dependency "rake"
|
|
18
|
-
gem.add_dependency "useragent"
|
|
19
|
+
gem.add_dependency "useragent", ">= 0.15.0"
|
|
20
|
+
|
|
21
|
+
# TODO: delete this after 4.1 is cut or a number of 4.0.x releases have occurred
|
|
22
|
+
gem.post_install_message = <<-POST_INSTALL
|
|
23
|
+
|
|
24
|
+
**********
|
|
25
|
+
:wave: secure_headers 4.0 introduces a lot of breaking changes (in the name of security!). It's highly likely you will need to update your secure_headers cookie configuration to avoid breaking things. See the upgrade guide for details: https://github.com/twitter/secureheaders/blob/master/upgrading-to-4-0.md
|
|
26
|
+
**********
|
|
27
|
+
|
|
28
|
+
POST_INSTALL
|
|
19
29
|
end
|