Skip to content

Commit 4e9da9d

Browse files
authored
Merge pull request #270 from twitter/ff-child-frame-src
Handle child/frame-src gracefully across different browsers and versions
2 parents 51aa52e + 951beee commit 4e9da9d

File tree

9 files changed

+100
-28
lines changed

9 files changed

+100
-28
lines changed

Gemfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ gemspec
55
group :test do
66
gem "tins", "~> 1.6.0" # 1.7 requires ruby 2.0
77
gem "pry-nav"
8-
gem "rack"
8+
gem "json", "~> 1"
9+
gem "rack", "~> 1"
910
gem "rspec"
1011
gem "coveralls"
1112
end

README.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,20 +53,19 @@ SecureHeaders::Configuration.default do |config|
5353

5454
# directive values: these values will directly translate into source directives
5555
default_src: %w(https: 'self'),
56-
frame_src: %w('self' *.twimg.com itunes.apple.com),
56+
base_uri: %w('self'),
57+
block_all_mixed_content: true, # see http://www.w3.org/TR/mixed-content/
58+
child_src: %w('self'),
5759
connect_src: %w(wss:),
5860
font_src: %w('self' data:),
61+
form_action: %w('self' github.com),
62+
frame_ancestors: %w('none'),
5963
img_src: %w(mycdn.com data:),
6064
media_src: %w(utoob.com),
6165
object_src: %w('self'),
66+
plugin_types: %w(application/x-shockwave-flash),
6267
script_src: %w('self'),
6368
style_src: %w('unsafe-inline'),
64-
base_uri: %w('self'),
65-
child_src: %w('self'),
66-
form_action: %w('self' github.com),
67-
frame_ancestors: %w('none'),
68-
plugin_types: %w(application/x-shockwave-flash),
69-
block_all_mixed_content: true, # see http://www.w3.org/TR/mixed-content/
7069
upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/
7170
report_uri: %w(https://report-uri.io/example-csp)
7271
}

lib/secure_headers/configuration.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def csp=(new_csp)
203203
raise IllegalPolicyModificationError, "You are attempting to modify CSP settings directly. Use dynamic_csp= instead."
204204
end
205205

206-
@csp = new_csp
206+
@csp = self.class.send(:deep_copy_if_hash, new_csp)
207207
end
208208

209209
def cookies=(cookies)
@@ -215,7 +215,7 @@ def cached_headers=(headers)
215215
end
216216

217217
def hpkp=(hpkp)
218-
@hpkp = hpkp
218+
@hpkp = self.class.send(:deep_copy_if_hash, hpkp)
219219
end
220220

221221
def hpkp_report_host=(hpkp_report_host)

lib/secure_headers/headers/content_security_policy.rb

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
require_relative 'policy_management'
2+
require 'useragent'
23

34
module SecureHeaders
45
class ContentSecurityPolicyConfigError < StandardError; end
56
class ContentSecurityPolicy
67
include PolicyManagement
78

9+
# constants to be used for version-specific UA sniffing
10+
VERSION_46 = ::UserAgent::Version.new("46")
11+
812
def initialize(config = nil, user_agent = OTHER)
9-
config = Configuration.deep_copy(DEFAULT_CONFIG) unless config
10-
@config = config
13+
@config = Configuration.send(:deep_copy, config || DEFAULT_CONFIG)
1114
@parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
1215
user_agent
1316
else
1417
UserAgent.parse(user_agent)
1518
end
19+
normalize_child_frame_src
1620
@report_only = @config[:report_only]
1721
@preserve_schemes = @config[:preserve_schemes]
1822
@script_nonce = @config[:script_nonce]
@@ -42,6 +46,22 @@ def value
4246

4347
private
4448

49+
# frame-src is deprecated, child-src is being implemented. They are
50+
# very similar and in most cases, the same value can be used for both.
51+
def normalize_child_frame_src
52+
if @config[:frame_src] && @config[:child_src] && @config[:frame_src] != @config[:child_src]
53+
Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] both :child_src and :frame_src supplied and do not match. This can lead to inconsistent behavior across browsers.")
54+
elsif @config[:frame_src]
55+
Kernel.warn("#{Kernel.caller.first}: [DEPRECATION] :frame_src is deprecated, use :child_src instead. Provided: #{@config[:frame_src]}.")
56+
end
57+
58+
if supported_directives.include?(:child_src)
59+
@config[:child_src] = @config[:child_src] || @config[:frame_src]
60+
else
61+
@config[:frame_src] = @config[:frame_src] || @config[:child_src]
62+
end
63+
end
64+
4565
# Private: converts the config object into a string representing a policy.
4666
# Places default-src at the first directive and report-uri as the last. All
4767
# others are presented in alphabetical order.
@@ -162,9 +182,19 @@ def strip_source_schemes!(source_list)
162182

163183
# Private: determine which directives are supported for the given user agent.
164184
#
185+
# Add UA-sniffing special casing here.
186+
#
165187
# Returns an array of symbols representing the directives.
166188
def supported_directives
167-
@supported_directives ||= VARIATIONS[@parsed_ua.browser] || VARIATIONS[OTHER]
189+
@supported_directives ||= if VARIATIONS[@parsed_ua.browser]
190+
if @parsed_ua.browser == "Firefox" && @parsed_ua.version >= VERSION_46
191+
VARIATIONS["FirefoxTransitional"]
192+
else
193+
VARIATIONS[@parsed_ua.browser]
194+
end
195+
else
196+
VARIATIONS[OTHER]
197+
end
168198
end
169199

170200
def nonces_supported?

lib/secure_headers/headers/policy_management.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,23 @@ def self.included(base)
100100
PLUGIN_TYPES
101101
].freeze
102102

103+
FIREFOX_46_DEPRECATED_DIRECTIVES = [
104+
FRAME_SRC
105+
].freeze
106+
107+
FIREFOX_46_UNSUPPORTED_DIRECTIVES = [
108+
BLOCK_ALL_MIXED_CONTENT,
109+
PLUGIN_TYPES
110+
].freeze
111+
103112
FIREFOX_DIRECTIVES = (
104113
DIRECTIVES_2_0 + DIRECTIVES_DRAFT - FIREFOX_UNSUPPORTED_DIRECTIVES
105114
).freeze
106115

116+
FIREFOX_46_DIRECTIVES = (
117+
DIRECTIVES_2_0 + DIRECTIVES_DRAFT - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
118+
).freeze
119+
107120
CHROME_DIRECTIVES = (
108121
DIRECTIVES_2_0 + DIRECTIVES_DRAFT
109122
).freeze
@@ -118,6 +131,7 @@ def self.included(base)
118131
"Chrome" => CHROME_DIRECTIVES,
119132
"Opera" => CHROME_DIRECTIVES,
120133
"Firefox" => FIREFOX_DIRECTIVES,
134+
"FirefoxTransitional" => FIREFOX_46_DIRECTIVES,
121135
"Safari" => SAFARI_DIRECTIVES,
122136
"Edge" => EDGE_DIRECTIVES,
123137
"Other" => CHROME_DIRECTIVES

spec/lib/secure_headers/headers/content_security_policy_spec.rb

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ module SecureHeaders
2424

2525
describe "#value" do
2626
it "discards 'none' values if any other source expressions are present" do
27-
csp = ContentSecurityPolicy.new(default_opts.merge(frame_src: %w('self' 'none')))
27+
csp = ContentSecurityPolicy.new(default_opts.merge(child_src: %w('self' 'none')))
2828
expect(csp.value).not_to include("'none'")
2929
end
3030

@@ -86,42 +86,68 @@ module SecureHeaders
8686
expect(csp.value).to eq("default-src example.org")
8787
end
8888

89+
it "emits a warning when using frame-src" do
90+
expect(Kernel).to receive(:warn).with(/:frame_src is deprecated, use :child_src instead./)
91+
ContentSecurityPolicy.new(default_src: %w('self'), frame_src: %w('self')).value
92+
end
93+
94+
it "emits a warning when child-src and frame-src are supplied but are not equal" do
95+
expect(Kernel).to receive(:warn).with(/both :child_src and :frame_src supplied and do not match./)
96+
ContentSecurityPolicy.new(default_src: %w('self'), child_src: %w(child-src.com), frame_src: %w(frame-src,com)).value
97+
end
98+
99+
it "will still set inconsistent child/frame-src values to be less surprising" do
100+
expect(Kernel).to receive(:warn).at_least(:once)
101+
firefox = ContentSecurityPolicy.new({default_src: %w('self'), child_src: %w(child-src.com), frame_src: %w(frame-src,com)}, USER_AGENTS[:firefox]).value
102+
firefox_transitional = ContentSecurityPolicy.new({default_src: %w('self'), child_src: %w(child-src.com), frame_src: %w(frame-src,com)}, USER_AGENTS[:firefox46]).value
103+
expect(firefox).not_to eq(firefox_transitional)
104+
expect(firefox).to match(/frame-src/)
105+
expect(firefox).not_to match(/child-src/)
106+
expect(firefox_transitional).to match(/child-src/)
107+
expect(firefox_transitional).not_to match(/frame-src/)
108+
end
109+
89110
context "browser sniffing" do
90111
let (:complex_opts) do
91-
ContentSecurityPolicy::ALL_DIRECTIVES.each_with_object({}) do |directive, hash|
92-
hash[directive] = %w('self')
112+
(ContentSecurityPolicy::ALL_DIRECTIVES - [:frame_src]).each_with_object({}) do |directive, hash|
113+
hash[directive] = ["#{directive.to_s.gsub("_", "-")}.com"]
93114
end.merge({
94115
block_all_mixed_content: true,
95116
upgrade_insecure_requests: true,
96117
reflected_xss: "block",
97-
script_src: %w('self'),
118+
script_src: %w(script-src.com),
98119
script_nonce: 123456
99120
})
100121
end
101122

102123
it "does not filter any directives for Chrome" do
103124
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:chrome])
104-
expect(policy.value).to eq("default-src 'self'; base-uri 'self'; block-all-mixed-content; child-src 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'self'; frame-src 'self'; img-src 'self'; media-src 'self'; object-src 'self'; plugin-types 'self'; sandbox 'self'; script-src 'self' 'nonce-123456'; style-src 'self'; upgrade-insecure-requests; report-uri 'self'")
125+
expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; block-all-mixed-content; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; plugin-types plugin-types.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
105126
end
106127

107128
it "does not filter any directives for Opera" do
108129
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:opera])
109-
expect(policy.value).to eq("default-src 'self'; base-uri 'self'; block-all-mixed-content; child-src 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'self'; frame-src 'self'; img-src 'self'; media-src 'self'; object-src 'self'; plugin-types 'self'; sandbox 'self'; script-src 'self' 'nonce-123456'; style-src 'self'; upgrade-insecure-requests; report-uri 'self'")
130+
expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; block-all-mixed-content; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; plugin-types plugin-types.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
110131
end
111132

112133
it "filters blocked-all-mixed-content, child-src, and plugin-types for firefox" do
113134
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:firefox])
114-
expect(policy.value).to eq("default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'self'; frame-src 'self'; img-src 'self'; media-src 'self'; object-src 'self'; sandbox 'self'; script-src 'self' 'nonce-123456'; style-src 'self'; upgrade-insecure-requests; report-uri 'self'")
135+
expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; frame-src child-src.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
136+
end
137+
138+
it "filters blocked-all-mixed-content, frame-src, and plugin-types for firefox 46 and higher" do
139+
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:firefox46])
140+
expect(policy.value).to eq("default-src default-src.com; base-uri base-uri.com; child-src child-src.com; connect-src connect-src.com; font-src font-src.com; form-action form-action.com; frame-ancestors frame-ancestors.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'nonce-123456'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
115141
end
116142

117-
it "adds 'unsafe-inline', filters base-uri, blocked-all-mixed-content, upgrade-insecure-requests, child-src, form-action, frame-ancestors, nonce sources, hash sources, and plugin-types for Edge" do
143+
it "child-src value is copied to frame-src, adds 'unsafe-inline', filters base-uri, blocked-all-mixed-content, upgrade-insecure-requests, child-src, form-action, frame-ancestors, nonce sources, hash sources, and plugin-types for Edge" do
118144
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:edge])
119-
expect(policy.value).to eq("default-src 'self'; connect-src 'self'; font-src 'self'; frame-src 'self'; img-src 'self'; media-src 'self'; object-src 'self'; sandbox 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; report-uri 'self'")
145+
expect(policy.value).to eq("default-src default-src.com; connect-src connect-src.com; font-src font-src.com; frame-src child-src.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
120146
end
121147

122-
it "adds 'unsafe-inline', filters base-uri, blocked-all-mixed-content, upgrade-insecure-requests, child-src, form-action, frame-ancestors, nonce sources, hash sources, and plugin-types for safari" do
148+
it "child-src value is copied to frame-src, adds 'unsafe-inline', filters base-uri, blocked-all-mixed-content, upgrade-insecure-requests, child-src, form-action, frame-ancestors, nonce sources, hash sources, and plugin-types for safari" do
123149
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:safari6])
124-
expect(policy.value).to eq("default-src 'self'; connect-src 'self'; font-src 'self'; frame-src 'self'; img-src 'self'; media-src 'self'; object-src 'self'; sandbox 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'; report-uri 'self'")
150+
expect(policy.value).to eq("default-src default-src.com; connect-src connect-src.com; font-src font-src.com; frame-src child-src.com; img-src img-src.com; media-src media-src.com; object-src object-src.com; sandbox sandbox.com; script-src script-src.com 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
125151
end
126152
end
127153
end

spec/lib/secure_headers/headers/policy_management_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module SecureHeaders
2323
# directive values: these values will directly translate into source directives
2424
default_src: %w(https: 'self'),
2525
frame_src: %w('self' *.twimg.com itunes.apple.com),
26+
child_src: %w('self' *.twimg.com itunes.apple.com),
2627
connect_src: %w(wss:),
2728
font_src: %w('self' data:),
2829
img_src: %w(mycdn.com data:),
@@ -31,7 +32,6 @@ module SecureHeaders
3132
script_src: %w('self'),
3233
style_src: %w('unsafe-inline'),
3334
base_uri: %w('self'),
34-
child_src: %w('self'),
3535
form_action: %w('self' github.com),
3636
frame_ancestors: %w('none'),
3737
plugin_types: %w(application/x-shockwave-flash),

spec/lib/secure_headers_spec.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ module SecureHeaders
9090
config = Configuration.default do |config|
9191
config.csp = {
9292
default_src: %w('self'),
93-
child_src: %w('self'), #unsupported by firefox
94-
frame_src: %w('self')
93+
child_src: %w('self')
9594
}
9695
end
9796
firefox_request = Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:firefox]))
@@ -102,6 +101,8 @@ module SecureHeaders
102101
SecureHeaders.override_content_security_policy_directives(firefox_request, script_src: %w('self'))
103102

104103
hash = SecureHeaders.header_hash_for(firefox_request)
104+
105+
# child-src is translated to frame-src
105106
expect(hash[CSP::HEADER_NAME]).to eq("default-src 'self'; frame-src 'self'; script-src 'self'")
106107
end
107108

spec/spec_helper.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212

1313
USER_AGENTS = {
1414
edge: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246",
15-
firefox: 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:14.0) Gecko/20100101 Firefox/14.0.1',
15+
firefox: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:14.0) Gecko/20100101 Firefox/14.0.1",
16+
firefox46: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:46.0) Gecko/20100101 Firefox/46.0",
1617
chrome: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/536.5 (KHTML, like Gecko) Chrome/19.0.1084.56 Safari/536.5',
1718
ie: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/5.0)',
1819
opera: 'Opera/9.80 (Windows NT 6.1; U; es-ES) Presto/2.9.181 Version/12.00',

0 commit comments

Comments
 (0)