secure_headers 3.0.3 → 4.0.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.
- checksums.yaml +4 -4
- data/.github/ISSUE_TEMPLATE.md +41 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +20 -0
- data/.gitignore +0 -9
- data/.rspec +2 -0
- data/.rubocop.yml +3 -0
- data/.ruby-version +1 -1
- data/.travis.yml +17 -5
- data/CHANGELOG.md +287 -0
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +41 -0
- data/Gemfile +13 -5
- data/Guardfile +1 -0
- data/LICENSE +4 -199
- data/README.md +85 -265
- data/Rakefile +22 -18
- data/docs/HPKP.md +17 -0
- data/docs/cookies.md +64 -0
- data/docs/hashes.md +64 -0
- data/docs/named_overrides_and_appends.md +107 -0
- data/docs/per_action_configuration.md +133 -0
- data/docs/sinatra.md +25 -0
- data/lib/secure_headers/configuration.rb +188 -59
- data/lib/secure_headers/hash_helper.rb +11 -0
- data/lib/secure_headers/headers/clear_site_data.rb +55 -0
- data/lib/secure_headers/headers/content_security_policy.rb +136 -325
- data/lib/secure_headers/headers/content_security_policy_config.rb +162 -0
- data/lib/secure_headers/headers/cookie.rb +144 -0
- data/lib/secure_headers/headers/expect_certificate_transparency.rb +70 -0
- data/lib/secure_headers/headers/policy_management.rb +410 -0
- data/lib/secure_headers/headers/public_key_pins.rb +5 -4
- data/lib/secure_headers/headers/referrer_policy.rb +37 -0
- data/lib/secure_headers/headers/strict_transport_security.rb +2 -1
- data/lib/secure_headers/headers/x_content_type_options.rb +3 -2
- data/lib/secure_headers/headers/x_download_options.rb +3 -2
- data/lib/secure_headers/headers/x_frame_options.rb +2 -1
- data/lib/secure_headers/headers/x_permitted_cross_domain_policies.rb +3 -2
- data/lib/secure_headers/headers/x_xss_protection.rb +2 -1
- data/lib/secure_headers/middleware.rb +44 -0
- data/lib/secure_headers/railtie.rb +11 -6
- data/lib/secure_headers/utils/cookies_config.rb +95 -0
- data/lib/secure_headers/view_helper.rb +76 -5
- data/lib/secure_headers.rb +159 -99
- data/lib/tasks/tasks.rake +83 -0
- data/secure_headers.gemspec +13 -3
- data/spec/lib/secure_headers/configuration_spec.rb +24 -9
- data/spec/lib/secure_headers/headers/clear_site_data_spec.rb +87 -0
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +88 -189
- data/spec/lib/secure_headers/headers/cookie_spec.rb +182 -0
- data/spec/lib/secure_headers/headers/expect_certificate_spec.rb +42 -0
- data/spec/lib/secure_headers/headers/policy_management_spec.rb +228 -0
- data/spec/lib/secure_headers/headers/public_key_pins_spec.rb +7 -6
- data/spec/lib/secure_headers/headers/referrer_policy_spec.rb +73 -0
- data/spec/lib/secure_headers/headers/strict_transport_security_spec.rb +6 -5
- data/spec/lib/secure_headers/headers/x_content_type_options_spec.rb +2 -1
- data/spec/lib/secure_headers/headers/x_download_options_spec.rb +3 -2
- data/spec/lib/secure_headers/headers/x_frame_options_spec.rb +2 -1
- data/spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb +4 -3
- data/spec/lib/secure_headers/headers/x_xss_protection_spec.rb +4 -3
- data/spec/lib/secure_headers/middleware_spec.rb +96 -6
- data/spec/lib/secure_headers/view_helpers_spec.rb +138 -0
- data/spec/lib/secure_headers_spec.rb +407 -58
- data/spec/spec_helper.rb +19 -12
- data/upgrading-to-3-0.md +13 -9
- data/upgrading-to-4-0.md +55 -0
- metadata +45 -9
- data/lib/secure_headers/padrino.rb +0 -13
- data/travis.sh +0 -10
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "yaml"
|
|
3
|
+
|
|
1
4
|
module SecureHeaders
|
|
2
5
|
class Configuration
|
|
3
6
|
DEFAULT_CONFIG = :default
|
|
4
7
|
NOOP_CONFIGURATION = "secure_headers_noop_config"
|
|
5
8
|
class NotYetConfiguredError < StandardError; end
|
|
9
|
+
class IllegalPolicyModificationError < StandardError; end
|
|
6
10
|
class << self
|
|
7
11
|
# Public: Set the global default configuration.
|
|
8
12
|
#
|
|
@@ -23,12 +27,12 @@ module SecureHeaders
|
|
|
23
27
|
# if no value is supplied.
|
|
24
28
|
#
|
|
25
29
|
# Returns: the newly created config
|
|
26
|
-
def override(name, base = DEFAULT_CONFIG)
|
|
30
|
+
def override(name, base = DEFAULT_CONFIG, &block)
|
|
27
31
|
unless get(base)
|
|
28
32
|
raise NotYetConfiguredError, "#{base} policy not yet supplied"
|
|
29
33
|
end
|
|
30
34
|
override = @configurations[base].dup
|
|
31
|
-
|
|
35
|
+
override.instance_eval(&block) if block_given?
|
|
32
36
|
add_configuration(name, override)
|
|
33
37
|
end
|
|
34
38
|
|
|
@@ -43,16 +47,15 @@ module SecureHeaders
|
|
|
43
47
|
@configurations[name]
|
|
44
48
|
end
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
end
|
|
50
|
+
def named_appends(name)
|
|
51
|
+
@appends ||= {}
|
|
52
|
+
@appends[name]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def named_append(name, target = nil, &block)
|
|
56
|
+
@appends ||= {}
|
|
57
|
+
raise "Provide a configuration block" unless block_given?
|
|
58
|
+
@appends[name] = block
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
private
|
|
@@ -70,6 +73,7 @@ module SecureHeaders
|
|
|
70
73
|
config.validate_config!
|
|
71
74
|
@configurations ||= {}
|
|
72
75
|
config.send(:cache_headers!)
|
|
76
|
+
config.send(:cache_hpkp_report_host)
|
|
73
77
|
config.freeze
|
|
74
78
|
@configurations[name] = config
|
|
75
79
|
end
|
|
@@ -86,17 +90,68 @@ module SecureHeaders
|
|
|
86
90
|
|
|
87
91
|
add_configuration(NOOP_CONFIGURATION, noop_config)
|
|
88
92
|
end
|
|
93
|
+
|
|
94
|
+
# Public: perform a basic deep dup. The shallow copy provided by dup/clone
|
|
95
|
+
# can lead to modifying parent objects.
|
|
96
|
+
def deep_copy(config)
|
|
97
|
+
return unless config
|
|
98
|
+
config.each_with_object({}) do |(key, value), hash|
|
|
99
|
+
hash[key] = if value.is_a?(Array)
|
|
100
|
+
value.dup
|
|
101
|
+
else
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Private: convenience method purely DRY things up. The value may not be a
|
|
108
|
+
# hash (e.g. OPT_OUT, nil)
|
|
109
|
+
def deep_copy_if_hash(value)
|
|
110
|
+
if value.is_a?(Hash)
|
|
111
|
+
deep_copy(value)
|
|
112
|
+
else
|
|
113
|
+
value
|
|
114
|
+
end
|
|
115
|
+
end
|
|
89
116
|
end
|
|
90
117
|
|
|
91
|
-
|
|
92
|
-
:x_xss_protection, :
|
|
93
|
-
:
|
|
94
|
-
|
|
118
|
+
attr_writer :hsts, :x_frame_options, :x_content_type_options,
|
|
119
|
+
:x_xss_protection, :x_download_options, :x_permitted_cross_domain_policies,
|
|
120
|
+
:referrer_policy, :clear_site_data, :expect_certificate_transparency
|
|
121
|
+
|
|
122
|
+
attr_reader :cached_headers, :csp, :cookies, :csp_report_only, :hpkp, :hpkp_report_host
|
|
123
|
+
|
|
124
|
+
@script_hashes = nil
|
|
125
|
+
@style_hashes = nil
|
|
126
|
+
|
|
127
|
+
HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml"
|
|
128
|
+
if File.exist?(HASH_CONFIG_FILE)
|
|
129
|
+
config = YAML.safe_load(File.open(HASH_CONFIG_FILE))
|
|
130
|
+
@script_hashes = config["scripts"]
|
|
131
|
+
@style_hashes = config["styles"]
|
|
132
|
+
end
|
|
95
133
|
|
|
96
134
|
def initialize(&block)
|
|
135
|
+
@cookies = nil
|
|
136
|
+
@clear_site_data = nil
|
|
137
|
+
@csp = nil
|
|
138
|
+
@csp_report_only = nil
|
|
139
|
+
@hpkp_report_host = nil
|
|
140
|
+
@hpkp = nil
|
|
141
|
+
@hsts = nil
|
|
142
|
+
@x_content_type_options = nil
|
|
143
|
+
@x_download_options = nil
|
|
144
|
+
@x_frame_options = nil
|
|
145
|
+
@x_permitted_cross_domain_policies = nil
|
|
146
|
+
@x_xss_protection = nil
|
|
147
|
+
@expect_certificate_transparency = nil
|
|
148
|
+
|
|
97
149
|
self.hpkp = OPT_OUT
|
|
98
|
-
self.
|
|
99
|
-
|
|
150
|
+
self.referrer_policy = OPT_OUT
|
|
151
|
+
self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT)
|
|
152
|
+
self.csp_report_only = OPT_OUT
|
|
153
|
+
|
|
154
|
+
instance_eval(&block) if block_given?
|
|
100
155
|
end
|
|
101
156
|
|
|
102
157
|
# Public: copy everything but the cached headers
|
|
@@ -104,33 +159,32 @@ module SecureHeaders
|
|
|
104
159
|
# Returns a deep-dup'd copy of this configuration.
|
|
105
160
|
def dup
|
|
106
161
|
copy = self.class.new
|
|
107
|
-
copy.
|
|
108
|
-
copy.
|
|
109
|
-
copy.
|
|
110
|
-
copy.
|
|
111
|
-
copy.
|
|
112
|
-
copy.
|
|
113
|
-
copy.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
copy.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
hpkp
|
|
123
|
-
end
|
|
162
|
+
copy.cookies = self.class.send(:deep_copy_if_hash, @cookies)
|
|
163
|
+
copy.csp = @csp.dup if @csp
|
|
164
|
+
copy.csp_report_only = @csp_report_only.dup if @csp_report_only
|
|
165
|
+
copy.cached_headers = self.class.send(:deep_copy_if_hash, @cached_headers)
|
|
166
|
+
copy.x_content_type_options = @x_content_type_options
|
|
167
|
+
copy.hsts = @hsts
|
|
168
|
+
copy.x_frame_options = @x_frame_options
|
|
169
|
+
copy.x_xss_protection = @x_xss_protection
|
|
170
|
+
copy.x_download_options = @x_download_options
|
|
171
|
+
copy.x_permitted_cross_domain_policies = @x_permitted_cross_domain_policies
|
|
172
|
+
copy.clear_site_data = @clear_site_data
|
|
173
|
+
copy.expect_certificate_transparency = @expect_certificate_transparency
|
|
174
|
+
copy.referrer_policy = @referrer_policy
|
|
175
|
+
copy.hpkp = @hpkp
|
|
176
|
+
copy.hpkp_report_host = @hpkp_report_host
|
|
124
177
|
copy
|
|
125
178
|
end
|
|
126
179
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
180
|
+
def opt_out(header)
|
|
181
|
+
send("#{header}=", OPT_OUT)
|
|
182
|
+
self.cached_headers.delete(header)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def update_x_frame_options(value)
|
|
186
|
+
@x_frame_options = value
|
|
187
|
+
self.cached_headers[XFrameOptions::CONFIG_KEY] = XFrameOptions.make_header(value)
|
|
134
188
|
end
|
|
135
189
|
|
|
136
190
|
# Public: validates all configurations values.
|
|
@@ -139,24 +193,96 @@ module SecureHeaders
|
|
|
139
193
|
#
|
|
140
194
|
# Returns nothing
|
|
141
195
|
def validate_config!
|
|
142
|
-
StrictTransportSecurity.validate_config!(hsts)
|
|
143
|
-
ContentSecurityPolicy.validate_config!(csp)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
196
|
+
StrictTransportSecurity.validate_config!(@hsts)
|
|
197
|
+
ContentSecurityPolicy.validate_config!(@csp)
|
|
198
|
+
ContentSecurityPolicy.validate_config!(@csp_report_only)
|
|
199
|
+
ReferrerPolicy.validate_config!(@referrer_policy)
|
|
200
|
+
XFrameOptions.validate_config!(@x_frame_options)
|
|
201
|
+
XContentTypeOptions.validate_config!(@x_content_type_options)
|
|
202
|
+
XXssProtection.validate_config!(@x_xss_protection)
|
|
203
|
+
XDownloadOptions.validate_config!(@x_download_options)
|
|
204
|
+
XPermittedCrossDomainPolicies.validate_config!(@x_permitted_cross_domain_policies)
|
|
205
|
+
ClearSiteData.validate_config!(@clear_site_data)
|
|
206
|
+
ExpectCertificateTransparency.validate_config!(@expect_certificate_transparency)
|
|
207
|
+
PublicKeyPins.validate_config!(@hpkp)
|
|
208
|
+
Cookie.validate_config!(@cookies)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def secure_cookies=(secure_cookies)
|
|
212
|
+
raise ArgumentError, "#{Kernel.caller.first}: `#secure_cookies=` is no longer supported. Please use `#cookies=` to configure secure cookies instead."
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def csp=(new_csp)
|
|
216
|
+
if new_csp.respond_to?(:opt_out?)
|
|
217
|
+
@csp = new_csp.dup
|
|
218
|
+
else
|
|
219
|
+
if new_csp[:report_only]
|
|
220
|
+
# invalid configuration implies that CSPRO should be set, CSP should not - so opt out
|
|
221
|
+
raise ArgumentError, "#{Kernel.caller.first}: `#csp=` was supplied a config with report_only: true. Use #csp_report_only="
|
|
222
|
+
else
|
|
223
|
+
@csp = ContentSecurityPolicyConfig.new(new_csp)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Configures the Content-Security-Policy-Report-Only header. `new_csp` cannot
|
|
229
|
+
# contain `report_only: false` or an error will be raised.
|
|
230
|
+
#
|
|
231
|
+
# NOTE: if csp has not been configured/has the default value when
|
|
232
|
+
# configuring csp_report_only, the code will assume you mean to only use
|
|
233
|
+
# report-only mode and you will be opted-out of enforce mode.
|
|
234
|
+
def csp_report_only=(new_csp)
|
|
235
|
+
@csp_report_only = begin
|
|
236
|
+
if new_csp.is_a?(ContentSecurityPolicyConfig)
|
|
237
|
+
new_csp.make_report_only
|
|
238
|
+
elsif new_csp.respond_to?(:opt_out?)
|
|
239
|
+
new_csp.dup
|
|
240
|
+
else
|
|
241
|
+
if new_csp[:report_only] == false # nil is a valid value on which we do not want to raise
|
|
242
|
+
raise ContentSecurityPolicyConfigError, "`#csp_report_only=` was supplied a config with report_only: false. Use #csp="
|
|
243
|
+
else
|
|
244
|
+
ContentSecurityPolicyReportOnlyConfig.new(new_csp)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
protected
|
|
251
|
+
|
|
252
|
+
def cookies=(cookies)
|
|
253
|
+
@cookies = cookies
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def cached_headers=(headers)
|
|
257
|
+
@cached_headers = headers
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def hpkp=(hpkp)
|
|
261
|
+
@hpkp = self.class.send(:deep_copy_if_hash, hpkp)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def hpkp_report_host=(hpkp_report_host)
|
|
265
|
+
@hpkp_report_host = hpkp_report_host
|
|
150
266
|
end
|
|
151
267
|
|
|
152
|
-
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
def cache_hpkp_report_host
|
|
271
|
+
has_report_uri = @hpkp && @hpkp != OPT_OUT && @hpkp[:report_uri]
|
|
272
|
+
self.hpkp_report_host = if has_report_uri
|
|
273
|
+
parsed_report_uri = URI.parse(@hpkp[:report_uri])
|
|
274
|
+
parsed_report_uri.host
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Public: Precompute the header names and values for this configuration.
|
|
153
279
|
# Ensures that headers generated at configure time, not on demand.
|
|
154
280
|
#
|
|
155
281
|
# Returns the cached headers
|
|
156
282
|
def cache_headers!
|
|
157
283
|
# generate defaults for the "easy" headers
|
|
158
284
|
headers = (ALL_HEADERS_BESIDES_CSP).each_with_object({}) do |klass, hash|
|
|
159
|
-
config =
|
|
285
|
+
config = instance_variable_get("@#{klass::CONFIG_KEY}")
|
|
160
286
|
unless config == OPT_OUT
|
|
161
287
|
hash[klass::CONFIG_KEY] = klass.make_header(config).freeze
|
|
162
288
|
end
|
|
@@ -165,7 +291,7 @@ module SecureHeaders
|
|
|
165
291
|
generate_csp_headers(headers)
|
|
166
292
|
|
|
167
293
|
headers.freeze
|
|
168
|
-
|
|
294
|
+
self.cached_headers = headers
|
|
169
295
|
end
|
|
170
296
|
|
|
171
297
|
# Private: adds CSP headers for each variation of CSP support.
|
|
@@ -175,13 +301,16 @@ module SecureHeaders
|
|
|
175
301
|
#
|
|
176
302
|
# Returns nothing
|
|
177
303
|
def generate_csp_headers(headers)
|
|
178
|
-
|
|
179
|
-
|
|
304
|
+
generate_csp_headers_for_config(headers, ContentSecurityPolicyConfig::CONFIG_KEY, self.csp)
|
|
305
|
+
generate_csp_headers_for_config(headers, ContentSecurityPolicyReportOnlyConfig::CONFIG_KEY, self.csp_report_only)
|
|
306
|
+
end
|
|
180
307
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
308
|
+
def generate_csp_headers_for_config(headers, header_key, csp_config)
|
|
309
|
+
unless csp_config.opt_out?
|
|
310
|
+
headers[header_key] = {}
|
|
311
|
+
ContentSecurityPolicy::VARIATIONS.each do |name, _|
|
|
312
|
+
csp = ContentSecurityPolicy.make_header(csp_config, UserAgent.parse(name))
|
|
313
|
+
headers[header_key][name] = csp.freeze
|
|
185
314
|
end
|
|
186
315
|
end
|
|
187
316
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module SecureHeaders
|
|
5
|
+
module HashHelper
|
|
6
|
+
def hash_source(inline_script, digest = :SHA256)
|
|
7
|
+
base64_hashed_content = Base64.encode64(Digest.const_get(digest).digest(inline_script)).chomp
|
|
8
|
+
"'#{digest.to_s.downcase}-#{base64_hashed_content}'"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module SecureHeaders
|
|
3
|
+
class ClearSiteDataConfigError < StandardError; end
|
|
4
|
+
class ClearSiteData
|
|
5
|
+
HEADER_NAME = "Clear-Site-Data".freeze
|
|
6
|
+
|
|
7
|
+
# Valid `types`
|
|
8
|
+
CACHE = "cache".freeze
|
|
9
|
+
COOKIES = "cookies".freeze
|
|
10
|
+
STORAGE = "storage".freeze
|
|
11
|
+
EXECUTION_CONTEXTS = "executionContexts".freeze
|
|
12
|
+
ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS]
|
|
13
|
+
|
|
14
|
+
CONFIG_KEY = :clear_site_data
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Public: make an Clear-Site-Data header name, value pair
|
|
18
|
+
#
|
|
19
|
+
# Returns nil if not configured, returns header name and value if configured.
|
|
20
|
+
def make_header(config = nil)
|
|
21
|
+
case config
|
|
22
|
+
when nil, OPT_OUT, []
|
|
23
|
+
# noop
|
|
24
|
+
when Array
|
|
25
|
+
[HEADER_NAME, make_header_value(config)]
|
|
26
|
+
when true
|
|
27
|
+
[HEADER_NAME, make_header_value(ALL_TYPES)]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_config!(config)
|
|
32
|
+
case config
|
|
33
|
+
when nil, OPT_OUT, true
|
|
34
|
+
# valid
|
|
35
|
+
when Array
|
|
36
|
+
unless config.all? { |t| t.is_a?(String) }
|
|
37
|
+
raise ClearSiteDataConfigError.new("types must be Strings")
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Public: Transform a Clear-Site-Data config (an Array of Strings) into a
|
|
45
|
+
# String that can be used as the value for the Clear-Site-Data header.
|
|
46
|
+
#
|
|
47
|
+
# types - An Array of String of types of data to clear.
|
|
48
|
+
#
|
|
49
|
+
# Returns a String of quoted values that are comma separated.
|
|
50
|
+
def make_header_value(types)
|
|
51
|
+
types.map { |t| "\"#{t}\""}.join(", ")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|