Skip to content

Commit 249feb9

Browse files
authored
Merge pull request #390 from twitter/no-more-sniffing
Remove all useragent sniffing
2 parents 8378f13 + ecc8bb0 commit 249feb9

File tree

9 files changed

+18
-222
lines changed

9 files changed

+18
-222
lines changed

docs/upgrading-to-6-0.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,7 @@ Prior to 6.0.0 you could conceivably, though unlikely, have `Configure#default`
4848
Since the first commit, reducing browser console messages was a goal. It led to overly complicated and error-prone UA sniffing. Nowadays, consoles warn on completely legitimate use of features meant to be backwards compatible. So the goal is impossible and the impact is negative, so eliminating code using sniffing is a goal.
4949

5050
The first example: we will now send `'unsafe-inline'` along with nonce source expressions. This will generate warnings in some consoles but is 100% valid use and was a design goal of CSP in the early days. The concept of versioning CSP lost out and so we're left with backward compatibility as our only option.
51+
52+
## No more frame-src/child-src magic
53+
54+
First there was frame-src. Then there was child-src which deprecated frame-src. Then child-src became deprecated in favor of frame-src and worker-src. In the meantime, every browser did something different. For a while, it was recommended to set child-src and frame-src but the values had to be identical. secure_headers would sniff the UA to determine whether to use frame or child src. That can lead to confusing things like setting frame-src but seeing child-src. If the child-src and frame-src did not match up, an error is raised. This can be very confusing when using dynamic overrides ("Do we use child-src or frame-src?" => :boom:). Now that the dust has settled, I think we can stop sniffing UAs and just go with a straightforward application.

lib/secure_headers.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
require "secure_headers/middleware"
1616
require "secure_headers/railtie"
1717
require "secure_headers/view_helper"
18-
require "useragent"
1918
require "singleton"
2019
require "secure_headers/configuration"
2120

@@ -149,8 +148,7 @@ def header_hash_for(request)
149148
prevent_dup = true
150149
config = config_for(request, prevent_dup)
151150
config.validate_config!
152-
user_agent = UserAgent.parse(request.user_agent)
153-
headers = config.generate_headers(user_agent)
151+
headers = config.generate_headers
154152

155153
if request.scheme != HTTPS
156154
HTTPS_HEADER_CLASSES.each do |klass|

lib/secure_headers/configuration.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,11 @@ def override(name = nil, &block)
194194
self
195195
end
196196

197-
def generate_headers(user_agent)
197+
def generate_headers
198198
headers = {}
199199
HEADERABLE_ATTRIBUTES.each do |attr|
200200
klass = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES[attr]
201-
header_name, value = klass.make_header(instance_variable_get("@#{attr}"), user_agent)
201+
header_name, value = klass.make_header(instance_variable_get("@#{attr}"))
202202
if header_name && value
203203
headers[header_name] = value
204204
end

lib/secure_headers/headers/content_security_policy.rb

Lines changed: 5 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
11
# frozen_string_literal: true
22
require_relative "policy_management"
33
require_relative "content_security_policy_config"
4-
require "useragent"
54

65
module SecureHeaders
76
class ContentSecurityPolicy
87
include PolicyManagement
98

10-
# constants to be used for version-specific UA sniffing
11-
VERSION_46 = ::UserAgent::Version.new("46")
12-
VERSION_10 = ::UserAgent::Version.new("10")
13-
FALLBACK_VERSION = ::UserAgent::Version.new("0")
14-
15-
def initialize(config = nil, user_agent = OTHER)
16-
user_agent ||= OTHER
9+
def initialize(config = nil)
1710
@config = if config.is_a?(Hash)
1811
if config[:report_only]
1912
ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
@@ -26,12 +19,6 @@ def initialize(config = nil, user_agent = OTHER)
2619
config
2720
end
2821

29-
@parsed_ua = if user_agent.is_a?(UserAgent::Browsers::Base)
30-
user_agent
31-
else
32-
UserAgent.parse(user_agent)
33-
end
34-
@frame_src = normalize_child_frame_src
3522
@preserve_schemes = @config.preserve_schemes
3623
@script_nonce = @config.script_nonce
3724
@style_nonce = @config.style_nonce
@@ -56,20 +43,10 @@ def value
5643

5744
private
5845

59-
def normalize_child_frame_src
60-
if @config.frame_src && @config.child_src && @config.frame_src != @config.child_src
61-
raise ArgumentError, "#{Kernel.caller.first}: both :child_src and :frame_src supplied and do not match. This can lead to inconsistent behavior across browsers."
62-
end
63-
64-
@config.frame_src || @config.child_src
65-
end
66-
6746
# Private: converts the config object into a string representing a policy.
6847
# Places default-src at the first directive and report-uri as the last. All
6948
# others are presented in alphabetical order.
7049
#
71-
# Unsupported directives are filtered based on the user agent.
72-
#
7350
# Returns a content security policy header value.
7451
def build_value
7552
directives.map do |directive_name|
@@ -125,18 +102,7 @@ def build_media_type_list_directive(directive)
125102
#
126103
# Returns a string representing a directive.
127104
def build_source_list_directive(directive)
128-
source_list = case directive
129-
when :child_src
130-
if supported_directives.include?(:child_src)
131-
@frame_src
132-
end
133-
when :frame_src
134-
unless supported_directives.include?(:child_src)
135-
@frame_src
136-
end
137-
else
138-
@config.directive_value(directive)
139-
end
105+
source_list = @config.directive_value(directive)
140106

141107
if source_list != OPT_OUT && source_list && source_list.any?
142108
normalized_source_list = minify_source_list(directive, source_list)
@@ -219,13 +185,13 @@ def append_nonce(source_list, nonce)
219185
source_list
220186
end
221187

222-
# Private: return the list of directives that are supported by the user agent,
188+
# Private: return the list of directives,
223189
# starting with default-src and ending with report-uri.
224190
def directives
225191
[
226192
DEFAULT_SRC,
227-
BODY_DIRECTIVES.select { |key| supported_directives.include?(key) },
228-
REPORT_URI
193+
BODY_DIRECTIVES,
194+
REPORT_URI,
229195
].flatten
230196
end
231197

@@ -234,25 +200,6 @@ def strip_source_schemes(source_list)
234200
source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
235201
end
236202

237-
# Private: determine which directives are supported for the given user agent.
238-
#
239-
# Add UA-sniffing special casing here.
240-
#
241-
# Returns an array of symbols representing the directives.
242-
def supported_directives
243-
@supported_directives ||= if VARIATIONS[@parsed_ua.browser]
244-
if @parsed_ua.browser == "Firefox" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_46)
245-
VARIATIONS["FirefoxTransitional"]
246-
elsif @parsed_ua.browser == "Safari" && ((@parsed_ua.version || FALLBACK_VERSION) >= VERSION_10)
247-
VARIATIONS["SafariTransitional"]
248-
else
249-
VARIATIONS[@parsed_ua.browser]
250-
end
251-
else
252-
VARIATIONS[OTHER]
253-
end
254-
end
255-
256203
def symbol_to_hyphen_case(sym)
257204
sym.to_s.tr("_", "-")
258205
end

lib/secure_headers/headers/policy_management.rb

Lines changed: 3 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -81,58 +81,12 @@ def self.included(base)
8181
UPGRADE_INSECURE_REQUESTS
8282
].flatten.freeze
8383

84-
EDGE_DIRECTIVES = DIRECTIVES_1_0
85-
SAFARI_DIRECTIVES = DIRECTIVES_1_0
86-
SAFARI_10_DIRECTIVES = DIRECTIVES_2_0
87-
88-
FIREFOX_UNSUPPORTED_DIRECTIVES = [
89-
BLOCK_ALL_MIXED_CONTENT,
90-
CHILD_SRC,
91-
WORKER_SRC,
92-
PLUGIN_TYPES
93-
].freeze
94-
95-
FIREFOX_46_DEPRECATED_DIRECTIVES = [
96-
FRAME_SRC
97-
].freeze
98-
99-
FIREFOX_46_UNSUPPORTED_DIRECTIVES = [
100-
BLOCK_ALL_MIXED_CONTENT,
101-
WORKER_SRC,
102-
PLUGIN_TYPES
103-
].freeze
104-
105-
FIREFOX_DIRECTIVES = (
106-
DIRECTIVES_3_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
107-
).freeze
108-
109-
FIREFOX_46_DIRECTIVES = (
110-
DIRECTIVES_3_0 - FIREFOX_46_UNSUPPORTED_DIRECTIVES - FIREFOX_46_DEPRECATED_DIRECTIVES
111-
).freeze
112-
113-
CHROME_DIRECTIVES = (
114-
DIRECTIVES_3_0
115-
).freeze
116-
11784
ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0).uniq.sort
11885

11986
# Think of default-src and report-uri as the beginning and end respectively,
12087
# everything else is in between.
12188
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
12289

123-
VARIATIONS = {
124-
"Chrome" => CHROME_DIRECTIVES,
125-
"Opera" => CHROME_DIRECTIVES,
126-
"Firefox" => FIREFOX_DIRECTIVES,
127-
"FirefoxTransitional" => FIREFOX_46_DIRECTIVES,
128-
"Safari" => SAFARI_DIRECTIVES,
129-
"SafariTransitional" => SAFARI_10_DIRECTIVES,
130-
"Edge" => EDGE_DIRECTIVES,
131-
"Other" => CHROME_DIRECTIVES
132-
}.freeze
133-
134-
OTHER = "Other".freeze
135-
13690
DIRECTIVE_VALUE_TYPES = {
13791
BASE_URI => :source_list,
13892
BLOCK_ALL_MIXED_CONTENT => :boolean,
@@ -199,9 +153,9 @@ module ClassMethods
199153
#
200154
# Returns a default policy if no configuration is provided, or a
201155
# header name and value based on the config.
202-
def make_header(config, user_agent = nil)
156+
def make_header(config)
203157
return if config.nil? || config == OPT_OUT
204-
header = new(config, user_agent)
158+
header = new(config)
205159
[header.name, header.value]
206160
end
207161

@@ -303,17 +257,11 @@ def populate_fetch_source_with_default!(original, additions)
303257
# Don't set a default if directive has an existing value
304258
next if original[directive]
305259
if FETCH_SOURCES.include?(directive)
306-
original[directive] = default_for(directive, original)
260+
original[directive] = original[DEFAULT_SRC]
307261
end
308262
end
309263
end
310264

311-
def default_for(directive, original)
312-
return original[FRAME_SRC] if directive == CHILD_SRC && original[FRAME_SRC]
313-
return original[CHILD_SRC] if directive == FRAME_SRC && original[CHILD_SRC]
314-
original[DEFAULT_SRC]
315-
end
316-
317265
def source_list?(directive)
318266
DIRECTIVE_VALUE_TYPES[directive] == :source_list
319267
end

secure_headers.gemspec

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,4 @@ Gem::Specification.new do |gem|
1616
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
1717
gem.require_paths = ["lib"]
1818
gem.add_development_dependency "rake"
19-
gem.add_dependency "useragent", ">= 0.15.0"
2019
end

spec/lib/secure_headers/headers/content_security_policy_spec.rb

Lines changed: 1 addition & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -116,73 +116,10 @@ module SecureHeaders
116116
ContentSecurityPolicy.new(default_src: %w('self'), frame_src: %w('self')).value
117117
end
118118

119-
it "raises an error when child-src and frame-src are supplied but are not equal" do
120-
expect {
121-
ContentSecurityPolicy.new(default_src: %w('self'), child_src: %w(child-src.com), frame_src: %w(frame-src,com)).value
122-
}.to raise_error(ArgumentError)
123-
end
124-
125119
it "supports strict-dynamic" do
126-
csp = ContentSecurityPolicy.new({default_src: %w('self'), script_src: [ContentSecurityPolicy::STRICT_DYNAMIC], script_nonce: 123456}, USER_AGENTS[:chrome])
120+
csp = ContentSecurityPolicy.new({default_src: %w('self'), script_src: [ContentSecurityPolicy::STRICT_DYNAMIC], script_nonce: 123456})
127121
expect(csp.value).to eq("default-src 'self'; script-src 'strict-dynamic' 'nonce-123456' 'unsafe-inline'")
128122
end
129-
130-
context "browser sniffing" do
131-
let (:complex_opts) do
132-
(ContentSecurityPolicy::ALL_DIRECTIVES - [:frame_src]).each_with_object({}) do |directive, hash|
133-
hash[directive] = ["#{directive.to_s.gsub("_", "-")}.com"]
134-
end.merge({
135-
block_all_mixed_content: true,
136-
upgrade_insecure_requests: true,
137-
script_src: %w(script-src.com),
138-
script_nonce: 123456,
139-
sandbox: %w(allow-forms),
140-
plugin_types: %w(application/pdf)
141-
})
142-
end
143-
144-
it "does not filter any directives for Chrome" do
145-
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:chrome])
146-
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; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; plugin-types application/pdf; sandbox allow-forms; script-src script-src.com 'nonce-123456' 'unsafe-inline'; style-src style-src.com; upgrade-insecure-requests; worker-src worker-src.com; report-uri report-uri.com")
147-
end
148-
149-
it "does not filter any directives for Opera" do
150-
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:opera])
151-
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; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; plugin-types application/pdf; sandbox allow-forms; script-src script-src.com 'nonce-123456' 'unsafe-inline'; style-src style-src.com; upgrade-insecure-requests; worker-src worker-src.com; report-uri report-uri.com")
152-
end
153-
154-
it "filters blocked-all-mixed-content, child-src, and plugin-types for firefox" do
155-
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:firefox])
156-
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; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox allow-forms; script-src script-src.com 'nonce-123456' 'unsafe-inline'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
157-
end
158-
159-
it "filters blocked-all-mixed-content, frame-src, and plugin-types for firefox 46 and higher" do
160-
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:firefox46])
161-
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; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox allow-forms; script-src script-src.com 'nonce-123456' 'unsafe-inline'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
162-
end
163-
164-
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, hash sources, and plugin-types for Edge" do
165-
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:edge])
166-
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 allow-forms; script-src script-src.com 'nonce-123456' 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
167-
end
168-
169-
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, hash sources, and plugin-types for safari" do
170-
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:safari6])
171-
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 allow-forms; script-src script-src.com 'nonce-123456' 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
172-
end
173-
174-
it "adds 'unsafe-inline', filters blocked-all-mixed-content, upgrade-insecure-requests, and hash sources for safari 10 and higher" do
175-
policy = ContentSecurityPolicy.new(complex_opts, USER_AGENTS[:safari10])
176-
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; plugin-types application/pdf; sandbox allow-forms; script-src script-src.com 'nonce-123456' 'unsafe-inline'; style-src style-src.com; report-uri report-uri.com")
177-
end
178-
179-
it "falls back to standard Firefox defaults when the useragent version is not present" do
180-
ua = USER_AGENTS[:firefox].dup
181-
allow(ua).to receive(:version).and_return(nil)
182-
policy = ContentSecurityPolicy.new(complex_opts, ua)
183-
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; manifest-src manifest-src.com; media-src media-src.com; object-src object-src.com; sandbox allow-forms; script-src script-src.com 'nonce-123456' 'unsafe-inline'; style-src style-src.com; upgrade-insecure-requests; report-uri report-uri.com")
184-
end
185-
end
186123
end
187124
end
188125
end

spec/lib/secure_headers/headers/policy_management_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ module SecureHeaders
190190
report_uri = "https://report-uri.io/asdf"
191191
default_policy = Configuration.dup
192192
combined_config = ContentSecurityPolicy.combine_policies(default_policy.csp.to_h, report_uri: [report_uri])
193-
csp = ContentSecurityPolicy.new(combined_config, USER_AGENTS[:firefox])
193+
csp = ContentSecurityPolicy.new(combined_config)
194194
expect(csp.value).to include("report-uri #{report_uri}")
195195
end
196196

@@ -223,7 +223,7 @@ module SecureHeaders
223223
end
224224
default_policy = Configuration.dup
225225
combined_config = ContentSecurityPolicy.combine_policies(default_policy.csp.to_h, report_only: true)
226-
csp = ContentSecurityPolicy.new(combined_config, USER_AGENTS[:firefox])
226+
csp = ContentSecurityPolicy.new(combined_config)
227227
expect(csp.name).to eq(ContentSecurityPolicyReportOnlyConfig::HEADER_NAME)
228228
end
229229

spec/lib/secure_headers_spec.rb

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -127,27 +127,6 @@ module SecureHeaders
127127
expect(hash[XFrameOptions::HEADER_NAME]).to eq(XFrameOptions::SAMEORIGIN)
128128
end
129129

130-
it "produces a UA-specific CSP when overriding (and busting the cache)" do
131-
Configuration.default do |config|
132-
config.csp = {
133-
default_src: %w('self'),
134-
script_src: %w('self'),
135-
child_src: %w('self')
136-
}
137-
end
138-
firefox_request = Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:firefox]))
139-
140-
# append an unsupported directive
141-
SecureHeaders.override_content_security_policy_directives(firefox_request, {plugin_types: %w(application/pdf)})
142-
# append a supported directive
143-
SecureHeaders.override_content_security_policy_directives(firefox_request, {script_src: %w('self')})
144-
145-
hash = SecureHeaders.header_hash_for(firefox_request)
146-
147-
# child-src is translated to frame-src
148-
expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self'; frame-src 'self'; script-src 'self'")
149-
end
150-
151130
it "produces a hash of headers with default config" do
152131
Configuration.default
153132
hash = SecureHeaders.header_hash_for(request)
@@ -197,22 +176,6 @@ module SecureHeaders
197176
expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self'; script-src mycdn.com 'unsafe-inline' anothercdn.com")
198177
end
199178

200-
it "child-src and frame-src must match" do
201-
Configuration.default do |config|
202-
config.csp = {
203-
default_src: %w('self'),
204-
frame_src: %w(frame_src.com),
205-
script_src: %w('self')
206-
}
207-
end
208-
209-
SecureHeaders.append_content_security_policy_directives(chrome_request, child_src: %w(child_src.com))
210-
211-
expect {
212-
SecureHeaders.header_hash_for(chrome_request)
213-
}.to raise_error(ArgumentError)
214-
end
215-
216179
it "supports named appends" do
217180
Configuration.default do |config|
218181
config.csp = {

0 commit comments

Comments
 (0)