secure_headers 3.0.3 → 3.2.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/.gitignore +0 -9
- data/.travis.yml +13 -5
- data/CHANGELOG.md +144 -0
- data/Gemfile +5 -2
- data/README.md +152 -61
- data/lib/secure_headers/configuration.rb +109 -54
- data/lib/secure_headers/hash_helper.rb +10 -0
- data/lib/secure_headers/headers/content_security_policy.rb +31 -309
- data/lib/secure_headers/headers/cookie.rb +126 -0
- data/lib/secure_headers/headers/policy_management.rb +320 -0
- data/lib/secure_headers/headers/x_content_type_options.rb +1 -1
- data/lib/secure_headers/middleware.rb +37 -0
- data/lib/secure_headers/railtie.rb +5 -1
- data/lib/secure_headers/utils/cookies_config.rb +94 -0
- data/lib/secure_headers/view_helper.rb +64 -0
- data/lib/secure_headers.rb +39 -63
- data/lib/tasks/tasks.rake +81 -0
- data/secure_headers.gemspec +1 -1
- data/spec/lib/secure_headers/configuration_spec.rb +18 -4
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +0 -175
- data/spec/lib/secure_headers/headers/cookie_spec.rb +164 -0
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +190 -0
- data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +1 -1
- data/spec/lib/secure_headers/middleware_spec.rb +59 -7
- data/spec/lib/secure_headers/view_helpers_spec.rb +125 -0
- data/spec/lib/secure_headers_spec.rb +119 -42
- data/spec/spec_helper.rb +16 -1
- data/upgrading-to-3-0.md +1 -0
- metadata +13 -4
- data/lib/secure_headers/padrino.rb +0 -13
- data/travis.sh +0 -10
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
module SecureHeaders
|
|
4
|
+
describe PolicyManagement do
|
|
5
|
+
let (:default_opts) do
|
|
6
|
+
{
|
|
7
|
+
default_src: %w(https:),
|
|
8
|
+
img_src: %w(https: data:),
|
|
9
|
+
script_src: %w('unsafe-inline' 'unsafe-eval' https: data:),
|
|
10
|
+
style_src: %w('unsafe-inline' https: about:),
|
|
11
|
+
report_uri: %w(/csp_report)
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe "#validate_config!" do
|
|
16
|
+
it "accepts all keys" do
|
|
17
|
+
# (pulled from README)
|
|
18
|
+
config = {
|
|
19
|
+
# "meta" values. these will shaped the header, but the values are not included in the header.
|
|
20
|
+
report_only: true, # default: false
|
|
21
|
+
preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content.
|
|
22
|
+
|
|
23
|
+
# directive values: these values will directly translate into source directives
|
|
24
|
+
default_src: %w(https: 'self'),
|
|
25
|
+
frame_src: %w('self' *.twimg.com itunes.apple.com),
|
|
26
|
+
connect_src: %w(wws:),
|
|
27
|
+
font_src: %w('self' data:),
|
|
28
|
+
img_src: %w(mycdn.com data:),
|
|
29
|
+
media_src: %w(utoob.com),
|
|
30
|
+
object_src: %w('self'),
|
|
31
|
+
script_src: %w('self'),
|
|
32
|
+
style_src: %w('unsafe-inline'),
|
|
33
|
+
base_uri: %w('self'),
|
|
34
|
+
child_src: %w('self'),
|
|
35
|
+
form_action: %w('self' github.com),
|
|
36
|
+
frame_ancestors: %w('none'),
|
|
37
|
+
plugin_types: %w(application/x-shockwave-flash),
|
|
38
|
+
block_all_mixed_content: true, # see [http://www.w3.org/TR/mixed-content/](http://www.w3.org/TR/mixed-content/)
|
|
39
|
+
upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/
|
|
40
|
+
report_uri: %w(https://example.com/uri-directive)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
CSP.validate_config!(config)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "requires a :default_src value" do
|
|
47
|
+
expect do
|
|
48
|
+
CSP.validate_config!(script_src: %('self'))
|
|
49
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "requires :report_only to be a truthy value" do
|
|
53
|
+
expect do
|
|
54
|
+
CSP.validate_config!(default_opts.merge(report_only: "steve"))
|
|
55
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "requires :preserve_schemes to be a truthy value" do
|
|
59
|
+
expect do
|
|
60
|
+
CSP.validate_config!(default_opts.merge(preserve_schemes: "steve"))
|
|
61
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "requires :block_all_mixed_content to be a boolean value" do
|
|
65
|
+
expect do
|
|
66
|
+
CSP.validate_config!(default_opts.merge(block_all_mixed_content: "steve"))
|
|
67
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "requires :upgrade_insecure_requests to be a boolean value" do
|
|
71
|
+
expect do
|
|
72
|
+
CSP.validate_config!(default_opts.merge(upgrade_insecure_requests: "steve"))
|
|
73
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "requires all source lists to be an array of strings" do
|
|
77
|
+
expect do
|
|
78
|
+
CSP.validate_config!(default_src: "steve")
|
|
79
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "allows nil values" do
|
|
83
|
+
expect do
|
|
84
|
+
CSP.validate_config!(default_src: %w('self'), script_src: ["https:", nil])
|
|
85
|
+
end.to_not raise_error
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "rejects unknown directives / config" do
|
|
89
|
+
expect do
|
|
90
|
+
CSP.validate_config!(default_src: %w('self'), default_src_totally_mispelled: "steve")
|
|
91
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# this is mostly to ensure people don't use the antiquated shorthands common in other configs
|
|
95
|
+
it "performs light validation on source lists" do
|
|
96
|
+
expect do
|
|
97
|
+
CSP.validate_config!(default_src: %w(self none inline eval))
|
|
98
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe "#combine_policies" do
|
|
103
|
+
it "combines the default-src value with the override if the directive was unconfigured" do
|
|
104
|
+
combined_config = CSP.combine_policies(Configuration.default.csp, script_src: %w(anothercdn.com))
|
|
105
|
+
csp = ContentSecurityPolicy.new(combined_config)
|
|
106
|
+
expect(csp.name).to eq(CSP::HEADER_NAME)
|
|
107
|
+
expect(csp.value).to eq("default-src https:; script-src https: anothercdn.com")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "combines directives where the original value is nil and the hash is frozen" do
|
|
111
|
+
Configuration.default do |config|
|
|
112
|
+
config.csp = {
|
|
113
|
+
default_src: %w('self'),
|
|
114
|
+
report_only: false
|
|
115
|
+
}.freeze
|
|
116
|
+
end
|
|
117
|
+
report_uri = "https://report-uri.io/asdf"
|
|
118
|
+
combined_config = CSP.combine_policies(Configuration.get.csp, report_uri: [report_uri])
|
|
119
|
+
csp = ContentSecurityPolicy.new(combined_config, USER_AGENTS[:firefox])
|
|
120
|
+
expect(csp.value).to include("report-uri #{report_uri}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "does not combine the default-src value for directives that don't fall back to default sources" do
|
|
124
|
+
Configuration.default do |config|
|
|
125
|
+
config.csp = {
|
|
126
|
+
default_src: %w('self'),
|
|
127
|
+
report_only: false
|
|
128
|
+
}.freeze
|
|
129
|
+
end
|
|
130
|
+
non_default_source_additions = CSP::NON_FETCH_SOURCES.each_with_object({}) do |directive, hash|
|
|
131
|
+
hash[directive] = %w("http://example.org)
|
|
132
|
+
end
|
|
133
|
+
combined_config = CSP.combine_policies(Configuration.get.csp, non_default_source_additions)
|
|
134
|
+
|
|
135
|
+
CSP::NON_FETCH_SOURCES.each do |directive|
|
|
136
|
+
expect(combined_config[directive]).to eq(%w("http://example.org))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
ContentSecurityPolicy.new(combined_config, USER_AGENTS[:firefox]).value
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "overrides the report_only flag" do
|
|
143
|
+
Configuration.default do |config|
|
|
144
|
+
config.csp = {
|
|
145
|
+
default_src: %w('self'),
|
|
146
|
+
report_only: false
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
combined_config = CSP.combine_policies(Configuration.get.csp, report_only: true)
|
|
150
|
+
csp = ContentSecurityPolicy.new(combined_config, USER_AGENTS[:firefox])
|
|
151
|
+
expect(csp.name).to eq(CSP::REPORT_ONLY)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "overrides the :block_all_mixed_content flag" do
|
|
155
|
+
Configuration.default do |config|
|
|
156
|
+
config.csp = {
|
|
157
|
+
default_src: %w(https:),
|
|
158
|
+
block_all_mixed_content: false
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
combined_config = CSP.combine_policies(Configuration.get.csp, block_all_mixed_content: true)
|
|
162
|
+
csp = ContentSecurityPolicy.new(combined_config)
|
|
163
|
+
expect(csp.value).to eq("default-src https:; block-all-mixed-content")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "raises an error if appending to a OPT_OUT policy" do
|
|
167
|
+
Configuration.default do |config|
|
|
168
|
+
config.csp = OPT_OUT
|
|
169
|
+
end
|
|
170
|
+
expect do
|
|
171
|
+
CSP.combine_policies(Configuration.get.csp, script_src: %w(anothercdn.com))
|
|
172
|
+
end.to raise_error(ContentSecurityPolicyConfigError)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe "#idempotent_additions?" do
|
|
177
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?(OPT_OUT, script_src: %w(b.com))).to be false }
|
|
178
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w(c.com))).to be false }
|
|
179
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, style_src: %w(b.com))).to be false }
|
|
180
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w(a.com b.com c.com))).to be false }
|
|
181
|
+
|
|
182
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w(b.com))).to be true }
|
|
183
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w(b.com a.com))).to be true }
|
|
184
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: %w())).to be true }
|
|
185
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, script_src: [nil])).to be true }
|
|
186
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, style_src: [nil])).to be true }
|
|
187
|
+
specify { expect(ContentSecurityPolicy.idempotent_additions?({script_src: %w(a.com b.com)}, style_src: nil)).to be true }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -4,7 +4,7 @@ module SecureHeaders
|
|
|
4
4
|
describe StrictTransportSecurity do
|
|
5
5
|
describe "#value" do
|
|
6
6
|
specify { expect(StrictTransportSecurity.make_header).to eq([StrictTransportSecurity::HEADER_NAME, StrictTransportSecurity::DEFAULT_VALUE]) }
|
|
7
|
-
specify { expect(StrictTransportSecurity.make_header("max-age=1234")).to eq([StrictTransportSecurity::HEADER_NAME, "max-age=1234"]) }
|
|
7
|
+
specify { expect(StrictTransportSecurity.make_header("max-age=1234; includeSubdomains; preload")).to eq([StrictTransportSecurity::HEADER_NAME, "max-age=1234; includeSubdomains; preload"]) }
|
|
8
8
|
|
|
9
9
|
context "with an invalid configuration" do
|
|
10
10
|
context "with a string argument" do
|
|
@@ -2,17 +2,15 @@ require "spec_helper"
|
|
|
2
2
|
|
|
3
3
|
module SecureHeaders
|
|
4
4
|
describe Middleware do
|
|
5
|
-
let(:app) {
|
|
5
|
+
let(:app) { lambda { |env| [200, env, "app"] } }
|
|
6
|
+
let(:cookie_app) { lambda { |env| [200, env.merge("Set-Cookie" => "foo=bar"), "app"] } }
|
|
6
7
|
|
|
7
|
-
let
|
|
8
|
-
|
|
9
|
-
end
|
|
8
|
+
let(:middleware) { Middleware.new(app) }
|
|
9
|
+
let(:cookie_middleware) { Middleware.new(cookie_app) }
|
|
10
10
|
|
|
11
11
|
before(:each) do
|
|
12
12
|
reset_config
|
|
13
|
-
Configuration.default
|
|
14
|
-
# use all default provided by the library
|
|
15
|
-
end
|
|
13
|
+
Configuration.default
|
|
16
14
|
end
|
|
17
15
|
|
|
18
16
|
it "sets the headers" do
|
|
@@ -33,8 +31,62 @@ module SecureHeaders
|
|
|
33
31
|
end
|
|
34
32
|
request = Rack::Request.new({})
|
|
35
33
|
SecureHeaders.use_secure_headers_override(request, "my_custom_config")
|
|
34
|
+
expect(request.env[SECURE_HEADERS_CONFIG]).to be(Configuration.get("my_custom_config"))
|
|
36
35
|
_, env = middleware.call request.env
|
|
37
36
|
expect(env[CSP::HEADER_NAME]).to match("example.org")
|
|
38
37
|
end
|
|
38
|
+
|
|
39
|
+
context "secure_cookies" do
|
|
40
|
+
context "cookies should be flagged" do
|
|
41
|
+
it "flags cookies as secure" do
|
|
42
|
+
capture_warning do
|
|
43
|
+
Configuration.default { |config| config.secure_cookies = true }
|
|
44
|
+
end
|
|
45
|
+
request = Rack::Request.new("HTTPS" => "on")
|
|
46
|
+
_, env = cookie_middleware.call request.env
|
|
47
|
+
expect(env['Set-Cookie']).to eq("foo=bar; secure")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
context "cookies should not be flagged" do
|
|
52
|
+
it "does not flags cookies as secure" do
|
|
53
|
+
capture_warning do
|
|
54
|
+
Configuration.default { |config| config.secure_cookies = false }
|
|
55
|
+
end
|
|
56
|
+
request = Rack::Request.new("HTTPS" => "on")
|
|
57
|
+
_, env = cookie_middleware.call request.env
|
|
58
|
+
expect(env['Set-Cookie']).to eq("foo=bar")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
context "cookies" do
|
|
64
|
+
it "flags cookies from configuration" do
|
|
65
|
+
Configuration.default { |config| config.cookies = { secure: true, httponly: true } }
|
|
66
|
+
request = Rack::Request.new("HTTPS" => "on")
|
|
67
|
+
_, env = cookie_middleware.call request.env
|
|
68
|
+
|
|
69
|
+
expect(env['Set-Cookie']).to eq("foo=bar; secure; HttpOnly")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "flags cookies with a combination of SameSite configurations" do
|
|
73
|
+
cookie_middleware = Middleware.new(lambda { |env| [200, env.merge("Set-Cookie" => ["_session=foobar", "_guest=true"]), "app"] })
|
|
74
|
+
|
|
75
|
+
Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } } } }
|
|
76
|
+
request = Rack::Request.new("HTTPS" => "on")
|
|
77
|
+
_, env = cookie_middleware.call request.env
|
|
78
|
+
|
|
79
|
+
expect(env['Set-Cookie']).to match("_session=foobar; SameSite=Strict")
|
|
80
|
+
expect(env['Set-Cookie']).to match("_guest=true; SameSite=Lax")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "disables secure cookies for non-https requests" do
|
|
84
|
+
Configuration.default { |config| config.cookies = { secure: true } }
|
|
85
|
+
|
|
86
|
+
request = Rack::Request.new("HTTPS" => "off")
|
|
87
|
+
_, env = cookie_middleware.call request.env
|
|
88
|
+
expect(env['Set-Cookie']).to eq("foo=bar")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
39
91
|
end
|
|
40
92
|
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "erb"
|
|
3
|
+
|
|
4
|
+
class Message < ERB
|
|
5
|
+
include SecureHeaders::ViewHelpers
|
|
6
|
+
|
|
7
|
+
def self.template
|
|
8
|
+
<<-TEMPLATE
|
|
9
|
+
<% hashed_javascript_tag(raise_error_on_unrecognized_hash = true) do %>
|
|
10
|
+
console.log(1)
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<% hashed_style_tag do %>
|
|
14
|
+
body {
|
|
15
|
+
background-color: black;
|
|
16
|
+
}
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<% nonced_javascript_tag do %>
|
|
20
|
+
body {
|
|
21
|
+
console.log(1)
|
|
22
|
+
}
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<% nonced_style_tag do %>
|
|
26
|
+
body {
|
|
27
|
+
background-color: black;
|
|
28
|
+
}
|
|
29
|
+
<% end %>
|
|
30
|
+
<%= @name %>
|
|
31
|
+
|
|
32
|
+
TEMPLATE
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(request, options = {})
|
|
36
|
+
@virtual_path = "/asdfs/index"
|
|
37
|
+
@_request = request
|
|
38
|
+
@template = self.class.template
|
|
39
|
+
super(@template)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def capture(*args)
|
|
43
|
+
yield(*args)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def content_tag(type, content = nil, options = nil, &block)
|
|
47
|
+
content = if block_given?
|
|
48
|
+
capture(block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if options.is_a?(Hash)
|
|
52
|
+
options = options.map {|k,v| " #{k}=#{v}"}
|
|
53
|
+
end
|
|
54
|
+
"<#{type}#{options}>#{content}</#{type}>"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def result
|
|
58
|
+
super(binding)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def request
|
|
62
|
+
@_request
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module SecureHeaders
|
|
67
|
+
describe ViewHelpers do
|
|
68
|
+
let(:app) { lambda { |env| [200, env, "app"] } }
|
|
69
|
+
let(:middleware) { Middleware.new(app) }
|
|
70
|
+
let(:request) { Rack::Request.new("HTTP_USER_AGENT" => USER_AGENTS[:chrome]) }
|
|
71
|
+
let(:filename) { "app/views/asdfs/index.html.erb" }
|
|
72
|
+
|
|
73
|
+
before(:all) do
|
|
74
|
+
Configuration.default do |config|
|
|
75
|
+
config.csp[:script_src] = %w('self')
|
|
76
|
+
config.csp[:style_src] = %w('self')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
after(:each) do
|
|
81
|
+
Configuration.instance_variable_set(:@script_hashes, nil)
|
|
82
|
+
Configuration.instance_variable_set(:@style_hashes, nil)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "raises an error when using hashed content without precomputed hashes" do
|
|
86
|
+
expect {
|
|
87
|
+
Message.new(request).result
|
|
88
|
+
}.to raise_error(ViewHelpers::UnexpectedHashedScriptException)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "raises an error when using hashed content with precomputed hashes, but none for the given file" do
|
|
92
|
+
Configuration.instance_variable_set(:@script_hashes, filename.reverse => ["'sha256-123'"])
|
|
93
|
+
expect {
|
|
94
|
+
Message.new(request).result
|
|
95
|
+
}.to raise_error(ViewHelpers::UnexpectedHashedScriptException)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "raises an error when using previously unknown hashed content with precomputed hashes for a given file" do
|
|
99
|
+
Configuration.instance_variable_set(:@script_hashes, filename => ["'sha256-123'"])
|
|
100
|
+
expect {
|
|
101
|
+
Message.new(request).result
|
|
102
|
+
}.to raise_error(ViewHelpers::UnexpectedHashedScriptException)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "adds known hash values to the corresponding headers when the helper is used" do
|
|
106
|
+
begin
|
|
107
|
+
allow(SecureRandom).to receive(:base64).and_return("abc123")
|
|
108
|
+
|
|
109
|
+
expected_hash = "sha256-3/URElR9+3lvLIouavYD/vhoICSNKilh15CzI/nKqg8="
|
|
110
|
+
Configuration.instance_variable_set(:@script_hashes, filename => ["'#{expected_hash}'"])
|
|
111
|
+
expected_style_hash = "sha256-7oYK96jHg36D6BM042er4OfBnyUDTG3pH1L8Zso3aGc="
|
|
112
|
+
Configuration.instance_variable_set(:@style_hashes, filename => ["'#{expected_style_hash}'"])
|
|
113
|
+
|
|
114
|
+
# render erb that calls out to helpers.
|
|
115
|
+
Message.new(request).result
|
|
116
|
+
_, env = middleware.call request.env
|
|
117
|
+
|
|
118
|
+
expect(env[CSP::HEADER_NAME]).to match(/script-src[^;]*'#{Regexp.escape(expected_hash)}'/)
|
|
119
|
+
expect(env[CSP::HEADER_NAME]).to match(/script-src[^;]*'nonce-abc123'/)
|
|
120
|
+
expect(env[CSP::HEADER_NAME]).to match(/style-src[^;]*'nonce-abc123'/)
|
|
121
|
+
expect(env[CSP::HEADER_NAME]).to match(/style-src[^;]*'#{Regexp.escape(expected_style_hash)}'/)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -2,57 +2,66 @@ require 'spec_helper'
|
|
|
2
2
|
|
|
3
3
|
module SecureHeaders
|
|
4
4
|
describe SecureHeaders do
|
|
5
|
-
example_hpkp_config = {
|
|
6
|
-
max_age: 1_000_000,
|
|
7
|
-
include_subdomains: true,
|
|
8
|
-
report_uri: '//example.com/uri-directive',
|
|
9
|
-
pins: [
|
|
10
|
-
{ sha256: 'abc' },
|
|
11
|
-
{ sha256: '123' }
|
|
12
|
-
]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
example_hpkp_config_value = %(max-age=1000000; pin-sha256="abc"; pin-sha256="123"; report-uri="//example.com/uri-directive"; includeSubDomains)
|
|
16
|
-
|
|
17
5
|
before(:each) do
|
|
18
6
|
reset_config
|
|
19
|
-
@request = Rack::Request.new("HTTP_X_FORWARDED_SSL" => "on")
|
|
20
7
|
end
|
|
21
8
|
|
|
9
|
+
let(:request) { Rack::Request.new("HTTP_X_FORWARDED_SSL" => "on") }
|
|
10
|
+
|
|
22
11
|
it "raises a NotYetConfiguredError if default has not been set" do
|
|
23
12
|
expect do
|
|
24
|
-
SecureHeaders.header_hash_for(
|
|
13
|
+
SecureHeaders.header_hash_for(request)
|
|
25
14
|
end.to raise_error(Configuration::NotYetConfiguredError)
|
|
26
15
|
end
|
|
27
16
|
|
|
28
17
|
it "raises a NotYetConfiguredError if trying to opt-out of unconfigured headers" do
|
|
29
18
|
expect do
|
|
30
|
-
SecureHeaders.opt_out_of_header(
|
|
19
|
+
SecureHeaders.opt_out_of_header(request, CSP::CONFIG_KEY)
|
|
31
20
|
end.to raise_error(Configuration::NotYetConfiguredError)
|
|
32
21
|
end
|
|
33
22
|
|
|
34
23
|
describe "#header_hash_for" do
|
|
35
|
-
it "allows you to opt out of individual headers" do
|
|
24
|
+
it "allows you to opt out of individual headers via API" do
|
|
36
25
|
Configuration.default
|
|
37
|
-
SecureHeaders.opt_out_of_header(
|
|
38
|
-
|
|
26
|
+
SecureHeaders.opt_out_of_header(request, CSP::CONFIG_KEY)
|
|
27
|
+
SecureHeaders.opt_out_of_header(request, XContentTypeOptions::CONFIG_KEY)
|
|
28
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
39
29
|
expect(hash['Content-Security-Policy-Report-Only']).to be_nil
|
|
40
30
|
expect(hash['Content-Security-Policy']).to be_nil
|
|
31
|
+
expect(hash['X-Content-Type-Options']).to be_nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "Carries options over when using overrides" do
|
|
35
|
+
Configuration.default do |config|
|
|
36
|
+
config.x_download_options = OPT_OUT
|
|
37
|
+
config.x_permitted_cross_domain_policies = OPT_OUT
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Configuration.override(:api) do |config|
|
|
41
|
+
config.x_frame_options = OPT_OUT
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
SecureHeaders.use_secure_headers_override(request, :api)
|
|
45
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
46
|
+
expect(hash['X-Download-Options']).to be_nil
|
|
47
|
+
expect(hash['X-Permitted-Cross-Domain-Policies']).to be_nil
|
|
48
|
+
expect(hash['X-Frame-Options']).to be_nil
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
it "allows you to opt out entirely" do
|
|
44
52
|
Configuration.default
|
|
45
|
-
SecureHeaders.opt_out_of_all_protection(
|
|
46
|
-
hash = SecureHeaders.header_hash_for(
|
|
53
|
+
SecureHeaders.opt_out_of_all_protection(request)
|
|
54
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
47
55
|
ALL_HEADER_CLASSES.each do |klass|
|
|
48
56
|
expect(hash[klass::CONFIG_KEY]).to be_nil
|
|
49
57
|
end
|
|
58
|
+
expect(hash.count).to eq(0)
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
it "allows you to override X-Frame-Options settings" do
|
|
53
62
|
Configuration.default
|
|
54
|
-
SecureHeaders.override_x_frame_options(
|
|
55
|
-
hash = SecureHeaders.header_hash_for(
|
|
63
|
+
SecureHeaders.override_x_frame_options(request, XFrameOptions::DENY)
|
|
64
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
56
65
|
expect(hash[XFrameOptions::HEADER_NAME]).to eq(XFrameOptions::DENY)
|
|
57
66
|
end
|
|
58
67
|
|
|
@@ -62,17 +71,36 @@ module SecureHeaders
|
|
|
62
71
|
config.csp = OPT_OUT
|
|
63
72
|
end
|
|
64
73
|
|
|
65
|
-
SecureHeaders.override_x_frame_options(
|
|
66
|
-
SecureHeaders.override_content_security_policy_directives(
|
|
74
|
+
SecureHeaders.override_x_frame_options(request, XFrameOptions::SAMEORIGIN)
|
|
75
|
+
SecureHeaders.override_content_security_policy_directives(request, default_src: %w(https:), script_src: %w('self'))
|
|
67
76
|
|
|
68
|
-
hash = SecureHeaders.header_hash_for(
|
|
77
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
69
78
|
expect(hash[CSP::HEADER_NAME]).to eq("default-src https:; script-src 'self'")
|
|
70
79
|
expect(hash[XFrameOptions::HEADER_NAME]).to eq(XFrameOptions::SAMEORIGIN)
|
|
71
80
|
end
|
|
72
81
|
|
|
82
|
+
it "produces a UA-specific CSP when overriding (and busting the cache)" do
|
|
83
|
+
config = Configuration.default do |config|
|
|
84
|
+
config.csp = {
|
|
85
|
+
default_src: %w('self'),
|
|
86
|
+
child_src: %w('self'), #unsupported by firefox
|
|
87
|
+
frame_src: %w('self')
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
firefox_request = Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:firefox]))
|
|
91
|
+
|
|
92
|
+
# append an unsupported directive
|
|
93
|
+
SecureHeaders.override_content_security_policy_directives(firefox_request, plugin_types: %w(flash))
|
|
94
|
+
# append a supported directive
|
|
95
|
+
SecureHeaders.override_content_security_policy_directives(firefox_request, script_src: %w('self'))
|
|
96
|
+
|
|
97
|
+
hash = SecureHeaders.header_hash_for(firefox_request)
|
|
98
|
+
expect(hash[CSP::HEADER_NAME]).to eq("default-src 'self'; frame-src 'self'; script-src 'self'")
|
|
99
|
+
end
|
|
100
|
+
|
|
73
101
|
it "produces a hash of headers with default config" do
|
|
74
102
|
Configuration.default
|
|
75
|
-
hash = SecureHeaders.header_hash_for(
|
|
103
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
76
104
|
expect_default_values(hash)
|
|
77
105
|
end
|
|
78
106
|
|
|
@@ -87,7 +115,15 @@ module SecureHeaders
|
|
|
87
115
|
it "does not set the HPKP header if request is over HTTP" do
|
|
88
116
|
plaintext_request = Rack::Request.new({})
|
|
89
117
|
Configuration.default do |config|
|
|
90
|
-
config.hpkp =
|
|
118
|
+
config.hpkp = {
|
|
119
|
+
max_age: 1_000_000,
|
|
120
|
+
include_subdomains: true,
|
|
121
|
+
report_uri: '//example.com/uri-directive',
|
|
122
|
+
pins: [
|
|
123
|
+
{ sha256: 'abc' },
|
|
124
|
+
{ sha256: '123' }
|
|
125
|
+
]
|
|
126
|
+
}
|
|
91
127
|
end
|
|
92
128
|
|
|
93
129
|
expect(SecureHeaders.header_hash_for(plaintext_request)[PublicKeyPins::HEADER_NAME]).to be_nil
|
|
@@ -102,26 +138,67 @@ module SecureHeaders
|
|
|
102
138
|
}
|
|
103
139
|
end
|
|
104
140
|
|
|
105
|
-
SecureHeaders.append_content_security_policy_directives(
|
|
106
|
-
hash = SecureHeaders.header_hash_for(
|
|
141
|
+
SecureHeaders.append_content_security_policy_directives(request, script_src: %w(anothercdn.com))
|
|
142
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
107
143
|
expect(hash[CSP::HEADER_NAME]).to eq("default-src 'self'; script-src mycdn.com 'unsafe-inline' anothercdn.com")
|
|
108
144
|
end
|
|
109
145
|
|
|
146
|
+
it "dups global configuration just once when overriding n times and only calls idempotent_additions? once" do
|
|
147
|
+
Configuration.default do |config|
|
|
148
|
+
config.csp = {
|
|
149
|
+
default_src: %w('self')
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
expect(CSP).to receive(:idempotent_additions?).once
|
|
154
|
+
|
|
155
|
+
# before an override occurs, the env is empty
|
|
156
|
+
expect(request.env[SECURE_HEADERS_CONFIG]).to be_nil
|
|
157
|
+
|
|
158
|
+
SecureHeaders.append_content_security_policy_directives(request, script_src: %w(anothercdn.com))
|
|
159
|
+
new_config = SecureHeaders.config_for(request)
|
|
160
|
+
expect(new_config).to_not be(Configuration.get)
|
|
161
|
+
|
|
162
|
+
SecureHeaders.override_content_security_policy_directives(request, script_src: %w(yet.anothercdn.com))
|
|
163
|
+
current_config = SecureHeaders.config_for(request)
|
|
164
|
+
expect(current_config).to be(new_config)
|
|
165
|
+
|
|
166
|
+
SecureHeaders.header_hash_for(request)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "doesn't allow you to muck with csp configs when a dynamic policy is in use" do
|
|
170
|
+
default_config = Configuration.default
|
|
171
|
+
expect { default_config.csp = {} }.to raise_error(NoMethodError)
|
|
172
|
+
|
|
173
|
+
# config is frozen
|
|
174
|
+
expect { default_config.send(:csp=, {}) }.to raise_error(RuntimeError)
|
|
175
|
+
|
|
176
|
+
SecureHeaders.append_content_security_policy_directives(request, script_src: %w(anothercdn.com))
|
|
177
|
+
new_config = SecureHeaders.config_for(request)
|
|
178
|
+
expect { new_config.send(:csp=, {}) }.to raise_error(Configuration::IllegalPolicyModificationError)
|
|
179
|
+
|
|
180
|
+
expect do
|
|
181
|
+
new_config.instance_eval do
|
|
182
|
+
new_config.csp = {}
|
|
183
|
+
end
|
|
184
|
+
end.to raise_error(Configuration::IllegalPolicyModificationError)
|
|
185
|
+
end
|
|
186
|
+
|
|
110
187
|
it "overrides individual directives" do
|
|
111
188
|
Configuration.default do |config|
|
|
112
189
|
config.csp = {
|
|
113
190
|
default_src: %w('self')
|
|
114
191
|
}
|
|
115
192
|
end
|
|
116
|
-
SecureHeaders.override_content_security_policy_directives(
|
|
117
|
-
hash = SecureHeaders.header_hash_for(
|
|
193
|
+
SecureHeaders.override_content_security_policy_directives(request, default_src: %w('none'))
|
|
194
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
118
195
|
expect(hash[CSP::HEADER_NAME]).to eq("default-src 'none'")
|
|
119
196
|
end
|
|
120
197
|
|
|
121
198
|
it "overrides non-existant directives" do
|
|
122
199
|
Configuration.default
|
|
123
|
-
SecureHeaders.override_content_security_policy_directives(
|
|
124
|
-
hash = SecureHeaders.header_hash_for(
|
|
200
|
+
SecureHeaders.override_content_security_policy_directives(request, img_src: [ContentSecurityPolicy::DATA_PROTOCOL])
|
|
201
|
+
hash = SecureHeaders.header_hash_for(request)
|
|
125
202
|
expect(hash[CSP::HEADER_NAME]).to eq("default-src https:; img-src data:")
|
|
126
203
|
end
|
|
127
204
|
|
|
@@ -134,9 +211,9 @@ module SecureHeaders
|
|
|
134
211
|
}
|
|
135
212
|
end
|
|
136
213
|
|
|
137
|
-
|
|
138
|
-
nonce = SecureHeaders.content_security_policy_script_nonce(
|
|
139
|
-
hash = SecureHeaders.header_hash_for(
|
|
214
|
+
safari_request = Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:safari5]))
|
|
215
|
+
nonce = SecureHeaders.content_security_policy_script_nonce(safari_request)
|
|
216
|
+
hash = SecureHeaders.header_hash_for(safari_request)
|
|
140
217
|
expect(hash[CSP::HEADER_NAME]).to eq("default-src 'self'; script-src mycdn.com 'unsafe-inline'; style-src 'self'")
|
|
141
218
|
end
|
|
142
219
|
|
|
@@ -149,15 +226,15 @@ module SecureHeaders
|
|
|
149
226
|
}
|
|
150
227
|
end
|
|
151
228
|
|
|
152
|
-
|
|
153
|
-
nonce = SecureHeaders.content_security_policy_script_nonce(
|
|
229
|
+
chrome_request = Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:chrome]))
|
|
230
|
+
nonce = SecureHeaders.content_security_policy_script_nonce(chrome_request)
|
|
154
231
|
|
|
155
232
|
# simulate the nonce being used multiple times in a request:
|
|
156
|
-
SecureHeaders.content_security_policy_script_nonce(
|
|
157
|
-
SecureHeaders.content_security_policy_script_nonce(
|
|
158
|
-
SecureHeaders.content_security_policy_script_nonce(
|
|
233
|
+
SecureHeaders.content_security_policy_script_nonce(chrome_request)
|
|
234
|
+
SecureHeaders.content_security_policy_script_nonce(chrome_request)
|
|
235
|
+
SecureHeaders.content_security_policy_script_nonce(chrome_request)
|
|
159
236
|
|
|
160
|
-
hash = SecureHeaders.header_hash_for(
|
|
237
|
+
hash = SecureHeaders.header_hash_for(chrome_request)
|
|
161
238
|
expect(hash['Content-Security-Policy']).to eq("default-src 'self'; script-src mycdn.com 'nonce-#{nonce}'; style-src 'self'")
|
|
162
239
|
end
|
|
163
240
|
end
|