Skip to content

Commit 452c13d

Browse files
committed
Merge pull request #143 from twitter/hpkp
Hpkp support (take 2)
2 parents 1ed9181 + 8df5b97 commit 452c13d

File tree

7 files changed

+229
-4
lines changed

7 files changed

+229
-4
lines changed

.ruby-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
ruby-1.9.3-p484
1+
2.1.6

README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The gem will automatically apply several headers that are related to security.
88
- X-Content-Type-Options - [Prevent content type sniffing](http://msdn.microsoft.com/en-us/library/ie/gg622941\(v=vs.85\).aspx)
99
- X-Download-Options - [Prevent file downloads opening](http://msdn.microsoft.com/en-us/library/ie/jj542450(v=vs.85).aspx)
1010
- X-Permitted-Cross-Domain-Policies - [Restrict Adobe Flash Player's access to data](https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html)
11+
- Public Key Pinning - Pin certificate fingerprints in the browser to prevent man-in-the-middle attacks due to compromised Certificate Authorites. [Public Key Pinnning Specification](https://tools.ietf.org/html/draft-ietf-websec-key-pinning-21)
1112

1213
## Usage
1314

@@ -21,6 +22,7 @@ The following methods are going to be called, unless they are provided in a `ski
2122

2223
* `:set_csp_header`
2324
* `:set_hsts_header`
25+
* `:set_hpkp_header`
2426
* `:set_x_frame_options_header`
2527
* `:set_x_xss_protection_header`
2628
* `:set_x_content_type_options_header`
@@ -51,15 +53,24 @@ This gem makes a few assumptions about how you will use some features. For exam
5153
:img_src => "https:",
5254
:report_uri => '//example.com/uri-directive'
5355
}
56+
config.hpkp = {
57+
:max_age => 60.days.to_i,
58+
:include_subdomains => true,
59+
:report_uri => '//example.com/uri-directive',
60+
:pins => [
61+
{:sha256 => 'abc'},
62+
{:sha256 => '123'}
63+
]
64+
}
5465
end
5566

56-
# and then simply include this in application_controller.rb
67+
# and then include this in application_controller.rb
5768
class ApplicationController < ActionController::Base
5869
ensure_security_headers
5970
end
6071
```
6172

62-
Or simply add it to application controller
73+
Or do the config as a parameter to `ensure_security_headers`
6374

6475
```ruby
6576
ensure_security_headers(
@@ -298,6 +309,26 @@ console.log("will raise an exception if not in script_hashes.yml!")
298309
<% end %>
299310
```
300311

312+
### Public Key Pins
313+
314+
Be aware that pinning error reporting is governed by the same rules as everything else. If you have a pinning failure that tries to report back to the same origin, by definition this will not work.
315+
316+
```
317+
config.hpkp = {
318+
max_age: 60.days.to_i, # max_age is a required parameter
319+
include_subdomains: true, # whether or not to apply pins to subdomains
320+
# Per the spec, SHA256 hashes are the only currently supported format.
321+
pins: [
322+
{sha256: 'b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c'},
323+
{sha256: '73a2c64f9545172c1195efb6616ca5f7afd1df6f245407cafb90de3998a1c97f'}
324+
],
325+
enforce: true, # defaults to false (report-only mode)
326+
report_uri: '//example.com/uri-directive',
327+
app_name: 'example',
328+
tag_report_uri: true
329+
}
330+
```
331+
301332
### Using with Sinatra
302333

303334
Here's an example using SecureHeaders for Sinatra applications:
@@ -321,6 +352,7 @@ require 'secure_headers'
321352
:img_src => "https: data:",
322353
:frame_src => "https: http:.twimg.com http://itunes.apple.com"
323354
}
355+
config.hpkp = false
324356
end
325357

326358
class Donkey < Sinatra::Application

lib/secure_headers.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module Configuration
66
class << self
77
attr_accessor :hsts, :x_frame_options, :x_content_type_options,
88
:x_xss_protection, :csp, :x_download_options, :script_hashes,
9-
:x_permitted_cross_domain_policies
9+
:x_permitted_cross_domain_policies, :hpkp
1010

1111
def configure &block
1212
instance_eval &block
@@ -42,6 +42,7 @@ def ensure_security_headers options = {}
4242
self.secure_headers_options = options
4343
before_filter :prep_script_hash
4444
before_filter :set_hsts_header
45+
before_filter :set_hpkp_header
4546
before_filter :set_x_frame_options_header
4647
before_filter :set_csp_header
4748
before_filter :set_x_xss_protection_header
@@ -61,6 +62,7 @@ module InstanceMethods
6162
def set_security_headers(options = self.class.secure_headers_options)
6263
set_csp_header(request, options[:csp])
6364
set_hsts_header(options[:hsts])
65+
set_hpkp_header(options[:hpkp])
6466
set_x_frame_options_header(options[:x_frame_options])
6567
set_x_xss_protection_header(options[:x_xss_protection])
6668
set_x_content_type_options_header(options[:x_content_type_options])
@@ -136,6 +138,16 @@ def set_hsts_header(options=self.class.secure_headers_options[:hsts])
136138
set_a_header(:hsts, StrictTransportSecurity, options)
137139
end
138140

141+
def set_hpkp_header(options=self.class.secure_headers_options[:hpkp])
142+
return unless request.ssl?
143+
config = self.class.options_for :hpkp, options
144+
145+
return if config == false || config.nil?
146+
147+
hpkp_header = PublicKeyPins.new(config)
148+
set_header(hpkp_header)
149+
end
150+
139151
def set_x_download_options_header(options=self.class.secure_headers_options[:x_download_options])
140152
set_a_header(:x_download_options, XDownloadOptions, options)
141153
end
@@ -168,6 +180,7 @@ def set_header(name_or_header, value=nil)
168180

169181
require "secure_headers/version"
170182
require "secure_headers/header"
183+
require "secure_headers/headers/public_key_pins"
171184
require "secure_headers/headers/content_security_policy"
172185
require "secure_headers/headers/x_frame_options"
173186
require "secure_headers/headers/strict_transport_security"
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
module SecureHeaders
2+
class PublicKeyPinsBuildError < StandardError; end
3+
class PublicKeyPins < Header
4+
module Constants
5+
HPKP_HEADER_NAME = "Public-Key-Pins"
6+
ENV_KEY = 'secure_headers.public_key_pins'
7+
HASH_ALGORITHMS = [:sha256]
8+
DIRECTIVES = [:max_age]
9+
end
10+
class << self
11+
def symbol_to_hyphen_case sym
12+
sym.to_s.gsub('_', '-')
13+
end
14+
end
15+
include Constants
16+
17+
def initialize(config=nil)
18+
@config = validate_config(config)
19+
20+
@pins = @config.fetch(:pins, nil)
21+
@report_uri = @config.fetch(:report_uri, nil)
22+
@app_name = @config.fetch(:app_name, nil)
23+
@enforce = !!@config.fetch(:enforce, nil)
24+
@include_subdomains = !!@config.fetch(:include_subdomains, nil)
25+
@tag_report_uri = !!@config.fetch(:tag_report_uri, nil)
26+
end
27+
28+
def name
29+
base = HPKP_HEADER_NAME
30+
if !@enforce
31+
base += "-Report-Only"
32+
end
33+
base
34+
end
35+
36+
def value
37+
header_value = [
38+
generic_directives,
39+
pin_directives,
40+
report_uri_directive,
41+
subdomain_directive
42+
].compact.join('; ').strip
43+
end
44+
45+
def validate_config(config)
46+
raise PublicKeyPinsBuildError.new("config must be a hash.") unless config.is_a? Hash
47+
48+
if !config[:max_age]
49+
raise PublicKeyPinsBuildError.new("max-age is a required directive.")
50+
elsif config[:max_age].to_s !~ /\A\d+\z/
51+
raise PublicKeyPinsBuildError.new("max-age must be a number.
52+
#{config[:max_age]} was supplied.")
53+
elsif config[:pins] && config[:pins].length < 2
54+
raise PublicKeyPinsBuildError.new("A minimum of 2 pins are required.")
55+
end
56+
57+
config
58+
end
59+
60+
def pin_directives
61+
return nil if @pins.nil?
62+
@pins.collect do |pin|
63+
pin.map do |token, hash|
64+
"pin-#{token}=\"#{hash}\"" if HASH_ALGORITHMS.include?(token)
65+
end
66+
end.join('; ')
67+
end
68+
69+
def generic_directives
70+
DIRECTIVES.collect do |directive_name|
71+
build_directive(directive_name) if @config[directive_name]
72+
end.join('; ')
73+
end
74+
75+
def build_directive(key)
76+
"#{self.class.symbol_to_hyphen_case(key)}=#{@config[key]}"
77+
end
78+
79+
def report_uri_directive
80+
return nil if @report_uri.nil?
81+
82+
if @tag_report_uri
83+
@report_uri = "#{@report_uri}?enforce=#{@enforce}"
84+
@report_uri += "&app_name=#{@app_name}" if @app_name
85+
end
86+
87+
"report-uri=\"#{@report_uri}\""
88+
end
89+
90+
91+
def subdomain_directive
92+
@include_subdomains ? 'includeSubDomains' : nil
93+
end
94+
end
95+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
require 'spec_helper'
2+
3+
module SecureHeaders
4+
describe PublicKeyPins do
5+
specify{ expect(PublicKeyPins.new(:max_age => 1234).name).to eq("Public-Key-Pins-Report-Only") }
6+
specify{ expect(PublicKeyPins.new(:max_age => 1234, :enforce => true).name).to eq("Public-Key-Pins") }
7+
8+
specify { expect(PublicKeyPins.new({:max_age => 1234}).value).to eq("max-age=1234")}
9+
specify { expect(PublicKeyPins.new(:max_age => 1234).value).to eq("max-age=1234")}
10+
specify {
11+
config = {:max_age => 1234, :pins => [{:sha256 => 'base64encodedpin1'}, {:sha256 => 'base64encodedpin2'}]}
12+
header_value = "max-age=1234; pin-sha256=\"base64encodedpin1\"; pin-sha256=\"base64encodedpin2\""
13+
expect(PublicKeyPins.new(config).value).to eq(header_value)
14+
}
15+
16+
context "with an invalid configuration" do
17+
it "raises an exception when max-age is not provided" do
18+
expect {
19+
PublicKeyPins.new(:foo => 'bar')
20+
}.to raise_error(PublicKeyPinsBuildError)
21+
end
22+
23+
it "raises an exception with an invalid max-age" do
24+
expect {
25+
PublicKeyPins.new(:max_age => 'abc123')
26+
}.to raise_error(PublicKeyPinsBuildError)
27+
end
28+
29+
it 'raises an exception with less than 2 pins' do
30+
expect {
31+
config = {:max_age => 1234, :pins => [{:sha256 => 'base64encodedpin'}]}
32+
PublicKeyPins.new(config)
33+
}.to raise_error(PublicKeyPinsBuildError)
34+
end
35+
end
36+
end
37+
end

spec/lib/secure_headers_spec.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def stub_user_agent val
2424

2525
def reset_config
2626
::SecureHeaders::Configuration.configure do |config|
27+
config.hpkp = nil
2728
config.hsts = nil
2829
config.x_frame_options = nil
2930
config.x_content_type_options = nil
@@ -36,6 +37,7 @@ def reset_config
3637

3738
def set_security_headers(subject)
3839
subject.set_csp_header
40+
subject.set_hpkp_header
3941
subject.set_hsts_header
4042
subject.set_x_frame_options_header
4143
subject.set_x_content_type_options_header
@@ -65,6 +67,7 @@ def set_security_headers(subject)
6567
subject.set_csp_header
6668
subject.set_x_frame_options_header
6769
subject.set_hsts_header
70+
subject.set_hpkp_header
6871
subject.set_x_xss_protection_header
6972
subject.set_x_content_type_options_header
7073
subject.set_x_download_options_header
@@ -109,6 +112,17 @@ def set_security_headers(subject)
109112
subject.set_hsts_header({:include_subdomains => true})
110113
end
111114

115+
it "does not set the HPKP header if disabled" do
116+
should_not_assign_header(HPKP_HEADER_NAME)
117+
subject.set_hpkp_header
118+
end
119+
120+
it "does not set the HPKP header if request is over HTTP" do
121+
allow(subject).to receive_message_chain(:request, :ssl?).and_return(false)
122+
should_not_assign_header(HPKP_HEADER_NAME)
123+
subject.set_hpkp_header(:max_age => 1234)
124+
end
125+
112126
it "does not set the CSP header if disabled" do
113127
stub_user_agent(USER_AGENTS[:chrome])
114128
should_not_assign_header(HEADER_NAME)
@@ -130,6 +144,7 @@ def set_security_headers(subject)
130144
it "does not set any headers when disabled" do
131145
::SecureHeaders::Configuration.configure do |config|
132146
config.hsts = false
147+
config.hpkp = false
133148
config.x_frame_options = false
134149
config.x_content_type_options = false
135150
config.x_xss_protection = false
@@ -190,6 +205,38 @@ def set_security_headers(subject)
190205
end
191206
end
192207

208+
describe "#set_public_key_pins" do
209+
it "sets the Public-Key-Pins header" do
210+
should_assign_header(HPKP_HEADER_NAME + "-Report-Only", "max-age=1234")
211+
subject.set_hpkp_header(:max_age => 1234)
212+
end
213+
214+
it "allows you to enforce public key pinning" do
215+
should_assign_header(HPKP_HEADER_NAME, "max-age=1234")
216+
subject.set_hpkp_header(:max_age => 1234, :enforce => true)
217+
end
218+
219+
it "allows you to specific a custom max-age value" do
220+
should_assign_header(HPKP_HEADER_NAME + "-Report-Only", 'max-age=1234')
221+
subject.set_hpkp_header(:max_age => 1234)
222+
end
223+
224+
it "allows you to specify includeSubdomains" do
225+
should_assign_header(HPKP_HEADER_NAME, "max-age=1234; includeSubDomains")
226+
subject.set_hpkp_header(:max_age => 1234, :include_subdomains => true, :enforce => true)
227+
end
228+
229+
it "allows you to specify a report-uri" do
230+
should_assign_header(HPKP_HEADER_NAME, "max-age=1234; report-uri=\"https://foobar.com\"")
231+
subject.set_hpkp_header(:max_age => 1234, :report_uri => "https://foobar.com", :enforce => true)
232+
end
233+
234+
it "allows you to specify a report-uri with app_name" do
235+
should_assign_header(HPKP_HEADER_NAME, "max-age=1234; report-uri=\"https://foobar.com?enforce=true&app_name=my_app\"")
236+
subject.set_hpkp_header(:max_age => 1234, :report_uri => "https://foobar.com", :app_name => "my_app", :tag_report_uri => true, :enforce => true)
237+
end
238+
end
239+
193240
describe "#set_x_xss_protection" do
194241
it "sets the X-XSS-Protection header" do
195242
should_assign_header(X_XSS_PROTECTION_HEADER_NAME, SecureHeaders::XXssProtection::Constants::DEFAULT_VALUE)

spec/spec_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Coveralls.wear!
88
end
99

10+
include ::SecureHeaders::PublicKeyPins::Constants
1011
include ::SecureHeaders::StrictTransportSecurity::Constants
1112
include ::SecureHeaders::ContentSecurityPolicy::Constants
1213
include ::SecureHeaders::XFrameOptions::Constants

0 commit comments

Comments
 (0)