secure_headers 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of secure_headers might be problematic. Click here for more details.
- data/.gitignore +17 -0
- data/.rvmrc +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +13 -0
- data/Guardfile +10 -0
- data/HISTORY.md +22 -0
- data/LICENSE +202 -0
- data/README.md +318 -0
- data/Rakefile +127 -0
- data/app/controllers/content_security_policy_controller.rb +44 -0
- data/config/curl-ca-bundle.crt +5420 -0
- data/config/routes.rb +3 -0
- data/lib/secure_headers.rb +115 -0
- data/lib/secure_headers/headers/content_security_policy.rb +300 -0
- data/lib/secure_headers/headers/strict_transport_security.rb +53 -0
- data/lib/secure_headers/headers/x_content_type_options.rb +40 -0
- data/lib/secure_headers/headers/x_frame_options.rb +40 -0
- data/lib/secure_headers/headers/x_xss_protection.rb +54 -0
- data/lib/secure_headers/railtie.rb +37 -0
- data/lib/secure_headers/version.rb +3 -0
- data/secure-headers.gemspec +23 -0
- data/spec/controllers/content_security_policy_controller_spec.rb +74 -0
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +382 -0
- data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +66 -0
- data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +35 -0
- data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +38 -0
- data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +41 -0
- data/spec/lib/secure_headers_spec.rb +252 -0
- data/spec/spec_helper.rb +25 -0
- metadata +116 -0
data/config/routes.rb
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
module SecureHeaders
|
2
|
+
module Configuration
|
3
|
+
class << self
|
4
|
+
attr_accessor :hsts, :x_frame_options, :x_content_type_options,
|
5
|
+
:x_xss_protection, :csp
|
6
|
+
|
7
|
+
def configure &block
|
8
|
+
instance_eval &block
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
class << self
|
14
|
+
def append_features(base)
|
15
|
+
base.module_eval do
|
16
|
+
@@secure_headers_options = nil
|
17
|
+
|
18
|
+
extend ClassMethods
|
19
|
+
include InstanceMethods
|
20
|
+
|
21
|
+
# jank?
|
22
|
+
def self.secure_headers_options=(opts)
|
23
|
+
@@secure_headers_options = opts
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.secure_headers_options
|
27
|
+
@@secure_headers_options
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
module ClassMethods
|
34
|
+
def ensure_security_headers options = {}, *args
|
35
|
+
self.secure_headers_options = options
|
36
|
+
before_filter :set_security_headers
|
37
|
+
end
|
38
|
+
|
39
|
+
# we can't use ||= because I'm overloading false => disable, nil => default
|
40
|
+
# both of which trigger the conditional assignment
|
41
|
+
def options_for(type, options)
|
42
|
+
options.nil? ? ::SecureHeaders::Configuration.send(type) : options
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
module InstanceMethods
|
47
|
+
def set_security_headers(options = self.class.secure_headers_options || {})
|
48
|
+
brwsr = Brwsr::Browser.new(:ua => request.env['HTTP_USER_AGENT'])
|
49
|
+
set_hsts_header(options[:hsts]) if request.ssl?
|
50
|
+
set_x_frame_options_header(options[:x_frame_options])
|
51
|
+
set_csp_header(request, options[:csp]) unless broken_implementation?(brwsr)
|
52
|
+
set_x_xss_protection_header(options[:x_xss_protection])
|
53
|
+
if brwsr.ie?
|
54
|
+
set_x_content_type_options_header(options[:x_content_type_options])
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def set_csp_header(request, options=nil)
|
59
|
+
options = self.class.options_for :csp, options
|
60
|
+
return if options == false
|
61
|
+
|
62
|
+
header = ContentSecurityPolicy.new(request, options)
|
63
|
+
set_header(header.name, header.value)
|
64
|
+
if options && options[:experimental] && options[:enforce]
|
65
|
+
header = ContentSecurityPolicy.new(request, options, :experimental => true)
|
66
|
+
set_header(header.name, header.value)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def set_a_header(name, klass, options=nil)
|
71
|
+
options = self.class.options_for name, options
|
72
|
+
return if options == false
|
73
|
+
|
74
|
+
header = klass.new(options)
|
75
|
+
set_header(header.name, header.value)
|
76
|
+
end
|
77
|
+
|
78
|
+
def set_x_frame_options_header(options=nil)
|
79
|
+
set_a_header(:x_frame_options, XFrameOptions, options)
|
80
|
+
end
|
81
|
+
|
82
|
+
def set_x_content_type_options_header(options=nil)
|
83
|
+
set_a_header(:x_content_type_options, XContentTypeOptions, options)
|
84
|
+
end
|
85
|
+
|
86
|
+
def set_x_xss_protection_header(options=nil)
|
87
|
+
set_a_header(:x_xss_protection, XXssProtection, options)
|
88
|
+
end
|
89
|
+
|
90
|
+
def set_hsts_header(options=nil)
|
91
|
+
set_a_header(:hsts, StrictTransportSecurity, options)
|
92
|
+
end
|
93
|
+
|
94
|
+
def set_header(name, value)
|
95
|
+
response.headers[name] = value
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def broken_implementation?(browser)
|
101
|
+
#IOS 5 sometimes refuses to load external resources even when whitelisted with CSP
|
102
|
+
return browser.ios5?
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
|
108
|
+
require "secure_headers/version"
|
109
|
+
require "secure_headers/headers/content_security_policy"
|
110
|
+
require "secure_headers/headers/x_frame_options"
|
111
|
+
require "secure_headers/headers/strict_transport_security"
|
112
|
+
require "secure_headers/headers/x_xss_protection"
|
113
|
+
require "secure_headers/headers/x_content_type_options"
|
114
|
+
require "secure_headers/railtie"
|
115
|
+
require "brwsr"
|
@@ -0,0 +1,300 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'brwsr'
|
3
|
+
|
4
|
+
module SecureHeaders
|
5
|
+
class ContentSecurityPolicyBuildError < StandardError; end
|
6
|
+
class ContentSecurityPolicy
|
7
|
+
module Constants
|
8
|
+
WEBKIT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https://* about: javascript:; img-src chrome-extension:"
|
9
|
+
FIREFOX_CSP_HEADER = "options eval-script inline-script; allow https://* data:; frame-src https://* about: javascript:; img-src chrome-extension:"
|
10
|
+
|
11
|
+
FIREFOX_CSP_HEADER_NAME = 'X-Content-Security-Policy'
|
12
|
+
WEBKIT_CSP_HEADER_NAME = 'X-WebKit-CSP'
|
13
|
+
STANDARD_HEADER_NAME = "Content-Security-Policy"
|
14
|
+
|
15
|
+
FF_CSP_ENDPOINT = "/content_security_policy/forward_report"
|
16
|
+
WEBKIT_DIRECTIVES = DIRECTIVES = [:default_src, :script_src, :frame_src, :style_src, :img_src, :media_src, :font_src, :object_src, :connect_src]
|
17
|
+
FIREFOX_DIRECTIVES = DIRECTIVES + [:xhr_src, :frame_ancestors] - [:connect_src]
|
18
|
+
META = [:enforce, :http_additions, :disable_chrome_extension, :disable_fill_missing, :forward_endpoint]
|
19
|
+
end
|
20
|
+
include Constants
|
21
|
+
|
22
|
+
META.each do |meta|
|
23
|
+
attr_accessor meta
|
24
|
+
end
|
25
|
+
attr_reader :browser, :ssl_request, :report_uri, :request_uri
|
26
|
+
|
27
|
+
alias :enforce? :enforce
|
28
|
+
alias :disable_chrome_extension? :disable_chrome_extension
|
29
|
+
alias :disable_fill_missing? :disable_fill_missing
|
30
|
+
alias :ssl_request? :ssl_request
|
31
|
+
|
32
|
+
|
33
|
+
def initialize(request=nil, config=nil, options={})
|
34
|
+
@experimental = !!options.delete(:experimental)
|
35
|
+
if config
|
36
|
+
configure request, config
|
37
|
+
elsif request
|
38
|
+
parse_request request
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def configure request, opts
|
43
|
+
@config = opts.dup
|
44
|
+
|
45
|
+
experimental_config = @config.delete(:experimental)
|
46
|
+
if @experimental && experimental_config
|
47
|
+
@config[:http_additions] = experimental_config[:http_additions]
|
48
|
+
@config.merge!(experimental_config)
|
49
|
+
end
|
50
|
+
|
51
|
+
parse_request request
|
52
|
+
META.each do |meta|
|
53
|
+
self.send(meta.to_s + "=", @config.delete(meta))
|
54
|
+
end
|
55
|
+
|
56
|
+
@report_uri = @config.delete(:report_uri)
|
57
|
+
|
58
|
+
normalize_csp_options
|
59
|
+
normalize_reporting_endpoint
|
60
|
+
filter_unsupported_directives
|
61
|
+
end
|
62
|
+
|
63
|
+
def name
|
64
|
+
base = if browser.ie?
|
65
|
+
STANDARD_HEADER_NAME
|
66
|
+
elsif browser.firefox?
|
67
|
+
# can't use supports_standard because FF18 does not support this part of the standard.
|
68
|
+
FIREFOX_CSP_HEADER_NAME
|
69
|
+
else
|
70
|
+
WEBKIT_CSP_HEADER_NAME
|
71
|
+
end
|
72
|
+
|
73
|
+
if !enforce || @experimental
|
74
|
+
base += "-Report-Only"
|
75
|
+
end
|
76
|
+
base
|
77
|
+
end
|
78
|
+
|
79
|
+
def value
|
80
|
+
return @config if @config.is_a?(String)
|
81
|
+
if @config.nil?
|
82
|
+
return supports_standard? ? WEBKIT_CSP_HEADER : FIREFOX_CSP_HEADER
|
83
|
+
end
|
84
|
+
|
85
|
+
build_value
|
86
|
+
end
|
87
|
+
|
88
|
+
def directives
|
89
|
+
# can't use supports_standard because FF18 does not support this part of the standard.
|
90
|
+
browser.firefox? ? FIREFOX_DIRECTIVES : WEBKIT_DIRECTIVES
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def build_value
|
96
|
+
fill_directives unless disable_fill_missing?
|
97
|
+
add_missing_chrome_extension_values unless disable_chrome_extension?
|
98
|
+
append_http_additions unless ssl_request?
|
99
|
+
|
100
|
+
header_value = build_impl_specific_directives
|
101
|
+
header_value += generic_directives(@config)
|
102
|
+
header_value += report_uri_directive(@report_uri)
|
103
|
+
|
104
|
+
#store the value for next time
|
105
|
+
@config = header_value
|
106
|
+
header_value.strip
|
107
|
+
rescue StandardError => e
|
108
|
+
raise ContentSecurityPolicyBuildError.new("Couldn't build CSP header :( #{e}")
|
109
|
+
end
|
110
|
+
|
111
|
+
def fill_directives
|
112
|
+
return unless @config[:default_src]
|
113
|
+
|
114
|
+
default = @config[:default_src]
|
115
|
+
directives.each do |directive|
|
116
|
+
unless @config[directive]
|
117
|
+
@config[directive] = default
|
118
|
+
end
|
119
|
+
end
|
120
|
+
@config
|
121
|
+
end
|
122
|
+
|
123
|
+
def add_missing_chrome_extension_values
|
124
|
+
directives.each do |directive|
|
125
|
+
next unless @config[directive]
|
126
|
+
if !@config[directive].include?('chrome-extension:')
|
127
|
+
@config[directive] << 'chrome-extension:'
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def append_http_additions
|
133
|
+
return unless http_additions
|
134
|
+
|
135
|
+
http_additions.each do |k, v|
|
136
|
+
@config[k] ||= []
|
137
|
+
@config[k] << v
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def normalize_csp_options
|
142
|
+
@config.each do |k,v|
|
143
|
+
@config[k] = v.split if v.is_a? String
|
144
|
+
@config[k] = @config[k].map do |val|
|
145
|
+
translate_dir_value(val)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def filter_unsupported_directives
|
151
|
+
if browser.firefox?
|
152
|
+
# can't use supports_standard because FF18 does not support this part of the standard.
|
153
|
+
@config[:xhr_src] = @config.delete(:connect_src) if @config[:connect_src]
|
154
|
+
else
|
155
|
+
@config.delete(:frame_ancestors)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# translates 'inline','self', 'none' and 'eval' to their respective impl-specific values.
|
160
|
+
def translate_dir_value val
|
161
|
+
if %w{inline eval}.include?(val)
|
162
|
+
translate_inline_or_eval(val)
|
163
|
+
# self/none are special sources/src-dir-values and need to be quoted in chrome
|
164
|
+
elsif %{self none}.include?(val)
|
165
|
+
"'#{val}'"
|
166
|
+
else
|
167
|
+
val
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# inline/eval => impl-specific values
|
172
|
+
def translate_inline_or_eval val
|
173
|
+
# can't use supports_standard because FF18 does not support this part of the standard.
|
174
|
+
if browser.firefox?
|
175
|
+
val == 'inline' ? 'inline-script' : 'eval-script'
|
176
|
+
else
|
177
|
+
val == 'inline' ? "'unsafe-inline'" : "'unsafe-eval'"
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# if we have a forwarding endpoint setup and we are not on the same origin as our report_uri
|
182
|
+
# or only a path was supplied (in which case we assume cross-host)
|
183
|
+
# we need to forward the request for Firefox.
|
184
|
+
def normalize_reporting_endpoint
|
185
|
+
if browser.firefox? && (!same_origin? || URI.parse(report_uri).host.nil?)
|
186
|
+
if forward_endpoint
|
187
|
+
@report_uri = FF_CSP_ENDPOINT
|
188
|
+
else
|
189
|
+
@report_uri = nil
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def supports_standard?
|
195
|
+
!browser.firefox? || (browser.firefox? && browser.version.to_i >= 18)
|
196
|
+
end
|
197
|
+
|
198
|
+
def build_impl_specific_directives
|
199
|
+
header_value = ""
|
200
|
+
default = expect_directive_value(:default_src)
|
201
|
+
# firefox 18 still requires the use of the options value, but can substitute default-src for allow
|
202
|
+
if browser.firefox?
|
203
|
+
header_value += build_firefox_specific_preamble(default) || ''
|
204
|
+
else
|
205
|
+
header_value += "default-src #{default.join(" ")}; " if default.any?
|
206
|
+
end
|
207
|
+
|
208
|
+
header_value
|
209
|
+
end
|
210
|
+
|
211
|
+
def build_firefox_specific_preamble(default_src_value)
|
212
|
+
header_value = ''
|
213
|
+
if supports_standard?
|
214
|
+
header_value += "default-src #{default_src_value.join(" ")}; " if default_src_value.any?
|
215
|
+
elsif default_src_value
|
216
|
+
header_value += "allow #{default_src_value.join(" ")}; " if default_src_value.any?
|
217
|
+
end
|
218
|
+
|
219
|
+
options_directive = build_options_directive
|
220
|
+
header_value += "options #{options_directive.join(" ")}; " if options_directive.any?
|
221
|
+
header_value
|
222
|
+
end
|
223
|
+
|
224
|
+
def expect_directive_value key
|
225
|
+
@config.delete(key) {|k| raise ContentSecurityPolicyBuildError.new("Expected to find #{k} directive value")}
|
226
|
+
end
|
227
|
+
|
228
|
+
# moves inline/eval values from script-src to options
|
229
|
+
# discards those values in the style-src directive
|
230
|
+
def build_options_directive
|
231
|
+
options_directive = []
|
232
|
+
@config.each do |directive, val|
|
233
|
+
next if val.is_a?(String)
|
234
|
+
new_val = []
|
235
|
+
val.each do |token|
|
236
|
+
if ['inline-script', 'eval-script'].include?(token)
|
237
|
+
# Firefox does not support blocking inline styles ATM
|
238
|
+
# https://bugzilla.mozilla.org/show_bug.cgi?id=763879
|
239
|
+
unless directive?(directive, "style_src") || options_directive.include?(token)
|
240
|
+
options_directive << token
|
241
|
+
end
|
242
|
+
else
|
243
|
+
new_val << token
|
244
|
+
end
|
245
|
+
end
|
246
|
+
@config[directive] = new_val
|
247
|
+
end
|
248
|
+
|
249
|
+
options_directive
|
250
|
+
end
|
251
|
+
|
252
|
+
def same_origin?
|
253
|
+
return if report_uri.nil?
|
254
|
+
|
255
|
+
origin = URI.parse(request_uri)
|
256
|
+
uri = URI.parse(report_uri)
|
257
|
+
uri.host == origin.host && origin.port == uri.port && origin.scheme == uri.scheme
|
258
|
+
end
|
259
|
+
|
260
|
+
def directive? val, name
|
261
|
+
val.to_s.casecmp(name) == 0
|
262
|
+
end
|
263
|
+
|
264
|
+
def report_uri_directive(report_uri)
|
265
|
+
report_uri.nil? ? '' : "report-uri #{report_uri};"
|
266
|
+
end
|
267
|
+
|
268
|
+
|
269
|
+
def generic_directives(config)
|
270
|
+
header_value = ''
|
271
|
+
if config[:img_src]
|
272
|
+
config[:img_src] = config[:img_src] + ['data:'] unless config[:img_src].include?('data:')
|
273
|
+
else
|
274
|
+
config[:img_src] = ['data:']
|
275
|
+
end
|
276
|
+
|
277
|
+
config.keys.sort_by{|k| k.to_s}.each do |k| # ensure consistent ordering
|
278
|
+
header_value += "#{symbol_to_hyphen_case(k)} #{config[k].join(" ")}; "
|
279
|
+
end
|
280
|
+
|
281
|
+
header_value
|
282
|
+
end
|
283
|
+
|
284
|
+
def symbol_to_hyphen_case sym
|
285
|
+
sym.to_s.gsub('_', '-')
|
286
|
+
end
|
287
|
+
|
288
|
+
def parse_request request
|
289
|
+
@browser = Brwsr::Browser.new(:ua => request.env['HTTP_USER_AGENT'])
|
290
|
+
@ssl_request = request.ssl?
|
291
|
+
@request_uri = if request.respond_to?(:original_url)
|
292
|
+
# rails 3.1+
|
293
|
+
request.original_url
|
294
|
+
else
|
295
|
+
# rails 2/3.0
|
296
|
+
request.url
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module SecureHeaders
|
2
|
+
class STSBuildError < StandardError; end
|
3
|
+
|
4
|
+
class StrictTransportSecurity
|
5
|
+
module Constants
|
6
|
+
HSTS_HEADER_NAME = 'Strict-Transport-Security'
|
7
|
+
HSTS_MAX_AGE = "631138519"
|
8
|
+
DEFAULT_VALUE = "max-age=" + HSTS_MAX_AGE
|
9
|
+
VALID_STS_HEADER = /\Amax-age=\d+(; includeSubdomains)?\z/i
|
10
|
+
MESSAGE = "The config value supplied for the HSTS header was invalid."
|
11
|
+
end
|
12
|
+
include Constants
|
13
|
+
|
14
|
+
def initialize(config = nil)
|
15
|
+
@config = config
|
16
|
+
validate_config unless @config.nil?
|
17
|
+
end
|
18
|
+
|
19
|
+
def name
|
20
|
+
return HSTS_HEADER_NAME
|
21
|
+
end
|
22
|
+
|
23
|
+
def value
|
24
|
+
case @config
|
25
|
+
when String
|
26
|
+
return @config
|
27
|
+
when NilClass
|
28
|
+
return DEFAULT_VALUE
|
29
|
+
end
|
30
|
+
|
31
|
+
max_age = @config.fetch(:max_age, HSTS_MAX_AGE)
|
32
|
+
value = "max-age=" + max_age
|
33
|
+
value += "; includeSubdomains" if @config[:include_subdomains]
|
34
|
+
|
35
|
+
value
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def validate_config
|
41
|
+
if @config.is_a? Hash
|
42
|
+
if !@config[:max_age]
|
43
|
+
raise STSBuildError.new("No max-age was supplied.")
|
44
|
+
elsif @config[:max_age] !~ /\A\d+\z/
|
45
|
+
raise STSBuildError.new("max-age must be a number. #{@config[:max_age]} was supplied.")
|
46
|
+
end
|
47
|
+
else
|
48
|
+
@config = @config.to_s
|
49
|
+
raise STSBuildError.new(MESSAGE) unless @config =~ VALID_STS_HEADER
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|