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.

@@ -0,0 +1,3 @@
1
+ Rails.application.routes.draw do
2
+ post SecureHeaders::ContentSecurityPolicy::FF_CSP_ENDPOINT => "content_security_policy#scribe"
3
+ end
@@ -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