recaptcha 4.14.0 → 5.14.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Recaptcha
4
+ module Adapters
5
+ module ControllerMethods
6
+ private
7
+
8
+ # Your private API can be specified in the +options+ hash or preferably
9
+ # using the Configuration.
10
+ def verify_recaptcha(options = {})
11
+ options = {model: options} unless options.is_a? Hash
12
+ return true if Recaptcha.skip_env?(options[:env])
13
+
14
+ model = options[:model]
15
+ attribute = options.fetch(:attribute, :base)
16
+ recaptcha_response = options[:response] || recaptcha_response_token(options[:action])
17
+
18
+ begin
19
+ verified = if Recaptcha.invalid_response?(recaptcha_response)
20
+ false
21
+ else
22
+ unless options[:skip_remote_ip]
23
+ remoteip = (request.respond_to?(:remote_ip) && request.remote_ip) || (env && env['REMOTE_ADDR'])
24
+ options = options.merge(remote_ip: remoteip.to_s) if remoteip
25
+ end
26
+
27
+ success, @_recaptcha_reply =
28
+ Recaptcha.verify_via_api_call(recaptcha_response, options.merge(with_reply: true))
29
+ success
30
+ end
31
+
32
+ if verified
33
+ flash.delete(:recaptcha_error) if recaptcha_flash_supported? && !model
34
+ true
35
+ else
36
+ recaptcha_error(
37
+ model,
38
+ attribute,
39
+ options.fetch(:message) { Recaptcha::Helpers.to_error_message(:verification_failed) }
40
+ )
41
+ false
42
+ end
43
+ rescue Timeout::Error
44
+ if Recaptcha.configuration.handle_timeouts_gracefully
45
+ recaptcha_error(
46
+ model,
47
+ attribute,
48
+ options.fetch(:message) { Recaptcha::Helpers.to_error_message(:recaptcha_unreachable) }
49
+ )
50
+ false
51
+ else
52
+ raise RecaptchaError, 'Recaptcha unreachable.'
53
+ end
54
+ rescue StandardError => e
55
+ raise RecaptchaError, e.message, e.backtrace
56
+ end
57
+ end
58
+
59
+ def verify_recaptcha!(options = {})
60
+ verify_recaptcha(options) || raise(VerifyError)
61
+ end
62
+
63
+ def recaptcha_reply
64
+ @_recaptcha_reply if defined?(@_recaptcha_reply)
65
+ end
66
+
67
+ def recaptcha_error(model, attribute, message)
68
+ if model
69
+ model.errors.add(attribute, message)
70
+ elsif recaptcha_flash_supported?
71
+ flash[:recaptcha_error] = message
72
+ end
73
+ end
74
+
75
+ def recaptcha_flash_supported?
76
+ request.respond_to?(:format) && request.format == :html && respond_to?(:flash)
77
+ end
78
+
79
+ # Extracts response token from params. params['g-recaptcha-response-data'] for recaptcha_v3 or
80
+ # params['g-recaptcha-response'] for recaptcha_tags and invisible_recaptcha_tags and should
81
+ # either be a string or a hash with the action name(s) as keys. If it is a hash, then `action`
82
+ # is used as the key.
83
+ # @return [String] A response token if one was passed in the params; otherwise, `''`
84
+ def recaptcha_response_token(action = nil)
85
+ response_param = params['g-recaptcha-response-data'] || params['g-recaptcha-response']
86
+ response_param = response_param[action] if action && response_param.respond_to?(:key?)
87
+
88
+ if response_param.is_a?(String)
89
+ response_param
90
+ else
91
+ ''
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Recaptcha
4
+ module Adapters
5
+ module ViewMethods
6
+ # Renders a [reCAPTCHA v3](https://developers.google.com/recaptcha/docs/v3) script and (by
7
+ # default) a hidden input to submit the response token. You can also call the functions
8
+ # directly if you prefer. You can use
9
+ # `Recaptcha::Helpers.recaptcha_v3_execute_function_name(action)` to get the name of the
10
+ # function to call.
11
+ def recaptcha_v3(options = {})
12
+ ::Recaptcha::Helpers.recaptcha_v3(options)
13
+ end
14
+
15
+ # Renders a reCAPTCHA [v2 Checkbox](https://developers.google.com/recaptcha/docs/display) widget
16
+ def recaptcha_tags(options = {})
17
+ ::Recaptcha::Helpers.recaptcha_tags(options)
18
+ end
19
+
20
+ # Renders a reCAPTCHA v2 [Invisible reCAPTCHA](https://developers.google.com/recaptcha/docs/invisible)
21
+ def invisible_recaptcha_tags(options = {})
22
+ ::Recaptcha::Helpers.invisible_recaptcha_tags(options)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -30,17 +30,33 @@ module Recaptcha
30
30
  # end
31
31
  #
32
32
  class Configuration
33
- attr_accessor :skip_verify_env, :secret_key, :site_key, :proxy, :handle_timeouts_gracefully, :hostname
33
+ DEFAULTS = {
34
+ 'free_server_url' => 'https://www.recaptcha.net/recaptcha/api.js',
35
+ 'enterprise_server_url' => 'https://www.recaptcha.net/recaptcha/enterprise.js',
36
+ 'free_verify_url' => 'https://www.recaptcha.net/recaptcha/api/siteverify',
37
+ 'enterprise_verify_url' => 'https://recaptchaenterprise.googleapis.com/v1/projects'
38
+ }.freeze
39
+
40
+ attr_accessor :default_env, :skip_verify_env, :proxy, :secret_key, :site_key, :handle_timeouts_gracefully,
41
+ :hostname, :enterprise, :enterprise_api_key, :enterprise_project_id, :response_limit
34
42
  attr_writer :api_server_url, :verify_url
35
43
 
36
- def initialize #:nodoc:
44
+ def initialize # :nodoc:
45
+ @default_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || (Rails.env if defined? Rails.env)
37
46
  @skip_verify_env = %w[test cucumber]
38
- @handle_timeouts_gracefully = HANDLE_TIMEOUTS_GRACEFULLY
47
+ @handle_timeouts_gracefully = true
39
48
 
40
49
  @secret_key = ENV['RECAPTCHA_SECRET_KEY']
41
50
  @site_key = ENV['RECAPTCHA_SITE_KEY']
51
+
52
+ @enterprise = ENV['RECAPTCHA_ENTERPRISE'] == 'true'
53
+ @enterprise_api_key = ENV['RECAPTCHA_ENTERPRISE_API_KEY']
54
+ @enterprise_project_id = ENV['RECAPTCHA_ENTERPRISE_PROJECT_ID']
55
+
42
56
  @verify_url = nil
43
57
  @api_server_url = nil
58
+
59
+ @response_limit = 4000
44
60
  end
45
61
 
46
62
  def secret_key!
@@ -51,12 +67,20 @@ module Recaptcha
51
67
  site_key || raise(RecaptchaError, "No site key specified.")
52
68
  end
53
69
 
70
+ def enterprise_api_key!
71
+ enterprise_api_key || raise(RecaptchaError, "No Enterprise API key specified.")
72
+ end
73
+
74
+ def enterprise_project_id!
75
+ enterprise_project_id || raise(RecaptchaError, "No Enterprise project ID specified.")
76
+ end
77
+
54
78
  def api_server_url
55
- @api_server_url || CONFIG.fetch('server_url')
79
+ @api_server_url || (enterprise ? DEFAULTS.fetch('enterprise_server_url') : DEFAULTS.fetch('free_server_url'))
56
80
  end
57
81
 
58
82
  def verify_url
59
- @verify_url || CONFIG.fetch('verify_url')
83
+ @verify_url || (enterprise ? DEFAULTS.fetch('enterprise_verify_url') : DEFAULTS.fetch('free_verify_url'))
60
84
  end
61
85
  end
62
86
  end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Recaptcha
4
+ module Helpers
5
+ DEFAULT_ERRORS = {
6
+ recaptcha_unreachable: 'Oops, we failed to validate your reCAPTCHA response. Please try again.',
7
+ verification_failed: 'reCAPTCHA verification failed, please try again.'
8
+ }.freeze
9
+
10
+ def self.recaptcha_v3(options = {})
11
+ site_key = options[:site_key] ||= Recaptcha.configuration.site_key!
12
+ action = options.delete(:action) || raise(Recaptcha::RecaptchaError, 'action is required')
13
+ id = options.delete(:id) || "g-recaptcha-response-data-#{dasherize_action(action)}"
14
+ name = options.delete(:name) || "g-recaptcha-response-data[#{action}]"
15
+ turbolinks = options.delete(:turbolinks)
16
+ options[:render] = site_key
17
+ options[:script_async] ||= false
18
+ options[:script_defer] ||= false
19
+ options[:ignore_no_element] = options.key?(:ignore_no_element) ? options[:ignore_no_element] : true
20
+ element = options.delete(:element)
21
+ element = element == false ? false : :input
22
+ if element == :input
23
+ callback = options.delete(:callback) || recaptcha_v3_default_callback_name(action)
24
+ end
25
+ options[:class] = "g-recaptcha-response #{options[:class]}"
26
+
27
+ if turbolinks
28
+ options[:onload] = recaptcha_v3_execute_function_name(action)
29
+ end
30
+ html, tag_attributes = components(options)
31
+ if turbolinks
32
+ html << recaptcha_v3_onload_script(site_key, action, callback, id, options)
33
+ elsif recaptcha_v3_inline_script?(options)
34
+ html << recaptcha_v3_inline_script(site_key, action, callback, id, options)
35
+ end
36
+ case element
37
+ when :input
38
+ html << %(<input type="hidden" name="#{name}" id="#{id}" #{tag_attributes}/>\n)
39
+ when false
40
+ # No tag
41
+ nil
42
+ else
43
+ raise(RecaptchaError, "ReCAPTCHA element `#{options[:element]}` is not valid.")
44
+ end
45
+ html.respond_to?(:html_safe) ? html.html_safe : html
46
+ end
47
+
48
+ def self.recaptcha_tags(options)
49
+ if options.key?(:stoken)
50
+ raise(RecaptchaError, "Secure Token is deprecated. Please remove 'stoken' from your calls to recaptcha_tags.")
51
+ end
52
+ if options.key?(:ssl)
53
+ raise(RecaptchaError, "SSL is now always true. Please remove 'ssl' from your calls to recaptcha_tags.")
54
+ end
55
+
56
+ noscript = options.delete(:noscript)
57
+
58
+ html, tag_attributes, fallback_uri = components(options.dup)
59
+ html << %(<div #{tag_attributes}></div>\n)
60
+
61
+ if noscript != false
62
+ html << <<-HTML
63
+ <noscript>
64
+ <div>
65
+ <div style="width: 302px; height: 422px; position: relative;">
66
+ <div style="width: 302px; height: 422px; position: absolute;">
67
+ <iframe
68
+ src="#{fallback_uri}"
69
+ name="ReCAPTCHA"
70
+ style="width: 302px; height: 422px; border-style: none; border: 0; overflow: hidden;">
71
+ </iframe>
72
+ </div>
73
+ </div>
74
+ <div style="width: 300px; height: 60px; border-style: none;
75
+ bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;
76
+ background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;">
77
+ <textarea id="g-recaptcha-response" name="g-recaptcha-response"
78
+ class="g-recaptcha-response"
79
+ style="width: 250px; height: 40px; border: 1px solid #c1c1c1;
80
+ margin: 10px 25px; padding: 0px; resize: none;">
81
+ </textarea>
82
+ </div>
83
+ </div>
84
+ </noscript>
85
+ HTML
86
+ end
87
+
88
+ html.respond_to?(:html_safe) ? html.html_safe : html
89
+ end
90
+
91
+ def self.invisible_recaptcha_tags(custom)
92
+ options = {callback: 'invisibleRecaptchaSubmit', ui: :button}.merge(custom)
93
+ text = options.delete(:text)
94
+ html, tag_attributes = components(options.dup)
95
+ html << default_callback(options) if default_callback_required?(options)
96
+
97
+ case options[:ui]
98
+ when :button
99
+ html << %(<button type="submit" #{tag_attributes}>#{text}</button>\n)
100
+ when :invisible
101
+ html << %(<div data-size="invisible" #{tag_attributes}></div>\n)
102
+ when :input
103
+ html << %(<input type="submit" #{tag_attributes} value="#{text}"/>\n)
104
+ else
105
+ raise(RecaptchaError, "ReCAPTCHA ui `#{options[:ui]}` is not valid.")
106
+ end
107
+ html.respond_to?(:html_safe) ? html.html_safe : html
108
+ end
109
+
110
+ def self.to_error_message(key)
111
+ default = DEFAULT_ERRORS.fetch(key) { raise ArgumentError "Unknown reCAPTCHA error - #{key}" }
112
+ to_message("recaptcha.errors.#{key}", default)
113
+ end
114
+
115
+ if defined?(I18n)
116
+ def self.to_message(key, default)
117
+ I18n.translate(key, default: default)
118
+ end
119
+ else
120
+ def self.to_message(_key, default)
121
+ default
122
+ end
123
+ end
124
+
125
+ private_class_method def self.components(options)
126
+ html = +''
127
+ attributes = {}
128
+ fallback_uri = +''
129
+
130
+ options = options.dup
131
+ env = options.delete(:env)
132
+ class_attribute = options.delete(:class)
133
+ site_key = options.delete(:site_key)
134
+ hl = options.delete(:hl)
135
+ onload = options.delete(:onload)
136
+ render = options.delete(:render)
137
+ script_async = options.delete(:script_async)
138
+ script_defer = options.delete(:script_defer)
139
+ nonce = options.delete(:nonce)
140
+ skip_script = (options.delete(:script) == false) || (options.delete(:external_script) == false)
141
+ ui = options.delete(:ui)
142
+ options.delete(:ignore_no_element)
143
+
144
+ data_attribute_keys = [:badge, :theme, :type, :callback, :expired_callback, :error_callback, :size]
145
+ data_attribute_keys << :tabindex unless ui == :button
146
+ data_attributes = {}
147
+ data_attribute_keys.each do |data_attribute|
148
+ value = options.delete(data_attribute)
149
+ data_attributes["data-#{data_attribute.to_s.tr('_', '-')}"] = value if value
150
+ end
151
+
152
+ unless Recaptcha.skip_env?(env)
153
+ site_key ||= Recaptcha.configuration.site_key!
154
+ script_url = Recaptcha.configuration.api_server_url
155
+ query_params = hash_to_query(
156
+ hl: hl,
157
+ onload: onload,
158
+ render: render
159
+ )
160
+ script_url += "?#{query_params}" unless query_params.empty?
161
+ async_attr = "async" if script_async != false
162
+ defer_attr = "defer" if script_defer != false
163
+ nonce_attr = " nonce='#{nonce}'" if nonce
164
+ html << %(<script src="#{script_url}" #{async_attr} #{defer_attr} #{nonce_attr}></script>\n) unless skip_script
165
+ fallback_uri = %(#{script_url.chomp(".js")}/fallback?k=#{site_key})
166
+ attributes["data-sitekey"] = site_key
167
+ attributes.merge! data_attributes
168
+ end
169
+
170
+ # The remaining options will be added as attributes on the tag.
171
+ attributes["class"] = "g-recaptcha #{class_attribute}"
172
+ tag_attributes = attributes.merge(options).map { |k, v| %(#{k}="#{v}") }.join(" ")
173
+
174
+ [html, tag_attributes, fallback_uri]
175
+ end
176
+
177
+ # v3
178
+
179
+ # Renders a script that calls `grecaptcha.execute` or
180
+ # `grecaptcha.enterprise.execute` for the given `site_key` and `action` and
181
+ # calls the `callback` with the resulting response token.
182
+ private_class_method def self.recaptcha_v3_inline_script(site_key, action, callback, id, options = {})
183
+ nonce = options[:nonce]
184
+ nonce_attr = " nonce='#{nonce}'" if nonce
185
+
186
+ <<-HTML
187
+ <script#{nonce_attr}>
188
+ // Define function so that we can call it again later if we need to reset it
189
+ // This executes reCAPTCHA and then calls our callback.
190
+ function #{recaptcha_v3_execute_function_name(action)}() {
191
+ #{recaptcha_ready_method_name}(function() {
192
+ #{recaptcha_execute_method_name}('#{site_key}', {action: '#{action}'}).then(function(token) {
193
+ #{callback}('#{id}', token)
194
+ });
195
+ });
196
+ };
197
+ // Invoke immediately
198
+ #{recaptcha_v3_execute_function_name(action)}()
199
+
200
+ // Async variant so you can await this function from another async function (no need for
201
+ // an explicit callback function then!)
202
+ // Returns a Promise that resolves with the response token.
203
+ async function #{recaptcha_v3_async_execute_function_name(action)}() {
204
+ return new Promise((resolve, reject) => {
205
+ #{recaptcha_ready_method_name}(async function() {
206
+ resolve(await #{recaptcha_execute_method_name}('#{site_key}', {action: '#{action}'}))
207
+ });
208
+ })
209
+ };
210
+
211
+ #{recaptcha_v3_define_default_callback(callback, options) if recaptcha_v3_define_default_callback?(callback, action, options)}
212
+ </script>
213
+ HTML
214
+ end
215
+
216
+ private_class_method def self.recaptcha_v3_onload_script(site_key, action, callback, id, options = {})
217
+ nonce = options[:nonce]
218
+ nonce_attr = " nonce='#{nonce}'" if nonce
219
+
220
+ <<-HTML
221
+ <script#{nonce_attr}>
222
+ function #{recaptcha_v3_execute_function_name(action)}() {
223
+ #{recaptcha_ready_method_name}(function() {
224
+ #{recaptcha_execute_method_name}('#{site_key}', {action: '#{action}'}).then(function(token) {
225
+ #{callback}('#{id}', token)
226
+ });
227
+ });
228
+ };
229
+ #{recaptcha_v3_define_default_callback(callback, options) if recaptcha_v3_define_default_callback?(callback, action, options)}
230
+ </script>
231
+ HTML
232
+ end
233
+
234
+ private_class_method def self.recaptcha_v3_inline_script?(options)
235
+ !Recaptcha.skip_env?(options[:env]) &&
236
+ options[:script] != false &&
237
+ options[:inline_script] != false
238
+ end
239
+
240
+ private_class_method def self.recaptcha_v3_define_default_callback(callback, options)
241
+ <<-HTML
242
+ var #{callback} = function(id, token) {
243
+ var element = document.getElementById(id);
244
+ #{element_check_condition(options)} element.value = token;
245
+ }
246
+ HTML
247
+ end
248
+
249
+ # Returns true if we should be adding the default callback.
250
+ # That is, if the given callback name is the default callback name (for the given action) and we
251
+ # are not skipping inline scripts for any reason.
252
+ private_class_method def self.recaptcha_v3_define_default_callback?(callback, action, options)
253
+ callback == recaptcha_v3_default_callback_name(action) &&
254
+ recaptcha_v3_inline_script?(options)
255
+ end
256
+
257
+ # Returns the name of the JavaScript function that actually executes the
258
+ # reCAPTCHA code (calls `grecaptcha.execute` or
259
+ # `grecaptcha.enterprise.execute`). You can call it again later to reset it.
260
+ def self.recaptcha_v3_execute_function_name(action)
261
+ "executeRecaptchaFor#{sanitize_action_for_js(action)}"
262
+ end
263
+
264
+ # Returns the name of an async JavaScript function that executes the reCAPTCHA code.
265
+ def self.recaptcha_v3_async_execute_function_name(action)
266
+ "#{recaptcha_v3_execute_function_name(action)}Async"
267
+ end
268
+
269
+ def self.recaptcha_v3_default_callback_name(action)
270
+ "setInputWithRecaptchaResponseTokenFor#{sanitize_action_for_js(action)}"
271
+ end
272
+
273
+ # v2
274
+
275
+ private_class_method def self.default_callback(options = {})
276
+ nonce = options[:nonce]
277
+ nonce_attr = " nonce='#{nonce}'" if nonce
278
+ selector_attr = options[:id] ? "##{options[:id]}" : ".g-recaptcha"
279
+
280
+ <<-HTML
281
+ <script#{nonce_attr}>
282
+ var invisibleRecaptchaSubmit = function () {
283
+ var closestForm = function (ele) {
284
+ var curEle = ele.parentNode;
285
+ while (curEle.nodeName !== 'FORM' && curEle.nodeName !== 'BODY'){
286
+ curEle = curEle.parentNode;
287
+ }
288
+ return curEle.nodeName === 'FORM' ? curEle : null
289
+ };
290
+
291
+ var el = document.querySelector("#{selector_attr}")
292
+ if (!!el) {
293
+ var form = closestForm(el);
294
+ if (form) {
295
+ form.submit();
296
+ }
297
+ }
298
+ };
299
+ </script>
300
+ HTML
301
+ end
302
+
303
+ def self.recaptcha_execute_method_name
304
+ Recaptcha.configuration.enterprise ? "grecaptcha.enterprise.execute" : "grecaptcha.execute"
305
+ end
306
+
307
+ def self.recaptcha_ready_method_name
308
+ Recaptcha.configuration.enterprise ? "grecaptcha.enterprise.ready" : "grecaptcha.ready"
309
+ end
310
+
311
+ private_class_method def self.default_callback_required?(options)
312
+ options[:callback] == 'invisibleRecaptchaSubmit' &&
313
+ !Recaptcha.skip_env?(options[:env]) &&
314
+ options[:script] != false &&
315
+ options[:inline_script] != false
316
+ end
317
+
318
+ # Returns a camelized string that is safe for use in a JavaScript variable/function name.
319
+ # sanitize_action_for_js('my/action') => 'MyAction'
320
+ private_class_method def self.sanitize_action_for_js(action)
321
+ action.to_s.gsub(/\W/, '_').split(/\/|_/).map(&:capitalize).join
322
+ end
323
+
324
+ # Returns a dasherized string that is safe for use as an HTML ID
325
+ # dasherize_action('my/action') => 'my-action'
326
+ private_class_method def self.dasherize_action(action)
327
+ action.to_s.gsub(/\W/, '-').tr('_', '-')
328
+ end
329
+
330
+ private_class_method def self.hash_to_query(hash)
331
+ hash.delete_if { |_, val| val.nil? || val.empty? }.to_a.map { |pair| pair.join('=') }.join('&')
332
+ end
333
+
334
+ private_class_method def self.element_check_condition(options)
335
+ options[:ignore_no_element] ? "if (element !== null)" : ""
336
+ end
337
+ end
338
+ end
@@ -3,13 +3,33 @@
3
3
  module Recaptcha
4
4
  class Railtie < Rails::Railtie
5
5
  ActiveSupport.on_load(:action_view) do
6
- require 'recaptcha/client_helper'
7
- include Recaptcha::ClientHelper
6
+ include Recaptcha::Adapters::ViewMethods
8
7
  end
9
8
 
10
9
  ActiveSupport.on_load(:action_controller) do
11
- require 'recaptcha/verify'
12
- include Recaptcha::Verify
10
+ include Recaptcha::Adapters::ControllerMethods
11
+ end
12
+
13
+ initializer 'recaptcha' do |app|
14
+ Recaptcha::Railtie.instance_eval do
15
+ pattern = pattern_from app.config.i18n.available_locales
16
+
17
+ add("rails/locales/#{pattern}.yml")
18
+ end
19
+ end
20
+
21
+ class << self
22
+ protected
23
+
24
+ def add(pattern)
25
+ files = Dir[File.join(File.dirname(__FILE__), '../..', pattern)]
26
+ I18n.load_path.concat(files)
27
+ end
28
+
29
+ def pattern_from(args)
30
+ array = Array(args || [])
31
+ array.blank? ? '*' : "{#{array.join ','}}"
32
+ end
13
33
  end
14
34
  end
15
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Recaptcha
4
- VERSION = '4.14.0'
4
+ VERSION = '5.14.0'
5
5
  end