Skip to content

Commit 7efa486

Browse files
committed
Merge pull request #179 from twitter/ua-sniffing-for-support
Ua sniffing for support
2 parents b5fe9d4 + d3aa8de commit 7efa486

File tree

6 files changed

+141
-57
lines changed

6 files changed

+141
-57
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ The following methods are going to be called, unless they are provided in a `ski
6161
:form_action => "'self' github.com",
6262
:frame_ancestors => "'none'",
6363
:plugin_types => 'application/x-shockwave-flash',
64+
:block_all_mixed_content => '' # see [http://www.w3.org/TR/mixed-content/]()
6465
:report_uri => '//example.com/uri-directive'
6566
}
6667
config.hpkp = {
@@ -99,7 +100,7 @@ Sometimes you need to override your content security policy for a given endpoint
99100
1. Override the `secure_header_options_for` class instance method. e.g.
100101

101102
```ruby
102-
class SomethingController < ApplicationController
103+
class SomethingController < ApplicationController
103104
def wumbus
104105
# gets style-src override
105106
end

lib/secure_headers/headers/content_security_policy.rb

Lines changed: 111 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,62 @@ module Constants
1111
DEFAULT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https: about: javascript:; img-src data:"
1212
HEADER_NAME = "Content-Security-Policy"
1313
ENV_KEY = 'secure_headers.content_security_policy'
14-
DIRECTIVES = [
14+
15+
DIRECTIVES_1_0 = [
1516
:default_src,
1617
:connect_src,
1718
:font_src,
1819
:frame_src,
1920
:img_src,
2021
:media_src,
2122
:object_src,
23+
:sandbox,
2224
:script_src,
2325
:style_src,
26+
:report_uri
27+
].freeze
28+
29+
DIRECTIVES_2_0 = [
30+
DIRECTIVES_1_0,
2431
:base_uri,
2532
:child_src,
2633
:form_action,
2734
:frame_ancestors,
2835
:plugin_types
29-
]
36+
].flatten.freeze
3037

31-
OTHER = [
32-
:report_uri
33-
]
3438

35-
ALL_DIRECTIVES = DIRECTIVES + OTHER
39+
# All the directives currently under consideration for CSP level 3.
40+
# https://w3c.github.io/webappsec/specs/CSP2/
41+
DIRECTIVES_3_0 = [
42+
DIRECTIVES_2_0,
43+
:manifest_src,
44+
:reflected_xss
45+
].flatten.freeze
46+
47+
# All the directives that are not currently in a formal spec, but have
48+
# been implemented somewhere.
49+
DIRECTIVES_DRAFT = [
50+
:block_all_mixed_content,
51+
].freeze
52+
53+
SAFARI_DIRECTIVES = DIRECTIVES_1_0
54+
55+
FIREFOX_UNSUPPORTED_DIRECTIVES = [
56+
:block_all_mixed_content,
57+
:child_src,
58+
:plugin_types
59+
].freeze
60+
61+
FIREFOX_DIRECTIVES = (
62+
DIRECTIVES_2_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
63+
).freeze
64+
65+
CHROME_DIRECTIVES = (
66+
DIRECTIVES_2_0 + DIRECTIVES_DRAFT
67+
).freeze
68+
69+
ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
3670
CONFIG_KEY = :csp
3771
end
3872

@@ -99,33 +133,55 @@ def initialize(config=nil, options={})
99133
@ua = options[:ua]
100134
@ssl_request = !!options.delete(:ssl)
101135
@request_uri = options.delete(:request_uri)
136+
@http_additions = config.delete(:http_additions)
137+
@disable_img_src_data_uri = !!config.delete(:disable_img_src_data_uri)
138+
@tag_report_uri = !!config.delete(:tag_report_uri)
139+
@script_hashes = config.delete(:script_hashes) || []
140+
@app_name = config.delete(:app_name)
141+
@app_name = @app_name.call(@controller) if @app_name.respond_to?(:call)
142+
@enforce = config.delete(:enforce)
143+
@enforce = @enforce.call(@controller) if @enforce.respond_to?(:call)
144+
@enforce = !!@enforce
102145

103146
# Config values can be string, array, or lamdba values
104147
@config = config.inject({}) do |hash, (key, value)|
105148
config_val = value.respond_to?(:call) ? value.call(@controller) : value
106-
107-
if DIRECTIVES.include?(key) # directives need to be normalized to arrays of strings
149+
if ALL_DIRECTIVES.include?(key.to_sym) # directives need to be normalized to arrays of strings
108150
config_val = config_val.split if config_val.is_a? String
109151
if config_val.is_a?(Array)
110152
config_val = config_val.map do |val|
111153
translate_dir_value(val)
112154
end.flatten.uniq
113155
end
156+
elsif key != :script_hash_middleware
157+
raise ArgumentError.new("Unknown directive supplied: #{key}")
114158
end
115159

116160
hash[key] = config_val
117161
hash
118162
end
119163

120-
@http_additions = @config.delete(:http_additions)
121-
@app_name = @config.delete(:app_name)
122-
@report_uri = @config.delete(:report_uri)
123-
@enforce = !!@config.delete(:enforce)
124-
@disable_img_src_data_uri = !!@config.delete(:disable_img_src_data_uri)
125-
@tag_report_uri = !!@config.delete(:tag_report_uri)
126-
@script_hashes = @config.delete(:script_hashes) || []
164+
# normalize and tag the report-uri
165+
if @config[:report_uri]
166+
@config[:report_uri] = @config[:report_uri].map do |report_uri|
167+
if report_uri.start_with?('//')
168+
report_uri = if @ssl_request
169+
"https:" + report_uri
170+
else
171+
"http:" + report_uri
172+
end
173+
end
174+
175+
if @tag_report_uri
176+
report_uri = "#{report_uri}?enforce=#{@enforce}"
177+
report_uri += "&app_name=#{@app_name}" if @app_name
178+
end
179+
report_uri
180+
end
181+
end
127182

128183
add_script_hashes if @script_hashes.any?
184+
strip_unsupported_directives
129185
end
130186

131187
##
@@ -160,13 +216,20 @@ def value
160216

161217
def to_json
162218
build_value
163-
@config.to_json.gsub(/(\w+)_src/, "\\1-src")
219+
@config.inject({}) do |hash, (key, value)|
220+
if ALL_DIRECTIVES.include?(key)
221+
hash[key.to_s.gsub(/(\w+)_(\w+)/, "\\1-\\2")] = value
222+
end
223+
hash
224+
end.to_json
164225
end
165226

166227
def self.from_json(*json_configs)
167228
json_configs.inject({}) do |combined_config, one_config|
168-
one_config = one_config.gsub(/(\w+)-src/, "\\1_src")
169-
config = JSON.parse(one_config, :symbolize_names => true)
229+
config = JSON.parse(one_config).inject({}) do |hash, (key, value)|
230+
hash[key.gsub(/(\w+)-(\w+)/, "\\1_\\2").to_sym] = value
231+
hash
232+
end
170233
combined_config.merge(config) do |_, lhs, rhs|
171234
lhs | rhs
172235
end
@@ -182,10 +245,7 @@ def add_script_hashes
182245
def build_value
183246
raise "Expected to find default_src directive value" unless @config[:default_src]
184247
append_http_additions unless ssl_request?
185-
header_value = [
186-
generic_directives,
187-
report_uri_directive
188-
].join.strip
248+
generic_directives
189249
end
190250

191251
def append_http_additions
@@ -204,7 +264,7 @@ def translate_dir_value val
204264
warn "[DEPRECATION] using self/none may not be supported in the future. Instead use 'self'/'none' instead."
205265
"'#{val}'"
206266
elsif val == 'nonce'
207-
if supports_nonces?(@ua)
267+
if supports_nonces?
208268
self.class.set_nonce(@controller, nonce)
209269
["'nonce-#{nonce}'", "'unsafe-inline'"]
210270
else
@@ -215,47 +275,50 @@ def translate_dir_value val
215275
end
216276
end
217277

218-
def report_uri_directive
219-
return '' if @report_uri.nil?
220-
221-
if @report_uri.start_with?('//')
222-
@report_uri = if @ssl_request
223-
"https:" + @report_uri
224-
else
225-
"http:" + @report_uri
226-
end
227-
end
228-
229-
if @tag_report_uri
230-
@report_uri = "#{@report_uri}?enforce=#{@enforce}"
231-
@report_uri += "&app_name=#{@app_name}" if @app_name
232-
end
233-
234-
"report-uri #{@report_uri};"
235-
end
236-
278+
# ensures defualt_src is first and report_uri is last
237279
def generic_directives
238-
header_value = ''
280+
header_value = build_directive(:default_src)
239281
data_uri = @disable_img_src_data_uri ? [] : ["data:"]
240282
if @config[:img_src]
241283
@config[:img_src] = @config[:img_src] + data_uri unless @config[:img_src].include?('data:')
242284
else
243285
@config[:img_src] = @config[:default_src] + data_uri
244286
end
245287

246-
DIRECTIVES.each do |directive_name|
247-
header_value += build_directive(directive_name) if @config[directive_name]
288+
(ALL_DIRECTIVES - [:default_src, :report_uri]).each do |directive_name|
289+
if @config[directive_name]
290+
header_value += build_directive(directive_name)
291+
end
248292
end
249293

250-
header_value
294+
header_value += build_directive(:report_uri) if @config[:report_uri]
295+
296+
header_value.strip
251297
end
252298

253299
def build_directive(key)
254300
"#{self.class.symbol_to_hyphen_case(key)} #{@config[key].join(" ")}; "
255301
end
256302

257-
def supports_nonces?(user_agent)
258-
parsed_ua = UserAgentParser.parse(user_agent)
303+
def strip_unsupported_directives
304+
@config.select! { |key, _| supported_directives.include?(key) }
305+
end
306+
307+
def supported_directives
308+
@supported_directives ||= case UserAgentParser.parse(@ua).family
309+
when "Chrome"
310+
CHROME_DIRECTIVES
311+
when "Safari"
312+
SAFARI_DIRECTIVES
313+
when "Firefox"
314+
FIREFOX_DIRECTIVES
315+
else
316+
DIRECTIVES_1_0
317+
end
318+
end
319+
320+
def supports_nonces?
321+
parsed_ua = UserAgentParser.parse(@ua)
259322
["Chrome", "Opera", "Firefox"].include?(parsed_ua.family)
260323
end
261324
end

lib/secure_headers/view_helper.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ def hashed_javascript_tag(raise_error_on_unrecognized_hash = false, &block)
2727
if raise_error_on_unrecognized_hash
2828
raise UnexpectedHashedScriptException.new(message)
2929
else
30-
puts message
3130
request.env[HASHES_ENV_KEY] = (request.env[HASHES_ENV_KEY] || []) << hash_value
3231
end
3332
end

spec/lib/secure_headers/headers/content_security_policy/script_hash_middleware_spec.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ module SecureHeaders
1010

1111
let(:default_config) do
1212
{
13-
:disable_fill_missing => true,
1413
:default_src => 'https://*',
1514
:report_uri => '/csp_report',
1615
:script_src => "'unsafe-inline' 'unsafe-eval' https://* data:",

spec/lib/secure_headers/headers/content_security_policy_spec.rb

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ module SecureHeaders
55
let(:default_opts) do
66
{
77
:default_src => 'https:',
8-
:report_uri => '/csp_report',
8+
:img_src => "https: data:",
99
:script_src => "'unsafe-inline' 'unsafe-eval' https: data:",
10-
:style_src => "'unsafe-inline' https: about:"
10+
:style_src => "'unsafe-inline' https: about:",
11+
:report_uri => '/csp_report'
1112
}
1213
end
1314
let(:controller) { DummyClass.new }
@@ -58,7 +59,7 @@ def request_for user_agent, request_uri=nil, options={:ssl => false}
5859

5960
it "exports a policy to JSON" do
6061
policy = ContentSecurityPolicy.new(default_opts)
61-
expected = %({"default-src":["https:"],"script-src":["'unsafe-inline'","'unsafe-eval'","https:","data:"],"style-src":["'unsafe-inline'","https:","about:"],"img-src":["https:","data:"]})
62+
expected = %({"default-src":["https:"],"img-src":["https:","data:"],"script-src":["'unsafe-inline'","'unsafe-eval'","https:","data:"],"style-src":["'unsafe-inline'","https:","about:"],"report-uri":["/csp_report"]})
6263
expect(policy.to_json).to eq(expected)
6364
end
6465

@@ -141,6 +142,27 @@ def request_for user_agent, request_uri=nil, options={:ssl => false}
141142
end
142143

143144
describe "#value" do
145+
context "browser sniffing" do
146+
let(:complex_opts) do
147+
ALL_DIRECTIVES.inject({}) { |memo, directive| memo[directive] = "'self'"; memo }.merge(:block_all_mixed_content => '')
148+
end
149+
150+
it "does not filter any directives for Chrome" do
151+
policy = ContentSecurityPolicy.new(complex_opts, :request => request_for(CHROME))
152+
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' data:; media-src 'self'; object-src 'self'; plugin-types 'self'; sandbox 'self'; script-src 'self'; style-src 'self'; report-uri 'self';")
153+
end
154+
155+
it "filters blocked-all-mixed-content, child-src, and plugin-types for firefox" do
156+
policy = ContentSecurityPolicy.new(complex_opts, :request => request_for(FIREFOX))
157+
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' data:; media-src 'self'; object-src 'self'; sandbox 'self'; script-src 'self'; style-src 'self'; report-uri 'self';")
158+
end
159+
160+
it "filters base-uri, blocked-all-mixed-content, child-src, form-action, frame-ancestors, and plugin-types for safari" do
161+
policy = ContentSecurityPolicy.new(complex_opts, :request => request_for(SAFARI))
162+
expect(policy.value).to eq("default-src 'self'; connect-src 'self'; font-src 'self'; frame-src 'self'; img-src 'self' data:; media-src 'self'; object-src 'self'; sandbox 'self'; script-src 'self'; style-src 'self'; report-uri 'self';")
163+
end
164+
end
165+
144166
it "raises an exception when default-src is missing" do
145167
csp = ContentSecurityPolicy.new({:script_src => 'anything'}, :request => request_for(CHROME))
146168
expect {
@@ -248,7 +270,7 @@ def request_for user_agent, request_uri=nil, options={:ssl => false}
248270

249271
it "adds directive values for headers on http" do
250272
csp = ContentSecurityPolicy.new(options, :request => request_for(CHROME))
251-
expect(csp.value).to eq("default-src https:; frame-src http:; img-src http: data:; script-src 'unsafe-inline' 'unsafe-eval' https: data:; style-src 'unsafe-inline' https: about:; report-uri /csp_report;")
273+
expect(csp.value).to eq("default-src https:; frame-src http:; img-src https: data: http:; script-src 'unsafe-inline' 'unsafe-eval' https: data:; style-src 'unsafe-inline' https: about:; report-uri /csp_report;")
252274
end
253275

254276
it "does not add the directive values if requesting https" do

spec/lib/secure_headers_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def expect_default_values(hash)
166166
end
167167

168168
it "produces a hash of headers given a hash as config" do
169-
hash = SecureHeaders::header_hash(:csp => {:default_src => "'none'", :img_src => "data:", :disable_fill_missing => true})
169+
hash = SecureHeaders::header_hash(:csp => {:default_src => "'none'", :img_src => "data:"})
170170
expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'none'; img-src data:;")
171171
expect_default_values(hash)
172172
end
@@ -186,7 +186,7 @@ def expect_default_values(hash)
186186
}
187187
end
188188

189-
hash = SecureHeaders::header_hash(:csp => {:default_src => "'none'", :img_src => "data:", :disable_fill_missing => true})
189+
hash = SecureHeaders::header_hash(:csp => {:default_src => "'none'", :img_src => "data:"})
190190
::SecureHeaders::Configuration.configure do |config|
191191
config.hsts = nil
192192
config.hpkp = nil

0 commit comments

Comments
 (0)