secure_headers 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
|