Skip to content

Commit

Permalink
Merge pull request #281 from twitter/dynamic-csp-config-abstraction
Browse files Browse the repository at this point in the history
Setting two CSP headers, abstract out dynamic pieces
  • Loading branch information
oreoshake authored Sep 9, 2016
2 parents f083d8c + 9a3c729 commit 9ee9693
Show file tree
Hide file tree
Showing 13 changed files with 690 additions and 257 deletions.
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ SecureHeaders::Configuration.default do |config|
secure: true, # mark all cookies as "Secure"
httponly: true, # mark all cookies as "HttpOnly"
samesite: {
strict: true # mark all cookies as SameSite=Strict
lax: true # mark all cookies as SameSite=lax
}
}
config.hsts = "max-age=#{20.years.to_i}; includeSubdomains; preload"
Expand All @@ -48,7 +48,7 @@ SecureHeaders::Configuration.default do |config|
config.referrer_policy = "origin-when-cross-origin"
config.csp = {
# "meta" values. these will shaped the header, but the values are not included in the header.
report_only: true, # default: false
report_only: true, # default: false [DEPRECATED: instead, configure csp_report_only]
preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content.

# directive values: these values will directly translate into source directives
Expand All @@ -69,6 +69,10 @@ SecureHeaders::Configuration.default do |config|
upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/
report_uri: %w(https://report-uri.io/example-csp)
}
config.csp_report_only = config.csp.merge({
img_src: %w(somewhereelse.com),
report_uri: %w(https://report-uri.io/example-csp-report-only)
})
config.hpkp = {
report_only: false,
max_age: 60.days.to_i,
Expand All @@ -92,7 +96,32 @@ use SecureHeaders::Middleware

## Default values

All headers except for PublicKeyPins have a default value. See the [corresponding classes for their defaults](https://github.com/twitter/secureheaders/tree/master/lib/secure_headers/headers).
All headers except for PublicKeyPins have a default value. See the [corresponding classes for their defaults](https://github.com/twitter/secureheaders/tree/master/lib/secure_headers/headers). The default set of headers is:

```
Content-Security-Policy: default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'
Strict-Transport-Security: max-age=631138519
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Frame-Options: sameorigin
X-Permitted-Cross-Domain-Policies: none
X-Xss-Protection: 1; mode=block
```

### Default CSP

By default, the above CSP will be applied to all requests. If you **only** want to set a Report-Only header, opt-out of the default enforced header for clarity. The configuration will assume that if you only supply `csp_report_only` that you intended to opt-out of `csp` but that's for the sake of backwards compatibility and it will be removed in the future.

```ruby
Configuration.default do |config|
config.csp = SecureHeaders::OPT_OUT # If this line is omitted, we will assume you meant to opt out.
config.csp_report_only = {
default_src: %w('self')
}
end
```

If **

## Named Appends

Expand Down
178 changes: 122 additions & 56 deletions lib/secure_headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,43 @@
require "secure_headers/railtie"
require "secure_headers/view_helper"
require "useragent"
require "singleton"

# All headers (except for hpkp) have a default value. Provide SecureHeaders::OPT_OUT
# or ":optout_of_protection" as a config value to disable a given header
module SecureHeaders
OPT_OUT = :opt_out_of_protection
class NoOpHeaderConfig
include Singleton

def boom(arg = nil)
raise "Illegal State: attempted to modify NoOpHeaderConfig. Create a new config instead."
end

def to_h
{}
end

def dup
self.class.instance
end

def opt_out?
true
end

alias_method :[], :boom
alias_method :[]=, :boom
alias_method :keys, :boom
end

OPT_OUT = NoOpHeaderConfig.instance
SECURE_HEADERS_CONFIG = "secure_headers_request_config".freeze
NONCE_KEY = "secure_headers_content_security_policy_nonce".freeze
HTTPS = "https".freeze
CSP = ContentSecurityPolicy

ALL_HEADER_CLASSES = [
ContentSecurityPolicy,
ContentSecurityPolicyConfig,
ContentSecurityPolicyReportOnlyConfig,
StrictTransportSecurity,
PublicKeyPins,
ReferrerPolicy,
Expand All @@ -36,7 +61,10 @@ module SecureHeaders
XXssProtection
].freeze

ALL_HEADERS_BESIDES_CSP = (ALL_HEADER_CLASSES - [CSP]).freeze
ALL_HEADERS_BESIDES_CSP = (
ALL_HEADER_CLASSES -
[ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig]
).freeze

# Headers set on http requests (excludes STS and HPKP)
HTTP_HEADER_CLASSES =
Expand All @@ -50,13 +78,25 @@ class << self
#
# additions - a hash containing directives. e.g.
# script_src: %w(another-host.com)
def override_content_security_policy_directives(request, additions)
config = config_for(request)
if config.current_csp == OPT_OUT
config.dynamic_csp = {}
def override_content_security_policy_directives(request, additions, target = nil)
config, target = config_and_target(request, target)

if [:both, :enforced].include?(target)
if config.csp.opt_out?
config.csp = ContentSecurityPolicyConfig.new({})
end

config.csp.merge!(additions)
end

if [:both, :report_only].include?(target)
if config.csp_report_only.opt_out?
config.csp_report_only = ContentSecurityPolicyReportOnlyConfig.new({})
end

config.csp_report_only.merge!(additions)
end

config.dynamic_csp = config.current_csp.merge(additions)
override_secure_headers_request_config(request, config)
end

Expand All @@ -66,9 +106,17 @@ def override_content_security_policy_directives(request, additions)
#
# additions - a hash containing directives. e.g.
# script_src: %w(another-host.com)
def append_content_security_policy_directives(request, additions)
config = config_for(request)
config.dynamic_csp = CSP.combine_policies(config.current_csp, additions)
def append_content_security_policy_directives(request, additions, target = nil)
config, target = config_and_target(request, target)

if [:both, :enforced].include?(target) && !config.csp.opt_out?
config.csp.append(additions)
end

if [:both, :report_only].include?(target) && !config.csp_report_only.opt_out?
config.csp_report_only.append(additions)
end

override_secure_headers_request_config(request, config)
end

Expand Down Expand Up @@ -112,12 +160,28 @@ def opt_out_of_all_protection(request)
# returned is meant to be merged into the header value from `@app.call(env)`
# in Rack middleware.
def header_hash_for(request)
config = config_for(request)
unless ContentSecurityPolicy.idempotent_additions?(config.csp, config.current_csp)
config.rebuild_csp_header_cache!(request.user_agent)
config = config_for(request, prevent_dup = true)
headers = config.cached_headers
user_agent = UserAgent.parse(request.user_agent)

if !config.csp.opt_out? && config.csp.modified?
headers = update_cached_csp(config.csp, headers, user_agent)
end

use_cached_headers(config.cached_headers, request)
if !config.csp_report_only.opt_out? && config.csp_report_only.modified?
headers = update_cached_csp(config.csp_report_only, headers, user_agent)
end

header_classes_for(request).each_with_object({}) do |klass, hash|
if header = headers[klass::CONFIG_KEY]
header_name, value = if [ContentSecurityPolicyConfig, ContentSecurityPolicyReportOnlyConfig].include?(klass)
csp_header_for_ua(header, user_agent)
else
header
end
hash[header_name] = value
end
end
end

# Public: specify which named override will be used for this request.
Expand All @@ -138,7 +202,7 @@ def use_secure_headers_override(request, name)
#
# Returns the nonce
def content_security_policy_script_nonce(request)
content_security_policy_nonce(request, CSP::SCRIPT_SRC)
content_security_policy_nonce(request, ContentSecurityPolicy::SCRIPT_SRC)
end

# Public: gets or creates a nonce for CSP.
Expand All @@ -147,33 +211,62 @@ def content_security_policy_script_nonce(request)
#
# Returns the nonce
def content_security_policy_style_nonce(request)
content_security_policy_nonce(request, CSP::STYLE_SRC)
content_security_policy_nonce(request, ContentSecurityPolicy::STYLE_SRC)
end

# Public: Retreives the config for a given header type:
#
# Checks to see if there is an override for this request, then
# Checks to see if a named override is used for this request, then
# Falls back to the global config
def config_for(request)
def config_for(request, prevent_dup = false)
config = request.env[SECURE_HEADERS_CONFIG] ||
Configuration.get(Configuration::DEFAULT_CONFIG)

if config.frozen?

# Global configs are frozen, per-request configs are not. When we're not
# making modifications to the config, prevent_dup ensures we don't dup
# the object unnecessarily. It's not necessarily frozen to begin with.
if config.frozen? && !prevent_dup
config.dup
else
config
end
end

private
TARGETS = [:both, :enforced, :report_only]
def raise_on_unknown_target(target)
unless TARGETS.include?(target)
raise "Unrecognized target: #{target}. Must be [:both, :enforced, :report_only]"
end
end

def config_and_target(request, target)
config = config_for(request)
target = guess_target(config) unless target
raise_on_unknown_target(target)
[config, target]
end

def guess_target(config)
if !config.csp.opt_out? && !config.csp_report_only.opt_out?
:both
elsif !config.csp.opt_out?
:enforced
elsif !config.csp_report_only.opt_out?
:report_only
else
:both
end
end

# Private: gets or creates a nonce for CSP.
#
# Returns the nonce
def content_security_policy_nonce(request, script_or_style)
request.env[NONCE_KEY] ||= SecureRandom.base64(32).chomp
nonce_key = script_or_style == CSP::SCRIPT_SRC ? :script_nonce : :style_nonce
nonce_key = script_or_style == ContentSecurityPolicy::SCRIPT_SRC ? :script_nonce : :style_nonce
append_content_security_policy_directives(request, nonce_key => request.env[NONCE_KEY])
request.env[NONCE_KEY]
end
Expand All @@ -198,48 +291,21 @@ def header_classes_for(request)
end
end

# Private: takes a precomputed hash of headers and returns the Headers
# customized for the request.
#
# Returns a hash of header names / values valid for a given request.
def use_cached_headers(headers, request)
header_classes_for(request).each_with_object({}) do |klass, hash|
if header = headers[klass::CONFIG_KEY]
header_name, value = if klass == CSP
csp_header_for_ua(header, request)
else
header
end
hash[header_name] = value
end
end
def update_cached_csp(config, headers, user_agent)
headers = Configuration.send(:deep_copy, headers)
headers[config.class::CONFIG_KEY] = {}
variation = ContentSecurityPolicy.ua_to_variation(user_agent)
headers[config.class::CONFIG_KEY][variation] = ContentSecurityPolicy.make_header(config, user_agent)
headers
end

# Private: chooses the applicable CSP header for the provided user agent.
#
# headers - a hash of header_config_key => [header_name, header_value]
#
# Returns a CSP [header, value] array
def csp_header_for_ua(headers, request)
headers[CSP.ua_to_variation(UserAgent.parse(request.user_agent))]
end

# Private: optionally build a header with a given configure
#
# klass - corresponding Class for a given header
# config - A string, symbol, or hash config for the header
# user_agent - A string representing the UA (only used for CSP feature sniffing)
#
# Returns a 2 element array [header_name, header_value] or nil if config
# is OPT_OUT
def make_header(klass, header_config, user_agent = nil)
unless header_config == OPT_OUT
if klass == CSP
klass.make_header(header_config, user_agent)
else
klass.make_header(header_config)
end
end
def csp_header_for_ua(headers, user_agent)
headers[ContentSecurityPolicy.ua_to_variation(user_agent)]
end
end

Expand Down
Loading

0 comments on commit 9ee9693

Please sign in to comment.