diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ddc15f..4b9e5eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,64 @@ +## 3.4.1 Named Appends + +### Small bugfix + +If your CSP did not define a script/style-src and you tried to use a script/style nonce, the nonce would be added to the page but it would not be added to the CSP. A workaround is to define a script/style src but now it should add the missing directive (and populate it with the default-src). + +### Named Appends + +Named Appends are blocks of code that can be reused and composed during requests. e.g. If a certain partial is rendered conditionally, and the csp needs to be adjusted for that partial, you can create a named append for that situation. The value returned by the block will be passed into `append_content_security_policy_directives`. The current request object is passed as an argument to the block for even more flexibility. + +```ruby +def show + if include_widget? + @widget = widget.render + use_content_security_policy_named_append(:widget_partial) + end +end + + +SecureHeaders::Configuration.named_append(:widget_partial) do |request| + if request.controller_instance.current_user.in_test_bucket? + SecureHeaders.override_x_frame_options(request, "DENY") + { child_src: %w(beta.thirdpartyhost.com) } + else + { child_src: %w(thirdpartyhost.com) } + end +end +``` + +You can use as many named appends as you would like per request, but be careful because order of inclusion matters. Consider the following: + +```ruby +SecureHeader::Configuration.default do |config| + config.csp = { default_src: %w('self')} +end + +SecureHeaders::Configuration.named_append(:A) do |request| + { default_src: %w(myhost.com) } +end + +SecureHeaders::Configuration.named_append(:B) do |request| + { script_src: %w('unsafe-eval') } +end +``` + +The following code will produce different policies due to the way policies are normalized (e.g. providing a previously undefined directive that inherits from `default-src`, removing host source values when `*` is provided. Removing `'none'` when additional values are present, etc.): + +```ruby +def index + use_content_security_policy_named_append(:A) + use_content_security_policy_named_append(:B) + # produces default-src 'self' myhost.com; script-src 'self' myhost.com 'unsafe-eval'; +end + +def show + use_content_security_policy_named_append(:B) + use_content_security_policy_named_append(:A) + # produces default-src 'self' myhost.com; script-src 'self' 'unsafe-eval'; +end +``` + ## 3.4.0 the frame-src/child-src transition for Firefox. Handle the `child-src`/`frame-src` transition semi-intelligently across versions. I think the code best descibes the behavior here: diff --git a/README.md b/README.md index 71da2ce7..b31e573a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Secure Headers [![Build Status](https://travis-ci.org/twitter/secureheaders.png?branch=master)](http://travis-ci.org/twitter/secureheaders) [![Code Climate](https://codeclimate.com/github/twitter/secureheaders.png)](https://codeclimate.com/github/twitter/secureheaders) [![Coverage Status](https://coveralls.io/repos/twitter/secureheaders/badge.png)](https://coveralls.io/r/twitter/secureheaders) +# Secure Headers [![Build Status](https://travis-ci.org/twitter/secureheaders.svg?branch=master)](http://travis-ci.org/twitter/secureheaders) [![Code Climate](https://codeclimate.com/github/twitter/secureheaders.svg)](https://codeclimate.com/github/twitter/secureheaders) [![Coverage Status](https://coveralls.io/repos/twitter/secureheaders/badge.svg)](https://coveralls.io/r/twitter/secureheaders) **The 3.x branch was recently merged**. See the [upgrading to 3.x doc](upgrading-to-3-0.md) for instructions on how to upgrade including the differences and benefits of using the 3.x branch. @@ -123,6 +123,62 @@ end If ** +## Named Appends + +Named Appends are blocks of code that can be reused and composed during requests. e.g. If a certain partial is rendered conditionally, and the csp needs to be adjusted for that partial, you can create a named append for that situation. The value returned by the block will be passed into `append_content_security_policy_directives`. The current request object is passed as an argument to the block for even more flexibility. + +```ruby +def show + if include_widget? + @widget = widget.render + use_content_security_policy_named_append(:widget_partial) + end +end + + +SecureHeaders::Configuration.named_append(:widget_partial) do |request| + SecureHeaders.override_x_frame_options(request, "DENY") + if request.controller_instance.current_user.in_test_bucket? + { child_src: %w(beta.thirdpartyhost.com) } + else + { child_src: %w(thirdpartyhost.com) } + end +end +``` + +You can use as many named appends as you would like per request, but be careful because order of inclusion matters. Consider the following: + +```ruby +SecureHeader::Configuration.default do |config| + config.csp = { default_src: %w('self')} +end + +SecureHeaders::Configuration.named_append(:A) do |request| + { default_src: %w(myhost.com) } +end + +SecureHeaders::Configuration.named_append(:B) do |request| + { script_src: %w('unsafe-eval') } +end +``` + +The following code will produce different policies due to the way policies are normalized (e.g. providing a previously undefined directive that inherits from `default-src`, removing host source values when `*` is provided. Removing `'none'` when additional values are present, etc.): + +```ruby +def index + use_content_security_policy_named_append(:A) + use_content_security_policy_named_append(:B) + # produces default-src 'self' myhost.com; script-src 'self' myhost.com 'unsafe-eval'; +end + +def show + use_content_security_policy_named_append(:B) + use_content_security_policy_named_append(:A) + # produces default-src 'self' myhost.com; script-src 'self' 'unsafe-eval'; +end +``` + + ## Named overrides Named overrides serve two purposes: diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index 3c98007a..a5112ffe 100644 --- a/lib/secure_headers.rb +++ b/lib/secure_headers.rb @@ -120,6 +120,11 @@ def append_content_security_policy_directives(request, additions, target = nil) override_secure_headers_request_config(request, config) end + def use_content_security_policy_named_append(request, name) + additions = SecureHeaders::Configuration.named_appends(name).call(request) + append_content_security_policy_directives(request, additions) + end + # Public: override X-Frame-Options settings for this request. # # value - deny, sameorigin, or allowall @@ -333,4 +338,8 @@ def override_content_security_policy_directives(additions) def override_x_frame_options(value) SecureHeaders.override_x_frame_options(request, value) end + + def use_content_security_policy_named_append(name) + SecureHeaders.use_content_security_policy_named_append(request, name) + end end diff --git a/lib/secure_headers/configuration.rb b/lib/secure_headers/configuration.rb index 0a5d5657..bf459ae3 100644 --- a/lib/secure_headers/configuration.rb +++ b/lib/secure_headers/configuration.rb @@ -46,6 +46,17 @@ def get(name = DEFAULT_CONFIG) @configurations[name] end + def named_appends(name) + @appends ||= {} + @appends[name] + end + + def named_append(name, target = nil, &block) + @appends ||= {} + raise "Provide a configuration block" unless block_given? + @appends[name] = block + end + private # Private: add a valid configuration to the global set of named configs. diff --git a/lib/secure_headers/headers/policy_management.rb b/lib/secure_headers/headers/policy_management.rb index 87bed936..e52eda3a 100644 --- a/lib/secure_headers/headers/policy_management.rb +++ b/lib/secure_headers/headers/policy_management.rb @@ -50,16 +50,6 @@ def self.included(base) FRAME_ANCESTORS = :frame_ancestors PLUGIN_TYPES = :plugin_types - # These are directives that do not inherit the default-src value. This is - # useful when calling #combine_policies. - NON_FETCH_SOURCES = [ - BASE_URI, - FORM_ACTION, - FRAME_ANCESTORS, - PLUGIN_TYPES, - REPORT_URI - ] - DIRECTIVES_2_0 = [ DIRECTIVES_1_0, BASE_URI, @@ -124,6 +114,18 @@ def self.included(base) # everything else is in between. BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI] + # These are directives that do not inherit the default-src value. This is + # useful when calling #combine_policies. + NON_FETCH_SOURCES = [ + BASE_URI, + FORM_ACTION, + FRAME_ANCESTORS, + PLUGIN_TYPES, + REPORT_URI + ] + + FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES + VARIATIONS = { "Chrome" => CHROME_DIRECTIVES, "Opera" => CHROME_DIRECTIVES, @@ -260,8 +262,23 @@ def merge_policy_additions(original, additions) def populate_fetch_source_with_default!(original, additions) # in case we would be appending to an empty directive, fill it with the default-src value additions.keys.each do |directive| - unless original[directive] || !source_list?(directive) || NON_FETCH_SOURCES.include?(directive) - original[directive] = original[:default_src] + if !original[directive] && ((source_list?(directive) && FETCH_SOURCES.include?(directive)) || nonce_added?(original, additions)) + if nonce_added?(original, additions) + inferred_directive = directive.to_s.gsub(/_nonce/, "_src").to_sym + unless original[inferred_directive] || NON_FETCH_SOURCES.include?(inferred_directive) + original[inferred_directive] = original[:default_src] + end + else + original[directive] = original[:default_src] + end + end + end + end + + def nonce_added?(original, additions) + [:script_nonce, :style_nonce].each do |nonce| + if additions[nonce] && !original[nonce] + return true end end end diff --git a/secure_headers.gemspec b/secure_headers.gemspec index b1055ce1..d43aca1e 100644 --- a/secure_headers.gemspec +++ b/secure_headers.gemspec @@ -1,7 +1,7 @@ # -*- encoding: utf-8 -*- Gem::Specification.new do |gem| gem.name = "secure_headers" - gem.version = "3.4.0" + gem.version = "3.4.1" gem.authors = ["Neil Matatall"] gem.email = ["neil.matatall@gmail.com"] gem.description = 'Security related headers all in one gem.' diff --git a/spec/lib/secure_headers_spec.rb b/spec/lib/secure_headers_spec.rb index c0e49045..30279e5e 100644 --- a/spec/lib/secure_headers_spec.rb +++ b/spec/lib/secure_headers_spec.rb @@ -156,6 +156,10 @@ module SecureHeaders end context "content security policy" do + let(:chrome_request) { + Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:chrome])) + } + it "appends a value to csp directive" do Configuration.default do |config| config.csp = { @@ -169,6 +173,52 @@ module SecureHeaders expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self'; script-src mycdn.com 'unsafe-inline' anothercdn.com") end + it "supports named appends" do + Configuration.default do |config| + config.csp = { + default_src: %w('self') + } + end + + Configuration.named_append(:moar_default_sources) do |request| + { default_src: %w(https:)} + end + + Configuration.named_append(:how_about_a_script_src_too) do |request| + { script_src: %w('unsafe-inline')} + end + + SecureHeaders.use_content_security_policy_named_append(request, :moar_default_sources) + SecureHeaders.use_content_security_policy_named_append(request, :how_about_a_script_src_too) + hash = SecureHeaders.header_hash_for(request) + + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self' https:; script-src 'self' https: 'unsafe-inline'") + end + + it "appends a nonce to a missing script-src value" do + Configuration.default do |config| + config.csp = { + default_src: %w('self') + } + end + + SecureHeaders.content_security_policy_script_nonce(request) # should add the value to the header + hash = SecureHeaders.header_hash_for(chrome_request) + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to match /\Adefault-src 'self'; script-src 'self' 'nonce-.*'\z/ + end + + it "appends a hash to a missing script-src value" do + Configuration.default do |config| + config.csp = { + default_src: %w('self') + } + end + + SecureHeaders.append_content_security_policy_directives(request, script_src: %w('sha256-abc123')) + hash = SecureHeaders.header_hash_for(chrome_request) + expect(hash[ContentSecurityPolicyConfig::HEADER_NAME]).to match /\Adefault-src 'self'; script-src 'self' 'sha256-abc123'\z/ + end + it "overrides individual directives" do Configuration.default do |config| config.csp = { @@ -216,7 +266,6 @@ module SecureHeaders } end - chrome_request = Rack::Request.new(request.env.merge("HTTP_USER_AGENT" => USER_AGENTS[:chrome])) nonce = SecureHeaders.content_security_policy_script_nonce(chrome_request) # simulate the nonce being used multiple times in a request: