Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ua sniffing for support #179

Merged
merged 8 commits into from
Oct 7, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ The following methods are going to be called, unless they are provided in a `ski
:form_action => "'self' github.com",
:frame_ancestors => "'none'",
:plugin_types => 'application/x-shockwave-flash',
:block_all_mixed_content => '' # see [http://www.w3.org/TR/mixed-content/]()
:report_uri => '//example.com/uri-directive'
}
config.hpkp = {
Expand Down Expand Up @@ -99,7 +100,7 @@ Sometimes you need to override your content security policy for a given endpoint
1. Override the `secure_header_options_for` class instance method. e.g.

```ruby
class SomethingController < ApplicationController
class SomethingController < ApplicationController
def wumbus
# gets style-src override
end
Expand Down
159 changes: 111 additions & 48 deletions lib/secure_headers/headers/content_security_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,62 @@ module Constants
DEFAULT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https: about: javascript:; img-src data:"
HEADER_NAME = "Content-Security-Policy"
ENV_KEY = 'secure_headers.content_security_policy'
DIRECTIVES = [

DIRECTIVES_1_0 = [
:default_src,
:connect_src,
:font_src,
:frame_src,
:img_src,
:media_src,
:object_src,
:sandbox,
:script_src,
:style_src,
:report_uri
].freeze

DIRECTIVES_2_0 = [
DIRECTIVES_1_0,
:base_uri,
:child_src,
:form_action,
:frame_ancestors,
:plugin_types
]
].flatten.freeze

OTHER = [
:report_uri
]

ALL_DIRECTIVES = DIRECTIVES + OTHER
# All the directives currently under consideration for CSP level 3.
# https://w3c.github.io/webappsec/specs/CSP2/
DIRECTIVES_3_0 = [
DIRECTIVES_2_0,
:manifest_src,
:reflected_xss
].flatten.freeze

# All the directives that are not currently in a formal spec, but have
# been implemented somewhere.
DIRECTIVES_DRAFT = [
:block_all_mixed_content,
].freeze

SAFARI_DIRECTIVES = DIRECTIVES_1_0

FIREFOX_UNSUPPORTED_DIRECTIVES = [
:block_all_mixed_content,
:child_src,
:plugin_types
].freeze

FIREFOX_DIRECTIVES = (
DIRECTIVES_2_0 - FIREFOX_UNSUPPORTED_DIRECTIVES
).freeze

CHROME_DIRECTIVES = (
DIRECTIVES_2_0 + DIRECTIVES_DRAFT
).freeze

ALL_DIRECTIVES = [DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_DRAFT].flatten.uniq.sort
CONFIG_KEY = :csp
end

Expand Down Expand Up @@ -99,33 +133,55 @@ def initialize(config=nil, options={})
@ua = options[:ua]
@ssl_request = !!options.delete(:ssl)
@request_uri = options.delete(:request_uri)
@http_additions = config.delete(:http_additions)
@disable_img_src_data_uri = !!config.delete(:disable_img_src_data_uri)
@tag_report_uri = !!config.delete(:tag_report_uri)
@script_hashes = config.delete(:script_hashes) || []
@app_name = config.delete(:app_name)
@app_name = @app_name.call(@controller) if @app_name.respond_to?(:call)
@enforce = config.delete(:enforce)
@enforce = @enforce.call(@controller) if @enforce.respond_to?(:call)
@enforce = !!@enforce

# Config values can be string, array, or lamdba values
@config = config.inject({}) do |hash, (key, value)|
config_val = value.respond_to?(:call) ? value.call(@controller) : value

if DIRECTIVES.include?(key) # directives need to be normalized to arrays of strings
if ALL_DIRECTIVES.include?(key.to_sym) # directives need to be normalized to arrays of strings
config_val = config_val.split if config_val.is_a? String
if config_val.is_a?(Array)
config_val = config_val.map do |val|
translate_dir_value(val)
end.flatten.uniq
end
elsif key != :script_hash_middleware
raise ArgumentError.new("Unknown directive supplied: #{key}")
end

hash[key] = config_val
hash
end

@http_additions = @config.delete(:http_additions)
@app_name = @config.delete(:app_name)
@report_uri = @config.delete(:report_uri)
@enforce = [email protected](:enforce)
@disable_img_src_data_uri = [email protected](:disable_img_src_data_uri)
@tag_report_uri = [email protected](:tag_report_uri)
@script_hashes = @config.delete(:script_hashes) || []
# normalize and tag the report-uri
if @config[:report_uri]
@config[:report_uri] = @config[:report_uri].map do |report_uri|
if report_uri.start_with?('//')
report_uri = if @ssl_request
"https:" + report_uri
else
"http:" + report_uri
end
end

if @tag_report_uri
report_uri = "#{report_uri}?enforce=#{@enforce}"
report_uri += "&app_name=#{@app_name}" if @app_name
end
report_uri
end
end

add_script_hashes if @script_hashes.any?
strip_unsupported_directives
end

##
Expand Down Expand Up @@ -160,13 +216,20 @@ def value

def to_json
build_value
@config.to_json.gsub(/(\w+)_src/, "\\1-src")
@config.inject({}) do |hash, (key, value)|
if ALL_DIRECTIVES.include?(key)
hash[key.to_s.gsub(/(\w+)_(\w+)/, "\\1-\\2")] = value
end
hash
end.to_json
end

def self.from_json(*json_configs)
json_configs.inject({}) do |combined_config, one_config|
one_config = one_config.gsub(/(\w+)-src/, "\\1_src")
config = JSON.parse(one_config, :symbolize_names => true)
config = JSON.parse(one_config).inject({}) do |hash, (key, value)|
hash[key.gsub(/(\w+)-(\w+)/, "\\1_\\2").to_sym] = value
hash
end
combined_config.merge(config) do |_, lhs, rhs|
lhs | rhs
end
Expand All @@ -182,10 +245,7 @@ def add_script_hashes
def build_value
raise "Expected to find default_src directive value" unless @config[:default_src]
append_http_additions unless ssl_request?
header_value = [
generic_directives,
report_uri_directive
].join.strip
generic_directives
end

def append_http_additions
Expand All @@ -204,7 +264,7 @@ def translate_dir_value val
warn "[DEPRECATION] using self/none may not be supported in the future. Instead use 'self'/'none' instead."
"'#{val}'"
elsif val == 'nonce'
if supports_nonces?(@ua)
if supports_nonces?
self.class.set_nonce(@controller, nonce)
["'nonce-#{nonce}'", "'unsafe-inline'"]
else
Expand All @@ -215,47 +275,50 @@ def translate_dir_value val
end
end

def report_uri_directive
return '' if @report_uri.nil?

if @report_uri.start_with?('//')
@report_uri = if @ssl_request
"https:" + @report_uri
else
"http:" + @report_uri
end
end

if @tag_report_uri
@report_uri = "#{@report_uri}?enforce=#{@enforce}"
@report_uri += "&app_name=#{@app_name}" if @app_name
end

"report-uri #{@report_uri};"
end

# ensures defualt_src is first and report_uri is last
def generic_directives
header_value = ''
header_value = build_directive(:default_src)
data_uri = @disable_img_src_data_uri ? [] : ["data:"]
if @config[:img_src]
@config[:img_src] = @config[:img_src] + data_uri unless @config[:img_src].include?('data:')
else
@config[:img_src] = @config[:default_src] + data_uri
end

DIRECTIVES.each do |directive_name|
header_value += build_directive(directive_name) if @config[directive_name]
(ALL_DIRECTIVES - [:default_src, :report_uri]).each do |directive_name|
if @config[directive_name]
header_value += build_directive(directive_name)
end
end

header_value
header_value += build_directive(:report_uri) if @config[:report_uri]

header_value.strip
end

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

def supports_nonces?(user_agent)
parsed_ua = UserAgentParser.parse(user_agent)
def strip_unsupported_directives
@config.select! { |key, _| supported_directives.include?(key) }
end

def supported_directives
@supported_directives ||= case UserAgentParser.parse(@ua).family
when "Chrome"
CHROME_DIRECTIVES
when "Safari"
SAFARI_DIRECTIVES
when "Firefox"
FIREFOX_DIRECTIVES
else
DIRECTIVES_1_0
end
end

def supports_nonces?
parsed_ua = UserAgentParser.parse(@ua)
["Chrome", "Opera", "Firefox"].include?(parsed_ua.family)
end
end
Expand Down
1 change: 0 additions & 1 deletion lib/secure_headers/view_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ def hashed_javascript_tag(raise_error_on_unrecognized_hash = false, &block)
if raise_error_on_unrecognized_hash
raise UnexpectedHashedScriptException.new(message)
else
puts message
request.env[HASHES_ENV_KEY] = (request.env[HASHES_ENV_KEY] || []) << hash_value
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ module SecureHeaders

let(:default_config) do
{
:disable_fill_missing => true,
:default_src => 'https://*',
:report_uri => '/csp_report',
:script_src => "'unsafe-inline' 'unsafe-eval' https://* data:",
Expand Down
30 changes: 26 additions & 4 deletions spec/lib/secure_headers/headers/content_security_policy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ module SecureHeaders
let(:default_opts) do
{
:default_src => 'https:',
:report_uri => '/csp_report',
:img_src => "https: data:",
:script_src => "'unsafe-inline' 'unsafe-eval' https: data:",
:style_src => "'unsafe-inline' https: about:"
:style_src => "'unsafe-inline' https: about:",
:report_uri => '/csp_report'
}
end
let(:controller) { DummyClass.new }
Expand Down Expand Up @@ -58,7 +59,7 @@ def request_for user_agent, request_uri=nil, options={:ssl => false}

it "exports a policy to JSON" do
policy = ContentSecurityPolicy.new(default_opts)
expected = %({"default-src":["https:"],"script-src":["'unsafe-inline'","'unsafe-eval'","https:","data:"],"style-src":["'unsafe-inline'","https:","about:"],"img-src":["https:","data:"]})
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"]})
expect(policy.to_json).to eq(expected)
end

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

describe "#value" do
context "browser sniffing" do
let(:complex_opts) do
ALL_DIRECTIVES.inject({}) { |memo, directive| memo[directive] = "'self'"; memo }.merge(:block_all_mixed_content => '')
end

it "does not filter any directives for Chrome" do
policy = ContentSecurityPolicy.new(complex_opts, :request => request_for(CHROME))
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';")
end

it "filters blocked-all-mixed-content, child-src, and plugin-types for firefox" do
policy = ContentSecurityPolicy.new(complex_opts, :request => request_for(FIREFOX))
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';")
end

it "filters base-uri, blocked-all-mixed-content, child-src, form-action, frame-ancestors, and plugin-types for safari" do
policy = ContentSecurityPolicy.new(complex_opts, :request => request_for(SAFARI))
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';")
end
end

it "raises an exception when default-src is missing" do
csp = ContentSecurityPolicy.new({:script_src => 'anything'}, :request => request_for(CHROME))
expect {
Expand Down Expand Up @@ -248,7 +270,7 @@ def request_for user_agent, request_uri=nil, options={:ssl => false}

it "adds directive values for headers on http" do
csp = ContentSecurityPolicy.new(options, :request => request_for(CHROME))
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;")
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;")
end

it "does not add the directive values if requesting https" do
Expand Down
4 changes: 2 additions & 2 deletions spec/lib/secure_headers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def expect_default_values(hash)
end

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

hash = SecureHeaders::header_hash(:csp => {:default_src => "'none'", :img_src => "data:", :disable_fill_missing => true})
hash = SecureHeaders::header_hash(:csp => {:default_src => "'none'", :img_src => "data:"})
::SecureHeaders::Configuration.configure do |config|
config.hsts = nil
config.hpkp = nil
Expand Down