recaptcha 4.14.0 → 5.2.1

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.
@@ -1,25 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'recaptcha/configuration'
4
- require 'uri'
3
+ require 'json'
5
4
  require 'net/http'
5
+ require 'uri'
6
6
 
7
+ require 'recaptcha/configuration'
8
+ require 'recaptcha/helpers'
9
+ require 'recaptcha/adapters/controller_methods'
10
+ require 'recaptcha/adapters/view_methods'
7
11
  if defined?(Rails)
8
12
  require 'recaptcha/railtie'
9
- else
10
- require 'recaptcha/client_helper'
11
- require 'recaptcha/verify'
12
13
  end
13
14
 
14
15
  module Recaptcha
15
- CONFIG = {
16
- 'server_url' => 'https://www.google.com/recaptcha/api.js',
17
- 'verify_url' => 'https://www.google.com/recaptcha/api/siteverify'
18
- }.freeze
19
-
20
- USE_SSL_BY_DEFAULT = false
21
- HANDLE_TIMEOUTS_GRACEFULLY = true
22
16
  DEFAULT_TIMEOUT = 3
17
+ RESPONSE_LIMIT = 4000
18
+
19
+ class RecaptchaError < StandardError
20
+ end
21
+
22
+ class VerifyError < RecaptchaError
23
+ end
23
24
 
24
25
  # Gives access to the current Configuration.
25
26
  def self.configuration
@@ -50,33 +51,68 @@ module Recaptcha
50
51
  original_config.each { |key, value| configuration.send("#{key}=", value) }
51
52
  end
52
53
 
53
- def self.get(verify_hash, options)
54
- http = if Recaptcha.configuration.proxy
55
- proxy_server = URI.parse(Recaptcha.configuration.proxy)
56
- Net::HTTP::Proxy(proxy_server.host, proxy_server.port, proxy_server.user, proxy_server.password)
57
- else
58
- Net::HTTP
54
+ def self.skip_env?(env)
55
+ configuration.skip_verify_env.include?(env || configuration.default_env)
56
+ end
57
+
58
+ def self.invalid_response?(resp)
59
+ resp.empty? || resp.length > RESPONSE_LIMIT
60
+ end
61
+
62
+ def self.verify_via_api_call(response, options)
63
+ secret_key = options.fetch(:secret_key) { configuration.secret_key! }
64
+ verify_hash = { 'secret' => secret_key, 'response' => response }
65
+ verify_hash['remoteip'] = options[:remote_ip] if options.key?(:remote_ip)
66
+
67
+ reply = api_verification(verify_hash, timeout: options[:timeout])
68
+ reply['success'].to_s == 'true' &&
69
+ hostname_valid?(reply['hostname'], options[:hostname]) &&
70
+ action_valid?(reply['action'], options[:action]) &&
71
+ score_above_threshold?(reply['score'], options[:minimum_score])
72
+ end
73
+
74
+ def self.hostname_valid?(hostname, validation)
75
+ validation ||= configuration.hostname
76
+
77
+ case validation
78
+ when nil, FalseClass then true
79
+ when String then validation == hostname
80
+ else validation.call(hostname)
59
81
  end
60
- query = URI.encode_www_form(verify_hash)
61
- uri = URI.parse(Recaptcha.configuration.verify_url + '?' + query)
62
- http_instance = http.new(uri.host, uri.port)
63
- http_instance.read_timeout = http_instance.open_timeout = options[:timeout] || DEFAULT_TIMEOUT
64
- http_instance.use_ssl = true if uri.port == 443
65
- request = Net::HTTP::Get.new(uri.request_uri)
66
- http_instance.request(request).body
67
82
  end
68
83
 
69
- def self.i18n(key, default)
70
- if defined?(I18n)
71
- I18n.translate(key, default: default)
72
- else
73
- default
84
+ def self.action_valid?(action, expected_action)
85
+ case expected_action
86
+ when nil, FalseClass then true
87
+ else action == expected_action
74
88
  end
75
89
  end
76
90
 
77
- class RecaptchaError < StandardError
91
+ # Returns true iff score is greater or equal to (>=) minimum_score, or if no minimum_score was specified
92
+ def self.score_above_threshold?(score, minimum_score)
93
+ return true if minimum_score.nil?
94
+ return false if score.nil?
95
+
96
+ case minimum_score
97
+ when nil, FalseClass then true
98
+ else score >= minimum_score
99
+ end
78
100
  end
79
101
 
80
- class VerifyError < RecaptchaError
102
+ def self.api_verification(verify_hash, timeout: nil)
103
+ timeout ||= DEFAULT_TIMEOUT
104
+ http = if configuration.proxy
105
+ proxy_server = URI.parse(configuration.proxy)
106
+ Net::HTTP::Proxy(proxy_server.host, proxy_server.port, proxy_server.user, proxy_server.password)
107
+ else
108
+ Net::HTTP
109
+ end
110
+ query = URI.encode_www_form(verify_hash)
111
+ uri = URI.parse(configuration.verify_url + '?' + query)
112
+ http_instance = http.new(uri.host, uri.port)
113
+ http_instance.read_timeout = http_instance.open_timeout = timeout
114
+ http_instance.use_ssl = true if uri.port == 443
115
+ request = Net::HTTP::Get.new(uri.request_uri)
116
+ JSON.parse(http_instance.request(request).body)
81
117
  end
82
118
  end
@@ -0,0 +1,87 @@
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
+ Recaptcha.verify_via_api_call(recaptcha_response, options)
28
+ end
29
+
30
+ if verified
31
+ flash.delete(:recaptcha_error) if recaptcha_flash_supported? && !model
32
+ true
33
+ else
34
+ recaptcha_error(
35
+ model,
36
+ attribute,
37
+ options.fetch(:message) { Recaptcha::Helpers.to_error_message(:verification_failed) }
38
+ )
39
+ false
40
+ end
41
+ rescue Timeout::Error
42
+ if Recaptcha.configuration.handle_timeouts_gracefully
43
+ recaptcha_error(
44
+ model,
45
+ attribute,
46
+ options.fetch(:message) { Recaptcha::Helpers.to_error_message(:recaptcha_unreachable) }
47
+ )
48
+ false
49
+ else
50
+ raise RecaptchaError, 'Recaptcha unreachable.'
51
+ end
52
+ rescue StandardError => e
53
+ raise RecaptchaError, e.message, e.backtrace
54
+ end
55
+ end
56
+
57
+ def verify_recaptcha!(options = {})
58
+ verify_recaptcha(options) || raise(VerifyError)
59
+ end
60
+
61
+ def recaptcha_error(model, attribute, message)
62
+ if model
63
+ model.errors.add(attribute, message)
64
+ elsif recaptcha_flash_supported?
65
+ flash[:recaptcha_error] = message
66
+ end
67
+ end
68
+
69
+ def recaptcha_flash_supported?
70
+ request.respond_to?(:format) && request.format == :html && respond_to?(:flash)
71
+ end
72
+
73
+ # Extracts response token from params. params['g-recaptcha-response'] should either be a
74
+ # string or a hash with the action name(s) as keys. If it is a hash, then `action` is used as
75
+ # the key.
76
+ # @return [String] A response token if one was passed in the params; otherwise, `''`
77
+ def recaptcha_response_token(action = nil)
78
+ response_param = params['g-recaptcha-response']
79
+ if response_param&.respond_to?(:to_h) # Includes ActionController::Parameters
80
+ response_param[action].to_s
81
+ else
82
+ response_param.to_s
83
+ end
84
+ end
85
+ end
86
+ end
87
+ 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,12 +30,18 @@ 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
+ 'server_url' => 'https://www.recaptcha.net/recaptcha/api.js',
35
+ 'verify_url' => 'https://www.recaptcha.net/recaptcha/api/siteverify'
36
+ }.freeze
37
+
38
+ attr_accessor :default_env, :skip_verify_env, :secret_key, :site_key, :proxy, :handle_timeouts_gracefully, :hostname
34
39
  attr_writer :api_server_url, :verify_url
35
40
 
36
41
  def initialize #:nodoc:
42
+ @default_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || (Rails.env if defined? Rails.env)
37
43
  @skip_verify_env = %w[test cucumber]
38
- @handle_timeouts_gracefully = HANDLE_TIMEOUTS_GRACEFULLY
44
+ @handle_timeouts_gracefully = true
39
45
 
40
46
  @secret_key = ENV['RECAPTCHA_SECRET_KEY']
41
47
  @site_key = ENV['RECAPTCHA_SITE_KEY']
@@ -52,11 +58,11 @@ module Recaptcha
52
58
  end
53
59
 
54
60
  def api_server_url
55
- @api_server_url || CONFIG.fetch('server_url')
61
+ @api_server_url || DEFAULTS.fetch('server_url')
56
62
  end
57
63
 
58
64
  def verify_url
59
- @verify_url || CONFIG.fetch('verify_url')
65
+ @verify_url || DEFAULTS.fetch('verify_url')
60
66
  end
61
67
  end
62
68
  end
@@ -0,0 +1,298 @@
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-" + dasherize_action(action)
14
+ name = options.delete(:name) || "g-recaptcha-response[#{action}]"
15
+ options[:render] = site_key
16
+ options[:script_async] ||= false
17
+ options[:script_defer] ||= false
18
+ element = options.delete(:element)
19
+ element = element == false ? false : :input
20
+ if element == :input
21
+ callback = options.delete(:callback) || recaptcha_v3_default_callback_name(action)
22
+ end
23
+ options[:class] = "g-recaptcha-response #{options[:class]}"
24
+
25
+ html, tag_attributes = components(options)
26
+ if recaptcha_v3_inline_script?(options)
27
+ html << recaptcha_v3_inline_script(site_key, action, callback, id, options)
28
+ end
29
+ case element
30
+ when :input
31
+ html << %(<input type="hidden" name="#{name}" id="#{id}" #{tag_attributes}/>\n)
32
+ when false
33
+ # No tag
34
+ nil
35
+ else
36
+ raise(RecaptchaError, "ReCAPTCHA element `#{options[:element]}` is not valid.")
37
+ end
38
+ html.respond_to?(:html_safe) ? html.html_safe : html
39
+ end
40
+
41
+ def self.recaptcha_tags(options)
42
+ if options.key?(:stoken)
43
+ raise(RecaptchaError, "Secure Token is deprecated. Please remove 'stoken' from your calls to recaptcha_tags.")
44
+ end
45
+ if options.key?(:ssl)
46
+ raise(RecaptchaError, "SSL is now always true. Please remove 'ssl' from your calls to recaptcha_tags.")
47
+ end
48
+
49
+ noscript = options.delete(:noscript)
50
+
51
+ html, tag_attributes, fallback_uri = components(options.dup)
52
+ html << %(<div #{tag_attributes}></div>\n)
53
+
54
+ if noscript != false
55
+ html << <<-HTML
56
+ <noscript>
57
+ <div>
58
+ <div style="width: 302px; height: 422px; position: relative;">
59
+ <div style="width: 302px; height: 422px; position: absolute;">
60
+ <iframe
61
+ src="#{fallback_uri}"
62
+ name="ReCAPTCHA"
63
+ style="width: 302px; height: 422px; border-style: none; border: 0; overflow: hidden;">
64
+ </iframe>
65
+ </div>
66
+ </div>
67
+ <div style="width: 300px; height: 60px; border-style: none;
68
+ bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;
69
+ background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;">
70
+ <textarea id="g-recaptcha-response" name="g-recaptcha-response"
71
+ class="g-recaptcha-response"
72
+ style="width: 250px; height: 40px; border: 1px solid #c1c1c1;
73
+ margin: 10px 25px; padding: 0px; resize: none;">
74
+ </textarea>
75
+ </div>
76
+ </div>
77
+ </noscript>
78
+ HTML
79
+ end
80
+
81
+ html.respond_to?(:html_safe) ? html.html_safe : html
82
+ end
83
+
84
+ def self.invisible_recaptcha_tags(custom)
85
+ options = {callback: 'invisibleRecaptchaSubmit', ui: :button}.merge(custom)
86
+ text = options.delete(:text)
87
+ html, tag_attributes = components(options.dup)
88
+ html << default_callback(options) if default_callback_required?(options)
89
+
90
+ case options[:ui]
91
+ when :button
92
+ html << %(<button type="submit" #{tag_attributes}>#{text}</button>\n)
93
+ when :invisible
94
+ html << %(<div data-size="invisible" #{tag_attributes}></div>\n)
95
+ when :input
96
+ html << %(<input type="submit" #{tag_attributes} value="#{text}"/>\n)
97
+ else
98
+ raise(RecaptchaError, "ReCAPTCHA ui `#{options[:ui]}` is not valid.")
99
+ end
100
+ html.respond_to?(:html_safe) ? html.html_safe : html
101
+ end
102
+
103
+ def self.to_error_message(key)
104
+ default = DEFAULT_ERRORS.fetch(key) { raise ArgumentError "Unknown reCAPTCHA error - #{key}" }
105
+ to_message("recaptcha.errors.#{key}", default)
106
+ end
107
+
108
+ if defined?(I18n)
109
+ def self.to_message(key, default)
110
+ I18n.translate(key, default: default)
111
+ end
112
+ else
113
+ def self.to_message(_key, default)
114
+ default
115
+ end
116
+ end
117
+
118
+ private_class_method def self.components(options)
119
+ html = +''
120
+ attributes = {}
121
+ fallback_uri = +''
122
+
123
+ options = options.dup
124
+ env = options.delete(:env)
125
+ class_attribute = options.delete(:class)
126
+ site_key = options.delete(:site_key)
127
+ hl = options.delete(:hl)
128
+ onload = options.delete(:onload)
129
+ render = options.delete(:render)
130
+ script_async = options.delete(:script_async)
131
+ script_defer = options.delete(:script_defer)
132
+ nonce = options.delete(:nonce)
133
+ skip_script = (options.delete(:script) == false) || (options.delete(:external_script) == false)
134
+ ui = options.delete(:ui)
135
+
136
+ data_attribute_keys = [:badge, :theme, :type, :callback, :expired_callback, :error_callback, :size]
137
+ data_attribute_keys << :tabindex unless ui == :button
138
+ data_attributes = {}
139
+ data_attribute_keys.each do |data_attribute|
140
+ value = options.delete(data_attribute)
141
+ data_attributes["data-#{data_attribute.to_s.tr('_', '-')}"] = value if value
142
+ end
143
+
144
+ unless Recaptcha.skip_env?(env)
145
+ site_key ||= Recaptcha.configuration.site_key!
146
+ script_url = Recaptcha.configuration.api_server_url
147
+ query_params = hash_to_query(
148
+ hl: hl,
149
+ onload: onload,
150
+ render: render
151
+ )
152
+ script_url += "?#{query_params}" unless query_params.empty?
153
+ async_attr = "async" if script_async != false
154
+ defer_attr = "defer" if script_defer != false
155
+ nonce_attr = " nonce='#{nonce}'" if nonce
156
+ html << %(<script src="#{script_url}" #{async_attr} #{defer_attr} #{nonce_attr}></script>\n) unless skip_script
157
+ fallback_uri = %(#{script_url.chomp(".js")}/fallback?k=#{site_key})
158
+ attributes["data-sitekey"] = site_key
159
+ attributes.merge! data_attributes
160
+ end
161
+
162
+ # The remaining options will be added as attributes on the tag.
163
+ attributes["class"] = "g-recaptcha #{class_attribute}"
164
+ tag_attributes = attributes.merge(options).map { |k, v| %(#{k}="#{v}") }.join(" ")
165
+
166
+ [html, tag_attributes, fallback_uri]
167
+ end
168
+
169
+ # v3
170
+
171
+ # Renders a script that calls `grecaptcha.execute` for the given `site_key` and `action` and
172
+ # calls the `callback` with the resulting response token.
173
+ private_class_method def self.recaptcha_v3_inline_script(site_key, action, callback, id, options = {})
174
+ nonce = options[:nonce]
175
+ nonce_attr = " nonce='#{nonce}'" if nonce
176
+
177
+ <<-HTML
178
+ <script#{nonce_attr}>
179
+ // Define function so that we can call it again later if we need to reset it
180
+ // This executes reCAPTCHA and then calls our callback.
181
+ function #{recaptcha_v3_execute_function_name(action)}() {
182
+ grecaptcha.ready(function() {
183
+ grecaptcha.execute('#{site_key}', {action: '#{action}'}).then(function(token) {
184
+ //console.log('#{id}', token)
185
+ #{callback}('#{id}', token)
186
+ });
187
+ });
188
+ };
189
+ // Invoke immediately
190
+ #{recaptcha_v3_execute_function_name(action)}()
191
+
192
+ // Async variant so you can await this function from another async function (no need for
193
+ // an explicit callback function then!)
194
+ // Returns a Promise that resolves with the response token.
195
+ async function #{recaptcha_v3_async_execute_function_name(action)}() {
196
+ return new Promise((resolve, reject) => {
197
+ grecaptcha.ready(async function() {
198
+ resolve(await grecaptcha.execute('#{site_key}', {action: '#{action}'}))
199
+ });
200
+ })
201
+ };
202
+
203
+ #{recaptcha_v3_define_default_callback(callback) if recaptcha_v3_define_default_callback?(callback, action, options)}
204
+ </script>
205
+ HTML
206
+ end
207
+
208
+ private_class_method def self.recaptcha_v3_inline_script?(options)
209
+ !Recaptcha.skip_env?(options[:env]) &&
210
+ options[:script] != false &&
211
+ options[:inline_script] != false
212
+ end
213
+
214
+ private_class_method def self.recaptcha_v3_define_default_callback(callback)
215
+ <<-HTML
216
+ var #{callback} = function(id, token) {
217
+ var element = document.getElementById(id);
218
+ element.value = token;
219
+ }
220
+ HTML
221
+ end
222
+
223
+ # Returns true if we should be adding the default callback.
224
+ # That is, if the given callback name is the default callback name (for the given action) and we
225
+ # are not skipping inline scripts for any reason.
226
+ private_class_method def self.recaptcha_v3_define_default_callback?(callback, action, options)
227
+ callback == recaptcha_v3_default_callback_name(action) &&
228
+ recaptcha_v3_inline_script?(options)
229
+ end
230
+
231
+ # Returns the name of the JavaScript function that actually executes the reCAPTCHA code (calls
232
+ # grecaptcha.execute). You can call it again later to reset it.
233
+ def self.recaptcha_v3_execute_function_name(action)
234
+ "executeRecaptchaFor#{sanitize_action_for_js(action)}"
235
+ end
236
+
237
+ # Returns the name of an async JavaScript function that executes the reCAPTCHA code.
238
+ def self.recaptcha_v3_async_execute_function_name(action)
239
+ "#{recaptcha_v3_execute_function_name(action)}Async"
240
+ end
241
+
242
+ def self.recaptcha_v3_default_callback_name(action)
243
+ "setInputWithRecaptchaResponseTokenFor#{sanitize_action_for_js(action)}"
244
+ end
245
+
246
+ # v2
247
+
248
+ private_class_method def self.default_callback(options = {})
249
+ nonce = options[:nonce]
250
+ nonce_attr = " nonce='#{nonce}'" if nonce
251
+
252
+ <<-HTML
253
+ <script#{nonce_attr}>
254
+ var invisibleRecaptchaSubmit = function () {
255
+ var closestForm = function (ele) {
256
+ var curEle = ele.parentNode;
257
+ while (curEle.nodeName !== 'FORM' && curEle.nodeName !== 'BODY'){
258
+ curEle = curEle.parentNode;
259
+ }
260
+ return curEle.nodeName === 'FORM' ? curEle : null
261
+ };
262
+
263
+ var eles = document.getElementsByClassName('g-recaptcha');
264
+ if (eles.length > 0) {
265
+ var form = closestForm(eles[0]);
266
+ if (form) {
267
+ form.submit();
268
+ }
269
+ }
270
+ };
271
+ </script>
272
+ HTML
273
+ end
274
+
275
+ private_class_method def self.default_callback_required?(options)
276
+ options[:callback] == 'invisibleRecaptchaSubmit' &&
277
+ !Recaptcha.skip_env?(options[:env]) &&
278
+ options[:script] != false &&
279
+ options[:inline_script] != false
280
+ end
281
+
282
+ # Returns a camelized string that is safe for use in a JavaScript variable/function name.
283
+ # sanitize_action_for_js('my/action') => 'MyAction'
284
+ private_class_method def self.sanitize_action_for_js(action)
285
+ action.to_s.gsub(/\W/, '_').split(/\/|_/).map(&:capitalize).join
286
+ end
287
+
288
+ # Returns a dasherized string that is safe for use as an HTML ID
289
+ # dasherize_action('my/action') => 'my-action'
290
+ private_class_method def self.dasherize_action(action)
291
+ action.to_s.gsub(/\W/, '-').tr('_', '-')
292
+ end
293
+
294
+ private_class_method def self.hash_to_query(hash)
295
+ hash.delete_if { |_, val| val.nil? || val.empty? }.to_a.map { |pair| pair.join('=') }.join('&')
296
+ end
297
+ end
298
+ end