recaptcha 4.14.0 → 5.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/recaptcha.rb CHANGED
@@ -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,67 @@ 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: DEFAULT_TIMEOUT)
103
+ http = if configuration.proxy
104
+ proxy_server = URI.parse(configuration.proxy)
105
+ Net::HTTP::Proxy(proxy_server.host, proxy_server.port, proxy_server.user, proxy_server.password)
106
+ else
107
+ Net::HTTP
108
+ end
109
+ query = URI.encode_www_form(verify_hash)
110
+ uri = URI.parse(configuration.verify_url + '?' + query)
111
+ http_instance = http.new(uri.host, uri.port)
112
+ http_instance.read_timeout = http_instance.open_timeout = timeout
113
+ http_instance.use_ssl = true if uri.port == 443
114
+ request = Net::HTTP::Get.new(uri.request_uri)
115
+ JSON.parse(http_instance.request(request).body)
81
116
  end
82
117
  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,297 @@
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
+ function #{recaptcha_v3_execute_function_name(action)}() {
181
+ grecaptcha.ready(function() {
182
+ grecaptcha.execute('#{site_key}', {action: '#{action}'}).then(function(token) {
183
+ //console.log('#{id}', token)
184
+ #{callback}('#{id}', token)
185
+ });
186
+ });
187
+ };
188
+ // Invoke immediately
189
+ #{recaptcha_v3_execute_function_name(action)}()
190
+
191
+ // Async variant so you can await this function from another async function (no need for
192
+ // an explicit callback function then!)
193
+ async function #{recaptcha_v3_async_execute_function_name(action)}() {
194
+ return new Promise((resolve, reject) => {
195
+ grecaptcha.ready(async function() {
196
+ resolve(await grecaptcha.execute('#{site_key}', {action: '#{action}'}))
197
+ });
198
+ })
199
+ };
200
+
201
+ #{recaptcha_v3_define_default_callback(callback) if recaptcha_v3_define_default_callback?(callback, action, options)}
202
+ </script>
203
+ HTML
204
+ end
205
+
206
+ private_class_method def self.recaptcha_v3_inline_script?(options)
207
+ !Recaptcha.skip_env?(options[:env]) &&
208
+ options[:script] != false &&
209
+ options[:inline_script] != false
210
+ end
211
+
212
+ private_class_method def self.recaptcha_v3_define_default_callback(callback)
213
+ <<-HTML
214
+ var #{callback} = function(id, token) {
215
+ var element = document.getElementById(id);
216
+ element.value = token;
217
+ }
218
+ </script>
219
+ HTML
220
+ end
221
+
222
+ # Returns true if we should be adding the default callback.
223
+ # That is, if the given callback name is the default callback name (for the given action) and we
224
+ # are not skipping inline scripts for any reason.
225
+ private_class_method def self.recaptcha_v3_define_default_callback?(callback, action, options)
226
+ callback == recaptcha_v3_default_callback_name(action) &&
227
+ recaptcha_v3_inline_script?(options)
228
+ end
229
+
230
+ # Returns the name of the JavaScript function that actually executes the reCAPTCHA code (calls
231
+ # grecaptcha.execute). You can call it again later to reset it.
232
+ def self.recaptcha_v3_execute_function_name(action)
233
+ "executeRecaptchaFor#{sanitize_action_for_js(action)}"
234
+ end
235
+
236
+ # Returns the name of an async JavaScript function that executes the reCAPTCHA code.
237
+ def self.recaptcha_v3_async_execute_function_name(action)
238
+ "#{recaptcha_v3_execute_function_name(action)}Async"
239
+ end
240
+
241
+ def self.recaptcha_v3_default_callback_name(action)
242
+ "setInputWithRecaptchaResponseTokenFor#{sanitize_action_for_js(action)}"
243
+ end
244
+
245
+ # v2
246
+
247
+ private_class_method def self.default_callback(options = {})
248
+ nonce = options[:nonce]
249
+ nonce_attr = " nonce='#{nonce}'" if nonce
250
+
251
+ <<-HTML
252
+ <script#{nonce_attr}>
253
+ var invisibleRecaptchaSubmit = function () {
254
+ var closestForm = function (ele) {
255
+ var curEle = ele.parentNode;
256
+ while (curEle.nodeName !== 'FORM' && curEle.nodeName !== 'BODY'){
257
+ curEle = curEle.parentNode;
258
+ }
259
+ return curEle.nodeName === 'FORM' ? curEle : null
260
+ };
261
+
262
+ var eles = document.getElementsByClassName('g-recaptcha');
263
+ if (eles.length > 0) {
264
+ var form = closestForm(eles[0]);
265
+ if (form) {
266
+ form.submit();
267
+ }
268
+ }
269
+ };
270
+ </script>
271
+ HTML
272
+ end
273
+
274
+ private_class_method def self.default_callback_required?(options)
275
+ options[:callback] == 'invisibleRecaptchaSubmit' &&
276
+ !Recaptcha.skip_env?(options[:env]) &&
277
+ options[:script] != false &&
278
+ options[:inline_script] != false
279
+ end
280
+
281
+ # Returns a camelized string that is safe for use in a JavaScript variable/function name.
282
+ # sanitize_action_for_js('my/action') => 'MyAction'
283
+ private_class_method def self.sanitize_action_for_js(action)
284
+ action.to_s.gsub(/\W/, '_').camelize
285
+ end
286
+
287
+ # Returns a dasherized string that is safe for use as an HTML ID
288
+ # dasherize_action('my/action') => 'my-action'
289
+ private_class_method def self.dasherize_action(action)
290
+ action.to_s.gsub(/\W/, '-').dasherize
291
+ end
292
+
293
+ private_class_method def self.hash_to_query(hash)
294
+ hash.delete_if { |_, val| val.nil? || val.empty? }.to_a.map { |pair| pair.join('=') }.join('&')
295
+ end
296
+ end
297
+ end