Skip to content
Open
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The gem will automatically apply several headers that are related to security.
- referrer-policy - [Referrer Policy draft](https://w3c.github.io/webappsec-referrer-policy/)
- expect-ct - Only use certificates that are present in the certificate transparency logs. [expect-ct draft specification](https://datatracker.ietf.org/doc/draft-stark-expect-ct/).
- clear-site-data - Clearing browser data for origin. [clear-site-data specification](https://w3c.github.io/webappsec-clear-site-data/).
- reporting-endpoints - Configure endpoints for the W3C Reporting API. [Reporting API specification](https://w3c.github.io/reporting/).

It can also mark all http cookies with the Secure, HttpOnly and SameSite attributes. This is on default but can be turned off by using `config.cookies = SecureHeaders::OPT_OUT`.

Expand Down Expand Up @@ -54,6 +55,9 @@ SecureHeaders::Configuration.default do |config|
config.x_download_options = "noopen"
config.x_permitted_cross_domain_policies = "none"
config.referrer_policy = %w(origin-when-cross-origin strict-origin-when-cross-origin)
config.reporting_endpoints = {
default: "https://report-uri.io/example-reporting"
}
config.csp = {
# "meta" values. these will shape the header, but the values are not included in the header.
preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content.
Expand Down Expand Up @@ -81,7 +85,8 @@ SecureHeaders::Configuration.default do |config|
style_src_attr: %w('unsafe-inline'),
worker_src: %w('self'),
upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/
report_uri: %w(https://report-uri.io/example-csp)
report_to: 'default', # W3C Reporting API endpoint name (modern browsers)
report_uri: %w(https://report-uri.io/example-csp) # Legacy reporting (older browsers)
}
# This is available only from 3.5.0; use the `report_only: true` setting for 3.4.1 and below.
config.csp_report_only = config.csp.merge({
Expand Down
1 change: 1 addition & 0 deletions lib/secure_headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require "secure_headers/headers/referrer_policy"
require "secure_headers/headers/clear_site_data"
require "secure_headers/headers/expect_certificate_transparency"
require "secure_headers/headers/reporting_endpoints"
require "secure_headers/middleware"
require "secure_headers/railtie"
require "secure_headers/view_helper"
Expand Down
1 change: 1 addition & 0 deletions lib/secure_headers/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def deep_copy_if_hash(value)
referrer_policy: ReferrerPolicy,
clear_site_data: ClearSiteData,
expect_certificate_transparency: ExpectCertificateTransparency,
reporting_endpoints: ReportingEndpoints,
csp: ContentSecurityPolicy,
csp_report_only: ContentSecurityPolicy,
cookies: Cookie,
Expand Down
12 changes: 10 additions & 2 deletions lib/secure_headers/headers/content_security_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def value
private

# Private: converts the config object into a string representing a policy.
# Places default-src at the first directive and report-uri as the last. All
# Places default-src at the first directive and report-to/report-uri as the last. All
# others are presented in alphabetical order.
#
# Returns a content security policy header value.
Expand All @@ -59,6 +59,8 @@ def build_value
build_source_list_directive(directive_name)
when :boolean
symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
when :string
build_string_directive(directive_name)
when :sandbox_list
build_sandbox_list_directive(directive_name)
when :media_type_list
Expand All @@ -67,6 +69,11 @@ def build_value
end.compact.join("; ")
end

def build_string_directive(directive)
return unless string_value = @config.directive_value(directive)
[symbol_to_hyphen_case(directive), string_value].join(" ")
end

def build_sandbox_list_directive(directive)
return unless sandbox_list = @config.directive_value(directive)
max_strict_policy = case sandbox_list
Expand Down Expand Up @@ -179,11 +186,12 @@ def append_nonce(source_list, nonce)
end

# Private: return the list of directives,
# starting with default-src and ending with report-uri.
# starting with default-src and ending with report-to and report-uri.
def directives
[
DEFAULT_SRC,
BODY_DIRECTIVES,
REPORT_TO,
REPORT_URI,
].flatten
end
Expand Down
12 changes: 10 additions & 2 deletions lib/secure_headers/headers/policy_management.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def self.included(base)
SANDBOX = :sandbox
SCRIPT_SRC = :script_src
STYLE_SRC = :style_src
REPORT_TO = :report_to
REPORT_URI = :report_uri

DIRECTIVES_1_0 = [
Expand Down Expand Up @@ -87,6 +88,7 @@ def self.included(base)
MANIFEST_SRC,
NAVIGATE_TO,
PREFETCH_SRC,
REPORT_TO,
REQUIRE_SRI_FOR,
WORKER_SRC,
UPGRADE_INSECURE_REQUESTS,
Expand All @@ -110,9 +112,9 @@ def self.included(base)

ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_EXPERIMENTAL).uniq.sort

# Think of default-src and report-uri as the beginning and end respectively,
# Think of default-src as the beginning and report-to/report-uri as the end,
# everything else is in between.
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI]
BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_TO, REPORT_URI]

DIRECTIVE_VALUE_TYPES = {
BASE_URI => :source_list,
Expand All @@ -131,6 +133,7 @@ def self.included(base)
PLUGIN_TYPES => :media_type_list,
REQUIRE_SRI_FOR => :require_sri_for_list,
REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list,
REPORT_TO => :string,
REPORT_URI => :source_list,
PREFETCH_SRC => :source_list,
SANDBOX => :sandbox_list,
Expand Down Expand Up @@ -158,6 +161,7 @@ def self.included(base)
FORM_ACTION,
FRAME_ANCESTORS,
NAVIGATE_TO,
REPORT_TO,
REPORT_URI,
]

Expand Down Expand Up @@ -336,6 +340,10 @@ def validate_directive!(directive, value)
unless boolean?(value)
raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value")
end
when :string
unless value.is_a?(String)
raise ContentSecurityPolicyConfigError.new("#{directive} must be a string. Found #{value.class} value")
end
when :sandbox_list
validate_sandbox_expression!(directive, value)
when :media_type_list
Expand Down
47 changes: 47 additions & 0 deletions lib/secure_headers/headers/reporting_endpoints.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true
module SecureHeaders
class ReportingEndpointsConfigError < StandardError; end

class ReportingEndpoints
HEADER_NAME = "reporting-endpoints".freeze
INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze
INVALID_ENDPOINT_NAME_ERROR = "endpoint names must be strings or symbols.".freeze
INVALID_ENDPOINT_URL_ERROR = "endpoint URLs must be strings.".freeze

class << self
# Public: Generate a Reporting-Endpoints header.
#
# Returns nil if not configured, returns header name and value if
# configured.
def make_header(config, user_agent = nil)
return if config.nil? || config == OPT_OUT

header = new(config)
[HEADER_NAME, header.value]
end

def validate_config!(config)
return if config.nil? || config == OPT_OUT
raise ReportingEndpointsConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a?(Hash)

config.each do |name, url|
unless name.is_a?(String) || name.is_a?(Symbol)
raise ReportingEndpointsConfigError.new(INVALID_ENDPOINT_NAME_ERROR)
end

unless url.is_a?(String)
raise ReportingEndpointsConfigError.new(INVALID_ENDPOINT_URL_ERROR)
end
end
end
end

def initialize(config)
@endpoints = config
end

def value
@endpoints.map { |name, url| "#{name}=\"#{url}\"" }.join(", ")
end
end
end
15 changes: 15 additions & 0 deletions spec/lib/secure_headers/headers/content_security_policy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ module SecureHeaders
expect(csp.value).to eq("default-src https:; report-uri https://example.org")
end

it "includes report-to directive with string value" do
csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'default')
expect(csp.value).to eq("default-src 'self'; script-src 'self'; report-to default")
end

it "includes both report-to and report-uri when both are specified" do
csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'default', report_uri: %w(https://example.org))
expect(csp.value).to eq("default-src 'self'; script-src 'self'; report-to default; report-uri https://example.org")
end

it "positions report-to before report-uri" do
csp = ContentSecurityPolicy.new(default_src: %w('self'), script_src: %w('self'), report_to: 'endpoint', report_uri: %w(/report))
expect(csp.value).to match(/report-to endpoint.*report-uri/)
end

it "does not remove schemes when :preserve_schemes is true" do
csp = ContentSecurityPolicy.new(default_src: %w(https://example.org), preserve_schemes: true)
expect(csp.value).to eq("default-src https://example.org")
Expand Down
56 changes: 56 additions & 0 deletions spec/lib/secure_headers/headers/reporting_endpoints_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true
require "spec_helper"

module SecureHeaders
describe ReportingEndpoints do
specify { expect(ReportingEndpoints.new(default: "https://example.com/reports").value).to eq('default="https://example.com/reports"') }
specify do
config = { default: "https://example.com/reports", csp: "https://example.com/csp" }
header_value = 'default="https://example.com/reports", csp="https://example.com/csp"'
expect(ReportingEndpoints.new(config).value).to eq(header_value)
end
specify do
config = { endpoint1: "https://example.com/1", endpoint2: "https://example.com/2", endpoint3: "https://example.com/3" }
header_value = 'endpoint1="https://example.com/1", endpoint2="https://example.com/2", endpoint3="https://example.com/3"'
expect(ReportingEndpoints.new(config).value).to eq(header_value)
end

context "with an invalid configuration" do
it "raises an exception when configuration isn't a hash" do
expect do
ReportingEndpoints.validate_config!(%w(a))
end.to raise_error(ReportingEndpointsConfigError)
end

it "raises an exception when configuration is a string" do
expect do
ReportingEndpoints.validate_config!("https://example.com")
end.to raise_error(ReportingEndpointsConfigError)
end

it "raises an exception when endpoint name is not a string or symbol" do
expect do
ReportingEndpoints.validate_config!(123 => "https://example.com")
end.to raise_error(ReportingEndpointsConfigError)
end

it "raises an exception when endpoint URL is not a string" do
expect do
ReportingEndpoints.validate_config!(default: 123)
end.to raise_error(ReportingEndpointsConfigError)
end
end

context "with OPT_OUT" do
it "does not produce a header" do
expect(ReportingEndpoints.make_header(OPT_OUT)).to be_nil
end
end

context "with nil config" do
it "does not produce a header" do
expect(ReportingEndpoints.make_header(nil)).to be_nil
end
end
end
end
Loading