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.

@@ -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