diff --git a/README.md b/README.md index 91f71f22..4394f2d9 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,35 @@ def before_load end ``` +### Using in rack middleware + +The `SecureHeaders::header_hash` generates a hash of all header values, which is useful for merging with rack middleware values. + +```ruby +class MySecureHeaders + include SecureHeaders + def initialize(app) + @app = app + end + + def call(env) + status, headers, response = @app.call(env) + security_headers = if override? + SecureHeaders::header_hash(:csp => false) # uses global config, but overrides CSP config + else + SecureHeaders::header_hash # uses global config + end + [status, headers.merge(security_headers), [response.body]] + end +end + +module Testapp + class Application < Rails::Application + config.middleware.use MySecureHeaders + end +end +``` + ## Similar libraries * Rack [rack-secure_headers](https://github.com/harmoni/rack-secure_headers) diff --git a/lib/secure_headers.rb b/lib/secure_headers.rb index 10ad906c..05cf1922 100644 --- a/lib/secure_headers.rb +++ b/lib/secure_headers.rb @@ -1,7 +1,32 @@ +require "secure_headers/version" +require "secure_headers/header" +require "secure_headers/headers/public_key_pins" +require "secure_headers/headers/content_security_policy" +require "secure_headers/headers/x_frame_options" +require "secure_headers/headers/strict_transport_security" +require "secure_headers/headers/x_xss_protection" +require "secure_headers/headers/x_content_type_options" +require "secure_headers/headers/x_download_options" +require "secure_headers/headers/x_permitted_cross_domain_policies" +require "secure_headers/railtie" +require "secure_headers/hash_helper" +require "secure_headers/view_helper" + module SecureHeaders SCRIPT_HASH_CONFIG_FILE = 'config/script_hashes.yml' HASHES_ENV_KEY = 'secure_headers.script_hashes' + ALL_HEADER_CLASSES = [ + SecureHeaders::ContentSecurityPolicy, + SecureHeaders::StrictTransportSecurity, + SecureHeaders::PublicKeyPins, + SecureHeaders::XContentTypeOptions, + SecureHeaders::XDownloadOptions, + SecureHeaders::XFrameOptions, + SecureHeaders::XPermittedCrossDomainPolicies, + SecureHeaders::XXssProtection + ] + module Configuration class << self attr_accessor :hsts, :x_frame_options, :x_content_type_options, @@ -24,6 +49,27 @@ def append_features(base) include InstanceMethods end end + + def header_hash(options = nil) + ALL_HEADER_CLASSES.inject({}) do |memo, klass| + config = if options.is_a?(Hash) && options[klass::Constants::CONFIG_KEY] + options[klass::Constants::CONFIG_KEY] + else + ::SecureHeaders::Configuration.send(klass::Constants::CONFIG_KEY) + end + + unless klass == SecureHeaders::PublicKeyPins && !config.is_a?(Hash) + header = get_a_header(klass::Constants::CONFIG_KEY, klass, config) + memo[header.name] = header.value + end + memo + end + end + + def get_a_header(name, klass, options) + return if options == false + klass.new(options) + end end module ClassMethods @@ -161,13 +207,10 @@ def secure_header_options_for(type, options) options.nil? ? ::SecureHeaders::Configuration.send(type) : options end - def set_a_header(name, klass, options=nil) - options = secure_header_options_for name, options + options = secure_header_options_for(name, options) return if options == false - - header = klass.new(options) - set_header(header) + set_header(SecureHeaders::get_a_header(name, klass, options)) end def set_header(name_or_header, value=nil) @@ -180,18 +223,3 @@ def set_header(name_or_header, value=nil) end end end - - -require "secure_headers/version" -require "secure_headers/header" -require "secure_headers/headers/public_key_pins" -require "secure_headers/headers/content_security_policy" -require "secure_headers/headers/x_frame_options" -require "secure_headers/headers/strict_transport_security" -require "secure_headers/headers/x_xss_protection" -require "secure_headers/headers/x_content_type_options" -require "secure_headers/headers/x_download_options" -require "secure_headers/headers/x_permitted_cross_domain_policies" -require "secure_headers/railtie" -require "secure_headers/hash_helper" -require "secure_headers/view_helper" diff --git a/lib/secure_headers/headers/content_security_policy.rb b/lib/secure_headers/headers/content_security_policy.rb index b7604fdb..78794bf4 100644 --- a/lib/secure_headers/headers/content_security_policy.rb +++ b/lib/secure_headers/headers/content_security_policy.rb @@ -40,7 +40,9 @@ module Constants SOURCE_DIRECTIVES = DIRECTIVES + NON_DEFAULT_SOURCES ALL_DIRECTIVES = DIRECTIVES + NON_DEFAULT_SOURCES + OTHER + CONFIG_KEY = :csp end + include Constants attr_reader :disable_fill_missing, :ssl_request diff --git a/lib/secure_headers/headers/public_key_pins.rb b/lib/secure_headers/headers/public_key_pins.rb index 2bc2f714..1aa453b2 100644 --- a/lib/secure_headers/headers/public_key_pins.rb +++ b/lib/secure_headers/headers/public_key_pins.rb @@ -6,6 +6,7 @@ module Constants ENV_KEY = 'secure_headers.public_key_pins' HASH_ALGORITHMS = [:sha256] DIRECTIVES = [:max_age] + CONFIG_KEY = :hpkp end class << self def symbol_to_hyphen_case sym diff --git a/lib/secure_headers/headers/strict_transport_security.rb b/lib/secure_headers/headers/strict_transport_security.rb index 40deb573..b5114fcb 100644 --- a/lib/secure_headers/headers/strict_transport_security.rb +++ b/lib/secure_headers/headers/strict_transport_security.rb @@ -8,6 +8,7 @@ module Constants DEFAULT_VALUE = "max-age=" + HSTS_MAX_AGE VALID_STS_HEADER = /\Amax-age=\d+(; includeSubdomains)?(; preload)?\z/i MESSAGE = "The config value supplied for the HSTS header was invalid." + CONFIG_KEY = :hsts end include Constants diff --git a/lib/secure_headers/headers/x_content_type_options.rb b/lib/secure_headers/headers/x_content_type_options.rb index 54bbe888..e832d0d3 100644 --- a/lib/secure_headers/headers/x_content_type_options.rb +++ b/lib/secure_headers/headers/x_content_type_options.rb @@ -5,6 +5,7 @@ class XContentTypeOptions < Header module Constants X_CONTENT_TYPE_OPTIONS_HEADER_NAME = "X-Content-Type-Options" DEFAULT_VALUE = "nosniff" + CONFIG_KEY = :x_content_type_options end include Constants @@ -37,4 +38,4 @@ def validate_config end end end -end \ No newline at end of file +end diff --git a/lib/secure_headers/headers/x_download_options.rb b/lib/secure_headers/headers/x_download_options.rb index 79582f67..77d2401d 100644 --- a/lib/secure_headers/headers/x_download_options.rb +++ b/lib/secure_headers/headers/x_download_options.rb @@ -4,6 +4,7 @@ class XDownloadOptions < Header module Constants XDO_HEADER_NAME = "X-Download-Options" DEFAULT_VALUE = 'noopen' + CONFIG_KEY = :x_download_options end include Constants diff --git a/lib/secure_headers/headers/x_frame_options.rb b/lib/secure_headers/headers/x_frame_options.rb index c816fc2a..2014dba4 100644 --- a/lib/secure_headers/headers/x_frame_options.rb +++ b/lib/secure_headers/headers/x_frame_options.rb @@ -5,6 +5,7 @@ module Constants XFO_HEADER_NAME = "X-Frame-Options" DEFAULT_VALUE = 'SAMEORIGIN' VALID_XFO_HEADER = /\A(SAMEORIGIN\z|DENY\z|ALLOW-FROM[:\s])/i + CONFIG_KEY = :x_frame_options end include Constants diff --git a/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb b/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb index f783897e..b92a62ef 100644 --- a/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +++ b/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb @@ -5,6 +5,7 @@ module Constants XPCDP_HEADER_NAME = "X-Permitted-Cross-Domain-Policies" DEFAULT_VALUE = 'none' VALID_POLICIES = %w(all none master-only by-content-type by-ftp-filename) + CONFIG_KEY = :x_permitted_cross_domain_policies end include Constants diff --git a/lib/secure_headers/headers/x_xss_protection.rb b/lib/secure_headers/headers/x_xss_protection.rb index 7860c0f3..fee7c167 100644 --- a/lib/secure_headers/headers/x_xss_protection.rb +++ b/lib/secure_headers/headers/x_xss_protection.rb @@ -5,6 +5,7 @@ module Constants X_XSS_PROTECTION_HEADER_NAME = 'X-XSS-Protection' DEFAULT_VALUE = "1" VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/i + CONFIG_KEY = :x_xss_protection end include Constants diff --git a/spec/lib/secure_headers_spec.rb b/spec/lib/secure_headers_spec.rb index 6f3d0532..4b8d19f8 100644 --- a/spec/lib/secure_headers_spec.rb +++ b/spec/lib/secure_headers_spec.rb @@ -159,6 +159,56 @@ def set_security_headers(subject) end end + describe "SecureHeaders#header_hash" do + def expect_default_values(hash) + expect(hash[XFO_HEADER_NAME]).to eq(SecureHeaders::XFrameOptions::Constants::DEFAULT_VALUE) + expect(hash[XDO_HEADER_NAME]).to eq(SecureHeaders::XDownloadOptions::Constants::DEFAULT_VALUE) + expect(hash[HSTS_HEADER_NAME]).to eq(SecureHeaders::StrictTransportSecurity::Constants::DEFAULT_VALUE) + expect(hash[X_XSS_PROTECTION_HEADER_NAME]).to eq(SecureHeaders::XXssProtection::Constants::DEFAULT_VALUE) + expect(hash[X_CONTENT_TYPE_OPTIONS_HEADER_NAME]).to eq(SecureHeaders::XContentTypeOptions::Constants::DEFAULT_VALUE) + expect(hash[XPCDP_HEADER_NAME]).to eq(SecureHeaders::XPermittedCrossDomainPolicies::Constants::DEFAULT_VALUE) + 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}) + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'none'; img-src data:;") + expect_default_values(hash) + end + + it "produces a hash with a mix of config values, override values, and default values" do + ::SecureHeaders::Configuration.configure do |config| + config.hsts = { :max_age => '123456'} + config.hpkp = { + :enforce => true, + :max_age => 1000000, + :include_subdomains => true, + :report_uri => '//example.com/uri-directive', + :pins => [ + {:sha256 => 'abc'}, + {:sha256 => '123'} + ] + } + end + + hash = SecureHeaders::header_hash(:csp => {:default_src => 'none', :img_src => "data:", :disable_fill_missing => true}) + ::SecureHeaders::Configuration.configure do |config| + config.hsts = nil + config.hpkp = nil + end + + expect(hash['Content-Security-Policy-Report-Only']).to eq("default-src 'none'; img-src data:;") + expect(hash[XFO_HEADER_NAME]).to eq(SecureHeaders::XFrameOptions::Constants::DEFAULT_VALUE) + expect(hash[HSTS_HEADER_NAME]).to eq("max-age=123456") + expect(hash[HPKP_HEADER_NAME]).to eq(%{max-age=1000000; pin-sha256="abc"; pin-sha256="123"; report-uri="//example.com/uri-directive"; includeSubDomains}) + end + + it "produces a hash of headers with default config" do + hash = SecureHeaders::header_hash + expect(hash['Content-Security-Policy-Report-Only']).to eq(SecureHeaders::ContentSecurityPolicy::Constants::DEFAULT_CSP_HEADER) + expect_default_values(hash) + end + end + describe "#set_x_frame_options_header" do it "sets the X-Frame-Options header" do should_assign_header(XFO_HEADER_NAME, SecureHeaders::XFrameOptions::Constants::DEFAULT_VALUE)