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

Setting two CSP headers, abstract out dynamic pieces #281

Merged
merged 32 commits into from
Sep 9, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5264670
add report-only functionality
oreoshake Jun 28, 2016
2e01a7f
wip
oreoshake Jul 8, 2016
7f581bc
get csp tests passing
oreoshake Jul 16, 2016
72a74cc
wip'
oreoshake Jul 18, 2016
388a12b
Merge branch 'master' into dynamic-csp-config-abstraction
oreoshake Jul 26, 2016
48f9200
initial work for making csp config an actual class
oreoshake Jul 26, 2016
e9fa410
wip
oreoshake Jul 27, 2016
89cac3d
Merge branch 'master' into dynamic-csp-config-abstraction
oreoshake Aug 8, 2016
27f2743
base spec is passing
oreoshake Aug 8, 2016
dbea8c6
specs for the configuration class now pass
oreoshake Aug 9, 2016
893d6e9
get append/override working
oreoshake Aug 9, 2016
40313ce
add tests for overriding with two headers
oreoshake Aug 9, 2016
d19a551
don't specify the target since that's not the point of the test
oreoshake Aug 9, 2016
c7a57f3
ensure that config objects are only dup'd when necessary
oreoshake Aug 17, 2016
92dd4e5
add tests for inferring which policy to modify
oreoshake Aug 17, 2016
60103a1
replace some == OPT_OUT with #opt_out?
oreoshake Aug 17, 2016
a77ce14
remove #idempotent_additions? because it's irrelevent now
oreoshake Aug 17, 2016
df96310
very minor cleanup
oreoshake Aug 17, 2016
2a9021c
update docs to show csp/cspro config
oreoshake Aug 17, 2016
3afd1dd
use #opt_out? helper in another location
oreoshake Aug 17, 2016
8c1c019
use #opt_out? helper in another location again
oreoshake Aug 17, 2016
7536cae
get rid of even more != OPT_OUT
oreoshake Aug 17, 2016
af0c26c
remove last bits of == OPT_OUT
oreoshake Aug 17, 2016
1e91f7e
further separate CSP from CSPRO
oreoshake Aug 17, 2016
9112c62
remove CSP/CSPRO shortcut constants
oreoshake Aug 17, 2016
6d9d76a
remove unnecessary special handling of CSP/CSPRO now that they are se…
oreoshake Aug 17, 2016
1b80f85
inline method so we're not creating UserAgent objects everywhere
oreoshake Aug 18, 2016
8e14907
don't modify the original config object, removing the need to dup it
oreoshake Aug 18, 2016
dc1bbac
ensure that the deprecated way of using the report_only header still …
oreoshake Aug 18, 2016
7600b90
add guard ensuring people aren't setting csp_report_only with report_…
oreoshake Aug 18, 2016
3225ed6
infer default policies when incomplete CSP configuration is supplied.
oreoshake Aug 18, 2016
9a3c729
Merge branch 'master' into dynamic-csp-config-abstraction
oreoshake Sep 9, 2016
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
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