Fixes #5863 - Form spam protection is inadequate for contemporary threat levels.
Approved-by: Dusan Vuckovic <dv@zammad.com> Co-authored-by: Florian Liebe <fl@zammad.com>
This commit is contained in:
@@ -9,6 +9,14 @@
|
||||
"matchStrings": ["DEVCONTAINERS_CLI_VERSION: '(?<currentValue>[^']+)'"],
|
||||
"depNameTemplate": "@devcontainers/cli",
|
||||
"datasourceTemplate": "npm"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^lib/form_spam_protection/captcha/.*\\.rb$/"
|
||||
],
|
||||
"matchStrings": ["cdn\\.jsdelivr\\.net/npm/(?<depName>[^@]+)@(?<currentValue>[^/]+)/"],
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
],
|
||||
"bundler": {
|
||||
|
||||
@@ -7,6 +7,26 @@ class ChannelForm extends App.ControllerSubContent
|
||||
'keyup form.js-paramsDesigner': 'updateParamsDesigner'
|
||||
'change .js-formSetting input': 'toggleFormSetting'
|
||||
'change .js-paramsSetting input': 'updateGroup'
|
||||
'change .js-spamProtection [name=form_ticket_create_honeypot]': 'toggleHoneypot'
|
||||
'change .js-spamProtection [name=form_ticket_create_captcha_provider]': 'updateCaptchaProvider'
|
||||
'change .js-spamProtection [name=sitekey]': 'updateCaptchaOptions'
|
||||
'change .js-spamProtection [name=secret]': 'updateCaptchaOptions'
|
||||
'change .js-spamProtection [name=project_id]': 'updateCaptchaOptions'
|
||||
'change .js-spamProtection [name=api_key]': 'updateCaptchaOptions'
|
||||
'change .js-spamProtection [name=min_score]': 'updateCaptchaOptions'
|
||||
|
||||
# coffeelint: disable=detect_translatable_string
|
||||
# Provider names are brand names; rendered through @T so translators can still adapt them.
|
||||
captchaProviders: [
|
||||
{ value: '', label: '-' }
|
||||
{ value: 'altcha', label: 'ALTCHA' }
|
||||
{ value: 'turnstile', label: 'Cloudflare Turnstile' }
|
||||
{ value: 'hcaptcha', label: 'hCaptcha' }
|
||||
{ value: 'friendly_captcha', label: 'Friendly Captcha' }
|
||||
{ value: 'recaptcha', label: 'Google reCAPTCHA' }
|
||||
{ value: 'recaptcha_enterprise', label: 'Google reCAPTCHA Enterprise' }
|
||||
]
|
||||
# coffeelint: enable=detect_translatable_string
|
||||
|
||||
elements:
|
||||
'.js-code': 'code'
|
||||
@@ -25,9 +45,24 @@ class ChannelForm extends App.ControllerSubContent
|
||||
render: =>
|
||||
setting = App.Setting.get('form_ticket_create')
|
||||
|
||||
captchaProvider = App.Setting.get('form_ticket_create_captcha_provider') or ''
|
||||
captchaOptions = App.Setting.get('form_ticket_create_captcha_options') or {}
|
||||
|
||||
element = $(App.view('channel/form')(
|
||||
baseurl: window.location.origin
|
||||
formSetting: setting
|
||||
baseurl: window.location.origin
|
||||
formSetting: setting
|
||||
honeypot: App.Setting.get('form_ticket_create_honeypot')
|
||||
captchaProviders: @captchaProviders
|
||||
captchaProvider: captchaProvider
|
||||
captchaNeedsSiteKey: @captchaNeedsSiteKey(captchaProvider)
|
||||
captchaNeedsSecret: @captchaNeedsSecret(captchaProvider)
|
||||
captchaIsEnterprise: @captchaIsEnterprise(captchaProvider)
|
||||
captchaNeedsScore: @captchaNeedsScore(captchaProvider)
|
||||
captchaSiteKey: captchaOptions.sitekey or ''
|
||||
captchaSecret: captchaOptions.secret or ''
|
||||
captchaProjectId: captchaOptions.project_id or ''
|
||||
captchaApiKey: captchaOptions.api_key or ''
|
||||
captchaMinScore: captchaOptions.min_score or ''
|
||||
))
|
||||
|
||||
group_id = App.Setting.get('form_ticket_create_group_id')
|
||||
@@ -109,4 +144,45 @@ class ChannelForm extends App.ControllerSubContent
|
||||
value = @paramsSetting.find('[name=group_id]').val()
|
||||
App.Setting.set('form_ticket_create_group_id', value)
|
||||
|
||||
captchaNeedsSiteKey: (provider) ->
|
||||
provider isnt '' and provider isnt 'altcha'
|
||||
|
||||
captchaNeedsSecret: (provider) ->
|
||||
provider in ['turnstile', 'hcaptcha', 'friendly_captcha', 'recaptcha']
|
||||
|
||||
captchaIsEnterprise: (provider) ->
|
||||
provider is 'recaptcha_enterprise'
|
||||
|
||||
captchaNeedsScore: (provider) ->
|
||||
provider in ['recaptcha', 'recaptcha_enterprise']
|
||||
|
||||
toggleHoneypot: (e) ->
|
||||
App.Setting.set('form_ticket_create_honeypot', $(e.currentTarget).prop('checked'))
|
||||
|
||||
updateCaptchaProvider: (e) =>
|
||||
provider = $(e.currentTarget).val()
|
||||
App.Setting.set('form_ticket_create_captcha_provider', provider)
|
||||
|
||||
# clear the previous provider's credentials so they are neither shown nor kept
|
||||
for name in ['sitekey', 'secret', 'project_id', 'api_key', 'min_score']
|
||||
@$(".js-spamProtection [name=#{name}]").val('')
|
||||
App.Setting.set('form_ticket_create_captcha_options', {})
|
||||
|
||||
@$('.js-captchaSiteKey').toggleClass('hide', !@captchaNeedsSiteKey(provider))
|
||||
@$('.js-captchaSecret').toggleClass('hide', !@captchaNeedsSecret(provider))
|
||||
@$('.js-captchaEnterprise').toggleClass('hide', !@captchaIsEnterprise(provider))
|
||||
@$('.js-captchaScore').toggleClass('hide', !@captchaNeedsScore(provider))
|
||||
|
||||
updateCaptchaOptions: =>
|
||||
options = {}
|
||||
for field in ['sitekey', 'secret', 'project_id', 'api_key']
|
||||
value = @$(".js-spamProtection [name=#{field}]:visible").val()
|
||||
options[field] = value if value
|
||||
|
||||
# only persist a valid score threshold (0–1); otherwise the server falls back to its default
|
||||
minScore = parseFloat(@$('.js-spamProtection [name=min_score]:visible').val())
|
||||
options.min_score = minScore if not isNaN(minScore) and minScore >= 0 and minScore <= 1
|
||||
|
||||
App.Setting.set('form_ticket_create_captcha_options', options)
|
||||
|
||||
App.Config.set('Form', { prio: 2000, name: __('Form'), parent: '#channels', target: '#channels/form', controller: ChannelForm, permission: ['admin.channel_formular'] }, 'NavBarAdmin')
|
||||
|
||||
@@ -22,6 +22,88 @@
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<h2><%- @T('Spam protection') %></h2>
|
||||
<form class="js-spamProtection">
|
||||
<fieldset>
|
||||
<div class="input form-group">
|
||||
<label class="inline-label">
|
||||
<span class="checkbox-replacement checkbox-replacement--inline">
|
||||
<input type="checkbox" name="form_ticket_create_honeypot" value="true" <% if @honeypot: %>checked<% end %>>
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</span>
|
||||
<%- @T('Enable honeypot protection (an invisible field that traps automated submissions).') %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form-captcha-provider"><%- @T('CAPTCHA provider') %></label>
|
||||
</div>
|
||||
<div class="controls controls--select">
|
||||
<select id="form-captcha-provider" class="form-control" name="form_ticket_create_captcha_provider">
|
||||
<% for option in @captchaProviders: %>
|
||||
<option value="<%= option.value %>" <% if option.value is @captchaProvider: %>selected<% end %>><%- @T(option.label) %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<%- @Icon('arrow-down') %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="js-captchaSiteKey <% if not @captchaNeedsSiteKey: %>hide<% end %>">
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form-captcha-site-key"><%- @T('Site key') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input type="text" id="form-captcha-site-key" name="sitekey" value="<%= @captchaSiteKey %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="js-captchaSecret <% if not @captchaNeedsSecret: %>hide<% end %>">
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form-captcha-secret"><%- @T('Secret') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input type="password" id="form-captcha-secret" name="secret" value="<%= @captchaSecret %>" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="js-captchaEnterprise <% if not @captchaIsEnterprise: %>hide<% end %>">
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form-captcha-project-id"><%- @T('Project ID') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input type="text" id="form-captcha-project-id" name="project_id" value="<%= @captchaProjectId %>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form-captcha-api-key"><%- @T('API key') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input type="password" id="form-captcha-api-key" name="api_key" value="<%= @captchaApiKey %>" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="js-captchaScore <% if not @captchaNeedsScore: %>hide<% end %>">
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form-captcha-min-score"><%- @T('Minimum score') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input type="text" id="form-captcha-min-score" name="min_score" value="<%= @captchaMinScore %>" placeholder="0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<h2><%- @T('Designer') %></h2>
|
||||
<form class="js-paramsDesigner">
|
||||
|
||||
|
||||
@@ -2439,6 +2439,11 @@ fieldset > .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Form channel spam protection: stacked fields with a medium control width.
|
||||
.js-spamProtection .controls {
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.controls--select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class FormController < ApplicationController
|
||||
prepend_before_action -> { authorize! }, only: %i[configuration submit]
|
||||
prepend_before_action -> { authorize! }, only: %i[configuration submit captcha_challenge]
|
||||
|
||||
skip_before_action :verify_csrf_token
|
||||
before_action :cors_preflight_check
|
||||
@@ -18,9 +18,10 @@ class FormController < ApplicationController
|
||||
endpoint = "#{http_type}://#{fqdn}#{api_path}/form_submit"
|
||||
|
||||
result = {
|
||||
enabled: Setting.get('form_ticket_create'),
|
||||
endpoint: endpoint,
|
||||
token: token_gen(params[:fingerprint])
|
||||
enabled: Setting.get('form_ticket_create'),
|
||||
endpoint: endpoint,
|
||||
token: token_gen(params[:fingerprint]),
|
||||
spam_protection: FormSpamProtection.frontend_config,
|
||||
}
|
||||
|
||||
if authorized?(policy_record, :test?)
|
||||
@@ -34,11 +35,27 @@ class FormController < ApplicationController
|
||||
return if !fingerprint_exists?
|
||||
return if !token_valid?(params[:token], params[:fingerprint])
|
||||
|
||||
submission = spam_protection_submission
|
||||
|
||||
# Stateless checks (honeypot) first: reject obvious bots before the
|
||||
# (potentially expensive) field validation, and without consuming anything.
|
||||
if !FormSpamProtection.verify_request(submission)
|
||||
render json: spam_protection_error, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
if (errors = validate_params) && errors.present?
|
||||
render json: { errors: }, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Verify the single-use CAPTCHA only after the rest of the form is valid, so an
|
||||
# invalid field does not consume a solved challenge and break the next attempt.
|
||||
if !FormSpamProtection.verify_challenge(submission)
|
||||
render json: spam_protection_error, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
customer = fetch_customer
|
||||
|
||||
ticket = UserInfo.with_user_id(customer.id) do
|
||||
@@ -60,6 +77,17 @@ class FormController < ApplicationController
|
||||
render json: result, status: :ok
|
||||
end
|
||||
|
||||
# Serves a fresh, signed challenge for the configured CAPTCHA provider (e.g. ALTCHA)
|
||||
# for the widget to fetch and solve. Providers whose challenges come from their own
|
||||
# service (Turnstile, hCaptcha, …) issue no challenge here, so this returns 404.
|
||||
def captcha_challenge
|
||||
challenge = FormSpamProtection::Captcha.configured_provider&.challenge
|
||||
|
||||
return head :not_found if challenge.nil?
|
||||
|
||||
render json: challenge, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# we don't wann to tell what the cause for the authorization error is
|
||||
@@ -114,6 +142,14 @@ class FormController < ApplicationController
|
||||
true
|
||||
end
|
||||
|
||||
def spam_protection_submission
|
||||
FormSpamProtection::Submission.new(params:, request:)
|
||||
end
|
||||
|
||||
def spam_protection_error
|
||||
{ errors: { spam: __('Your submission could not be verified. Please make sure you completed any verification challenge and try again.') } }
|
||||
end
|
||||
|
||||
def fingerprint_exists?
|
||||
return true if params[:fingerprint].present? && params[:fingerprint].length > 30
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class SystemReport::Plugin::Setting < SystemReport::Plugin
|
||||
SENSITIVE_SETTING_NAMES = %w[secret auth_ password pw credential endpoint_key _config _token recovery_codes pwd].freeze
|
||||
SENSITIVE_SETTING_NAMES = %w[secret auth_ password pw credential endpoint_key _config _token recovery_codes pwd captcha_options].freeze
|
||||
|
||||
DESCRIPTION = __('Current state of configured settings (excluding passwords and tokens)').freeze
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ class Controllers::FormControllerPolicy < Controllers::ApplicationControllerPoli
|
||||
authorized?
|
||||
end
|
||||
|
||||
def captcha_challenge?
|
||||
authorized?
|
||||
end
|
||||
|
||||
def test?
|
||||
record.params[:test] && user&.permissions?('admin.channel_formular')
|
||||
end
|
||||
|
||||
@@ -4,7 +4,8 @@ Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
# forms
|
||||
match api_path + '/form_submit', to: 'form#submit', via: :post
|
||||
match api_path + '/form_config', to: 'form#configuration', via: :post
|
||||
match api_path + '/form_submit', to: 'form#submit', via: :post
|
||||
match api_path + '/form_config', to: 'form#configuration', via: :post
|
||||
match api_path + '/form_captcha_challenge', to: 'form#captcha_challenge', via: :get
|
||||
|
||||
end
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class Issue5863FormSpamProtectionSettings < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
# return if it's a new setup
|
||||
return if !Setting.exists?(name: 'system_init_done')
|
||||
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Honeypot spam protection',
|
||||
name: 'form_ticket_create_honeypot',
|
||||
area: 'Form::SpamProtection',
|
||||
description: 'Adds an invisible field to the web form and rejects submissions that fill it in, which automated clients tend to do.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'form_ticket_create_honeypot',
|
||||
tag: 'boolean',
|
||||
options: {
|
||||
true => 'yes',
|
||||
false => 'no',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
state: true,
|
||||
preferences: {
|
||||
permission: ['admin.channel_formular'],
|
||||
},
|
||||
frontend: false,
|
||||
)
|
||||
|
||||
Setting.create_if_not_exists(
|
||||
title: 'CAPTCHA provider',
|
||||
name: 'form_ticket_create_captcha_provider',
|
||||
area: 'Form::SpamProtection',
|
||||
description: 'Defines the CAPTCHA provider used to protect the web form. Leave empty to disable. The list of available providers is derived from the registered FormSpamProtection::Captcha backends.',
|
||||
state: '',
|
||||
preferences: {
|
||||
permission: ['admin.channel_formular'],
|
||||
},
|
||||
frontend: false,
|
||||
)
|
||||
|
||||
Setting.create_if_not_exists(
|
||||
title: 'CAPTCHA provider options',
|
||||
name: 'form_ticket_create_captcha_options',
|
||||
area: 'Form::SpamProtection',
|
||||
description: 'Stores the credentials (e.g. site key and secret) of the selected CAPTCHA provider.',
|
||||
state: {},
|
||||
preferences: {
|
||||
permission: ['admin.channel_formular'],
|
||||
},
|
||||
frontend: false,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -3161,6 +3161,54 @@ Setting.create_if_not_exists(
|
||||
frontend: false,
|
||||
)
|
||||
|
||||
Setting.create_if_not_exists(
|
||||
title: __('Honeypot spam protection'),
|
||||
name: 'form_ticket_create_honeypot',
|
||||
area: 'Form::SpamProtection',
|
||||
description: __('Adds an invisible field to the web form and rejects submissions that fill it in, which automated clients tend to do.'),
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'form_ticket_create_honeypot',
|
||||
tag: 'boolean',
|
||||
options: {
|
||||
true => 'yes',
|
||||
false => 'no',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
state: true,
|
||||
preferences: {
|
||||
permission: ['admin.channel_formular'],
|
||||
},
|
||||
frontend: false,
|
||||
)
|
||||
Setting.create_if_not_exists(
|
||||
title: __('CAPTCHA provider'),
|
||||
name: 'form_ticket_create_captcha_provider',
|
||||
area: 'Form::SpamProtection',
|
||||
description: __('Defines the CAPTCHA provider used to protect the web form. Leave empty to disable. The list of available providers is derived from the registered FormSpamProtection::Captcha backends.'),
|
||||
state: '',
|
||||
preferences: {
|
||||
permission: ['admin.channel_formular'],
|
||||
},
|
||||
frontend: false,
|
||||
)
|
||||
Setting.create_if_not_exists(
|
||||
title: __('CAPTCHA provider options'),
|
||||
name: 'form_ticket_create_captcha_options',
|
||||
area: 'Form::SpamProtection',
|
||||
description: __('Stores the credentials (e.g. site key and secret) of the selected CAPTCHA provider.'),
|
||||
state: {},
|
||||
preferences: {
|
||||
permission: ['admin.channel_formular'],
|
||||
},
|
||||
frontend: false,
|
||||
)
|
||||
|
||||
Setting.create_if_not_exists(
|
||||
title: __('Form Allowed Parameters'),
|
||||
name: 'form_allowed_params',
|
||||
|
||||
727
i18n/zammad.pot
727
i18n/zammad.pot
File diff suppressed because it is too large
Load Diff
46
lib/form_spam_protection.rb
Normal file
46
lib/form_spam_protection.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Modular spam protection for the web form channel. Combines an always-on
|
||||
# honeypot check with an optionally configured CAPTCHA provider.
|
||||
module FormSpamProtection
|
||||
# Bundles everything a check needs to evaluate a single form submission.
|
||||
Submission = Struct.new(:params, :request, keyword_init: true)
|
||||
|
||||
# Public, secret-free configuration surfaced to the form widget via /form_config.
|
||||
#
|
||||
# @return [Hash]
|
||||
def self.frontend_config
|
||||
config = {}
|
||||
|
||||
if Setting.get('form_ticket_create_honeypot')
|
||||
config[:honeypot] = { field: FormSpamProtection::Honeypot::FIELD_NAME }
|
||||
end
|
||||
|
||||
if (captcha = FormSpamProtection::Captcha.configured_provider)
|
||||
config[:captcha] = captcha.frontend_config
|
||||
end
|
||||
|
||||
config
|
||||
end
|
||||
|
||||
# Stateless request checks (honeypot). Safe to run before field validation, as
|
||||
# they consume nothing.
|
||||
#
|
||||
# @param submission [Submission]
|
||||
#
|
||||
# @return [Boolean]
|
||||
def self.verify_request(submission)
|
||||
FormSpamProtection::Verifier.new(submission).request_valid?
|
||||
end
|
||||
|
||||
# The configured single-use CAPTCHA challenge (true when none is configured).
|
||||
# Run only after field validation, so a field error does not consume a solved
|
||||
# challenge and break the next attempt.
|
||||
#
|
||||
# @param submission [Submission]
|
||||
#
|
||||
# @return [Boolean]
|
||||
def self.verify_challenge(submission)
|
||||
FormSpamProtection::Verifier.new(submission).challenge_valid?
|
||||
end
|
||||
end
|
||||
75
lib/form_spam_protection/captcha.rb
Normal file
75
lib/form_spam_protection/captcha.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Base class and registry for the form channel's CAPTCHA providers. Concrete
|
||||
# providers live under form_spam_protection/captcha/ (loaded automatically via
|
||||
# Mixin::RequiredSubPaths) and are discovered through the inheritance tree, so a
|
||||
# new provider only needs to subclass this and declare a key/title — no central
|
||||
# list to maintain. Siteverify-style providers additionally include
|
||||
# Concerns::TokenVerification for the shared HTTP verification flow.
|
||||
class FormSpamProtection::Captcha
|
||||
include Mixin::RequiredSubPaths
|
||||
|
||||
# @return [String, nil] stable identifier and setting value; concrete providers override this.
|
||||
def self.key; end
|
||||
|
||||
# @return [String, nil] human-readable name for the admin UI; concrete providers override this.
|
||||
def self.title; end
|
||||
|
||||
# @return [Array<Class>] all selectable provider classes (abstract bases excluded).
|
||||
def self.providers
|
||||
descendants.select { |klass| klass.key.present? }
|
||||
end
|
||||
|
||||
# @return [Class, nil]
|
||||
def self.by_key(key)
|
||||
return if key.blank?
|
||||
|
||||
providers.find { |klass| klass.key == key }
|
||||
end
|
||||
|
||||
# @return [FormSpamProtection::Captcha, nil] instance of the configured provider.
|
||||
def self.configured_provider
|
||||
by_key(Setting.get('form_ticket_create_captcha_provider'))&.new
|
||||
end
|
||||
|
||||
# @return [Hash] secret-free configuration for the embeddable form widget.
|
||||
def frontend_config
|
||||
{ provider: self.class.key }.merge(widget_config)
|
||||
end
|
||||
|
||||
# @param submission [FormSpamProtection::Submission]
|
||||
#
|
||||
# @return [Boolean]
|
||||
def verify(submission)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
# Providers that issue a server-side challenge (e.g. self-hosted proof-of-work)
|
||||
# override this; it is served via the form captcha-challenge endpoint. Providers
|
||||
# whose challenges come from their own service return nil.
|
||||
#
|
||||
# @return [Hash, nil]
|
||||
def challenge
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def widget_config
|
||||
{}
|
||||
end
|
||||
|
||||
def options
|
||||
@options ||= (Setting.get('form_ticket_create_captcha_options') || {}).with_indifferent_access
|
||||
end
|
||||
|
||||
def site_key
|
||||
# Stored as `sitekey` (not `site_key`): the public site key is not a secret, but
|
||||
# a `_key` suffix would trip the settings masking and hide it from the admin.
|
||||
options[:sitekey]
|
||||
end
|
||||
|
||||
def secret
|
||||
options[:secret]
|
||||
end
|
||||
end
|
||||
127
lib/form_spam_protection/captcha/altcha.rb
Normal file
127
lib/form_spam_protection/captcha/altcha.rb
Normal file
@@ -0,0 +1,127 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Self-hosted ALTCHA proof-of-work CAPTCHA. Challenges are generated and verified
|
||||
# entirely within Zammad (HMAC-signed with the application secret), so no form data
|
||||
# leaves the instance — making it the GDPR-friendliest option. (The widget script
|
||||
# itself is loaded from a CDN, like the other providers' widgets.)
|
||||
#
|
||||
# @see https://altcha.org/docs/
|
||||
class FormSpamProtection::Captcha::Altcha < FormSpamProtection::Captcha
|
||||
ALGORITHM = 'SHA-256'.freeze
|
||||
MAX_NUMBER = 50_000
|
||||
CHALLENGE_TTL = 1.hour
|
||||
# Pinned widget version: the server owns ALTCHA verification, so a widget upgrade
|
||||
# (payload/protocol change) must be a deliberate, tested change, not picked up silently.
|
||||
SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/altcha@3.1.0/dist/main/altcha.min.js'.freeze
|
||||
RESPONSE_FIELD = 'altcha'.freeze
|
||||
|
||||
def self.key
|
||||
'altcha'
|
||||
end
|
||||
|
||||
def self.title
|
||||
'ALTCHA'
|
||||
end
|
||||
|
||||
def verify(submission)
|
||||
payload = decode_payload(submission.params[RESPONSE_FIELD])
|
||||
return false if payload.nil?
|
||||
|
||||
valid_payload?(payload) &&
|
||||
!expired?(payload['salt']) &&
|
||||
valid_solution?(payload) &&
|
||||
valid_signature?(payload['challenge'], payload['signature']) &&
|
||||
!replay?(payload['signature'])
|
||||
end
|
||||
|
||||
# Builds a signed, time-limited proof-of-work challenge. Served by the challenge
|
||||
# endpoint and fetched fresh by the widget on each render, so a solved challenge is
|
||||
# never reused.
|
||||
def challenge
|
||||
salt = "#{SecureRandom.hex(12)}?expires=#{(Time.zone.now + CHALLENGE_TTL).to_i}"
|
||||
number = SecureRandom.random_number(MAX_NUMBER)
|
||||
digest = hash_challenge(salt, number)
|
||||
|
||||
{
|
||||
algorithm: ALGORITHM,
|
||||
challenge: digest,
|
||||
maxnumber: MAX_NUMBER,
|
||||
salt:,
|
||||
signature: sign(digest),
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def widget_config
|
||||
{
|
||||
type: 'altcha',
|
||||
script_url: SCRIPT_URL,
|
||||
module: true,
|
||||
response_field: RESPONSE_FIELD,
|
||||
challenge_url: challenge_url,
|
||||
}
|
||||
end
|
||||
|
||||
def challenge_url
|
||||
"#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/form_captcha_challenge"
|
||||
end
|
||||
|
||||
def hash_challenge(salt, number)
|
||||
Digest::SHA256.hexdigest("#{salt}#{number}")
|
||||
end
|
||||
|
||||
def sign(challenge)
|
||||
OpenSSL::HMAC.hexdigest('SHA256', Setting.get('application_secret'), challenge)
|
||||
end
|
||||
|
||||
def decode_payload(value)
|
||||
return if value.blank?
|
||||
|
||||
JSON.parse(Base64.decode64(value))
|
||||
rescue JSON::ParserError, ArgumentError
|
||||
Rails.logger.debug 'Form spam protection: rejected submission (malformed ALTCHA payload).'
|
||||
nil
|
||||
end
|
||||
|
||||
def valid_payload?(payload)
|
||||
return true if payload.is_a?(Hash) && payload['algorithm'] == ALGORITHM && payload['salt'].present? && payload['challenge'].present? && payload['signature'].present?
|
||||
|
||||
Rails.logger.debug 'Form spam protection: rejected submission (incomplete ALTCHA payload).'
|
||||
false
|
||||
end
|
||||
|
||||
def expired?(salt)
|
||||
expires = salt.to_s[%r{expires=(\d+)}, 1]
|
||||
return false if expires.blank?
|
||||
return false if Time.zone.now.to_i <= expires.to_i
|
||||
|
||||
Rails.logger.debug 'Form spam protection: rejected submission (expired ALTCHA challenge).'
|
||||
true
|
||||
end
|
||||
|
||||
def valid_solution?(payload)
|
||||
expected = hash_challenge(payload['salt'], payload['number'])
|
||||
return true if ActiveSupport::SecurityUtils.secure_compare(expected, payload['challenge'].to_s)
|
||||
|
||||
Rails.logger.debug 'Form spam protection: rejected submission (invalid ALTCHA solution).'
|
||||
false
|
||||
end
|
||||
|
||||
def valid_signature?(challenge, signature)
|
||||
return true if ActiveSupport::SecurityUtils.secure_compare(sign(challenge), signature.to_s)
|
||||
|
||||
Rails.logger.debug 'Form spam protection: rejected submission (invalid ALTCHA signature).'
|
||||
false
|
||||
end
|
||||
|
||||
# Each solved challenge may be used only once within its validity window. The
|
||||
# write is atomic (unless_exist) so concurrent submissions can't both pass.
|
||||
def replay?(signature)
|
||||
cache_key = "form_spam_protection/altcha/#{signature}"
|
||||
return false if Rails.cache.write(cache_key, true, expires_in: CHALLENGE_TTL, unless_exist: true)
|
||||
|
||||
Rails.logger.debug 'Form spam protection: rejected submission (ALTCHA solution reused).'
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class FormSpamProtection::Captcha
|
||||
# Shared verification for CAPTCHA providers that render a widget which yields a
|
||||
# response token, verified server-side via an HTTP "siteverify" call. Including
|
||||
# providers define #provider_config (verify_url, script_url, widget_class,
|
||||
# response_field and, optionally, global — the JS object exposing a render
|
||||
# function).
|
||||
module Concerns::TokenVerification
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def verify(submission)
|
||||
response = submission.params[provider_config[:response_field]]
|
||||
if response.blank?
|
||||
Rails.logger.debug { "Form spam protection: rejected submission (missing #{self.class.key} response)." }
|
||||
return false
|
||||
end
|
||||
|
||||
if secret.blank?
|
||||
Rails.logger.error "Form spam protection: #{self.class.key} is selected but no secret is configured."
|
||||
return false
|
||||
end
|
||||
|
||||
data = siteverify(response, submission.request)
|
||||
return false if data.nil?
|
||||
|
||||
return true if accepted?(data)
|
||||
|
||||
Rails.logger.debug { "Form spam protection: rejected submission (#{self.class.key} verification failed)." }
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @return [Hash] provider endpoints and widget wiring; defined by each provider.
|
||||
def provider_config
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
# @return [Boolean] whether the provider response counts as a pass; overridden
|
||||
# by score-based providers (e.g. reCAPTCHA v3) to also require a minimum score.
|
||||
def accepted?(data)
|
||||
data['success'] == true
|
||||
end
|
||||
|
||||
def widget_config
|
||||
config = provider_config
|
||||
|
||||
{
|
||||
type: config[:type] || 'widget',
|
||||
script_url: config[:script_url],
|
||||
widget_class: config[:widget_class],
|
||||
response_field: config[:response_field],
|
||||
global: config[:global],
|
||||
site_key: site_key,
|
||||
action: config[:action],
|
||||
widget_instance: config[:widget_instance],
|
||||
}
|
||||
end
|
||||
|
||||
# @return [Hash, nil] parsed provider response, or nil on a transport failure.
|
||||
def siteverify(response, request)
|
||||
result = UserAgent.post(provider_config[:verify_url], siteverify_params(response, request).compact, { verify_ssl: true })
|
||||
|
||||
if !result.success?
|
||||
Rails.logger.error "Form spam protection: #{self.class.key} request failed (HTTP #{result.code})."
|
||||
return nil
|
||||
end
|
||||
|
||||
JSON.parse(result.body.to_s)
|
||||
rescue => e
|
||||
Rails.logger.error "Form spam protection: #{self.class.key} verification error (#{e.message})."
|
||||
nil
|
||||
end
|
||||
|
||||
def siteverify_params(response, request)
|
||||
{
|
||||
secret: secret,
|
||||
response: response,
|
||||
remoteip: request&.remote_ip,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
41
lib/form_spam_protection/captcha/friendly_captcha.rb
Normal file
41
lib/form_spam_protection/captcha/friendly_captcha.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Friendly Captcha provider — a proof-of-work widget that is GDPR-friendly and
|
||||
# EU-hosted. Currently integrates the `friendly-challenge` widget; the newer
|
||||
# @friendlycaptcha/sdk widget is a drop-in successor under the same provider.
|
||||
class FormSpamProtection::Captcha::FriendlyCaptcha < FormSpamProtection::Captcha
|
||||
include FormSpamProtection::Captcha::Concerns::TokenVerification
|
||||
|
||||
def self.key
|
||||
'friendly_captcha'
|
||||
end
|
||||
|
||||
def self.title
|
||||
__('Friendly Captcha')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_config
|
||||
{
|
||||
verify_url: 'https://api.friendlycaptcha.com/api/v1/siteverify',
|
||||
script_url: 'https://cdn.jsdelivr.net/npm/friendly-challenge@0.9.20/widget.min.js',
|
||||
widget_class: 'frc-captcha',
|
||||
response_field: 'frc-captcha-solution',
|
||||
global: 'friendlyChallenge',
|
||||
# The friendly-challenge widget has no render() and only auto-scans once; late
|
||||
# widgets are initialized in the form widget via `new friendlyChallenge.WidgetInstance(...)`.
|
||||
widget_instance: true,
|
||||
}
|
||||
end
|
||||
|
||||
# Friendly Captcha's siteverify expects `solution` and `sitekey` instead of
|
||||
# the `response`/`remoteip` shape used by the other providers.
|
||||
def siteverify_params(response, _request)
|
||||
{
|
||||
solution: response,
|
||||
secret: secret,
|
||||
sitekey: site_key,
|
||||
}
|
||||
end
|
||||
end
|
||||
26
lib/form_spam_protection/captcha/hcaptcha.rb
Normal file
26
lib/form_spam_protection/captcha/hcaptcha.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# hCaptcha CAPTCHA provider.
|
||||
class FormSpamProtection::Captcha::Hcaptcha < FormSpamProtection::Captcha
|
||||
include FormSpamProtection::Captcha::Concerns::TokenVerification
|
||||
|
||||
def self.key
|
||||
'hcaptcha'
|
||||
end
|
||||
|
||||
def self.title
|
||||
'hCaptcha'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_config
|
||||
{
|
||||
verify_url: 'https://api.hcaptcha.com/siteverify',
|
||||
script_url: 'https://js.hcaptcha.com/1/api.js',
|
||||
widget_class: 'h-captcha',
|
||||
response_field: 'h-captcha-response',
|
||||
global: 'hcaptcha',
|
||||
}
|
||||
end
|
||||
end
|
||||
42
lib/form_spam_protection/captcha/recaptcha.rb
Normal file
42
lib/form_spam_protection/captcha/recaptcha.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Google reCAPTCHA v3 provider. Invisible and score-based: the widget runs no
|
||||
# challenge, a token is fetched on submit, and the siteverify response is
|
||||
# accepted only when its risk score meets the configured minimum.
|
||||
#
|
||||
# reCAPTCHA Enterprise (different credentials + assessment API) is intended as a
|
||||
# separate provider sharing the same invisible "score" frontend.
|
||||
class FormSpamProtection::Captcha::Recaptcha < FormSpamProtection::Captcha
|
||||
include FormSpamProtection::Captcha::Concerns::TokenVerification
|
||||
|
||||
DEFAULT_MIN_SCORE = 0.5
|
||||
|
||||
def self.key
|
||||
'recaptcha'
|
||||
end
|
||||
|
||||
def self.title
|
||||
__('Google reCAPTCHA')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_config
|
||||
{
|
||||
type: 'score',
|
||||
verify_url: 'https://www.google.com/recaptcha/api/siteverify',
|
||||
script_url: "https://www.google.com/recaptcha/api.js?render=#{site_key}",
|
||||
response_field: 'g-recaptcha-response',
|
||||
global: 'grecaptcha',
|
||||
action: 'submit',
|
||||
}
|
||||
end
|
||||
|
||||
def accepted?(data)
|
||||
super && data['score'].to_f >= min_score
|
||||
end
|
||||
|
||||
def min_score
|
||||
options[:min_score].presence&.to_f || DEFAULT_MIN_SCORE
|
||||
end
|
||||
end
|
||||
90
lib/form_spam_protection/captcha/recaptcha_enterprise.rb
Normal file
90
lib/form_spam_protection/captcha/recaptcha_enterprise.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Google reCAPTCHA Enterprise provider. Shares the invisible, score-based
|
||||
# frontend with reCAPTCHA v3, but verifies via the Enterprise Assessment API
|
||||
# (a Cloud project ID + API key) instead of the classic siteverify endpoint.
|
||||
#
|
||||
# @see https://cloud.google.com/recaptcha/docs/create-assessment
|
||||
class FormSpamProtection::Captcha::RecaptchaEnterprise < FormSpamProtection::Captcha
|
||||
DEFAULT_MIN_SCORE = 0.5
|
||||
ACTION = 'submit'.freeze
|
||||
RESPONSE_FIELD = 'g-recaptcha-response'.freeze
|
||||
|
||||
def self.key
|
||||
'recaptcha_enterprise'
|
||||
end
|
||||
|
||||
def self.title
|
||||
__('Google reCAPTCHA Enterprise')
|
||||
end
|
||||
|
||||
def verify(submission)
|
||||
token = submission.params[RESPONSE_FIELD]
|
||||
if token.blank?
|
||||
Rails.logger.debug { "Form spam protection: rejected submission (missing #{self.class.key} response)." }
|
||||
return false
|
||||
end
|
||||
|
||||
if api_key.blank? || project_id.blank?
|
||||
Rails.logger.error "Form spam protection: #{self.class.key} is selected but project ID or API key is not configured."
|
||||
return false
|
||||
end
|
||||
|
||||
data = assess(token)
|
||||
return false if data.nil?
|
||||
|
||||
return true if accepted?(data)
|
||||
|
||||
Rails.logger.debug { "Form spam protection: rejected submission (#{self.class.key} verification failed)." }
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def widget_config
|
||||
{
|
||||
type: 'score',
|
||||
script_url: "https://www.google.com/recaptcha/enterprise.js?render=#{site_key}",
|
||||
response_field: RESPONSE_FIELD,
|
||||
global: 'grecaptcha.enterprise',
|
||||
site_key: site_key,
|
||||
action: ACTION,
|
||||
}
|
||||
end
|
||||
|
||||
# @return [Hash, nil] the parsed assessment, or nil on a transport failure.
|
||||
def assess(token)
|
||||
url = "https://recaptchaenterprise.googleapis.com/v1/projects/#{CGI.escape(project_id.to_s)}/assessments"
|
||||
params = { event: { token:, siteKey: site_key, expectedAction: ACTION } }
|
||||
|
||||
# Pass the API key via header instead of the URL query string, so it doesn't
|
||||
# end up in proxy/access logs.
|
||||
result = UserAgent.post(url, params, { verify_ssl: true, json: true, headers: { 'X-Goog-Api-Key' => api_key } })
|
||||
|
||||
if !result.success?
|
||||
Rails.logger.error "Form spam protection: #{self.class.key} request failed (HTTP #{result.code})."
|
||||
return nil
|
||||
end
|
||||
|
||||
result.data
|
||||
rescue => e
|
||||
Rails.logger.error "Form spam protection: #{self.class.key} verification error (#{e.message})."
|
||||
nil
|
||||
end
|
||||
|
||||
def accepted?(data)
|
||||
data.dig('tokenProperties', 'valid') == true && data.dig('riskAnalysis', 'score').to_f >= min_score
|
||||
end
|
||||
|
||||
def project_id
|
||||
options[:project_id]
|
||||
end
|
||||
|
||||
def api_key
|
||||
options[:api_key]
|
||||
end
|
||||
|
||||
def min_score
|
||||
options[:min_score].presence&.to_f || DEFAULT_MIN_SCORE
|
||||
end
|
||||
end
|
||||
26
lib/form_spam_protection/captcha/turnstile.rb
Normal file
26
lib/form_spam_protection/captcha/turnstile.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Cloudflare Turnstile CAPTCHA provider.
|
||||
class FormSpamProtection::Captcha::Turnstile < FormSpamProtection::Captcha
|
||||
include FormSpamProtection::Captcha::Concerns::TokenVerification
|
||||
|
||||
def self.key
|
||||
'turnstile'
|
||||
end
|
||||
|
||||
def self.title
|
||||
__('Cloudflare Turnstile')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_config
|
||||
{
|
||||
verify_url: 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
script_url: 'https://challenges.cloudflare.com/turnstile/v0/api.js',
|
||||
widget_class: 'cf-turnstile',
|
||||
response_field: 'cf-turnstile-response',
|
||||
global: 'turnstile',
|
||||
}
|
||||
end
|
||||
end
|
||||
18
lib/form_spam_protection/honeypot.rb
Normal file
18
lib/form_spam_protection/honeypot.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Rejects submissions where the invisible honeypot field was filled in. Real
|
||||
# users never see the field; automated clients that complete every field do.
|
||||
class FormSpamProtection::Honeypot
|
||||
# Namespaced so it can't collide with a real field an embedded form legitimately
|
||||
# collects (e.g. "website") and so the injected hidden field never overwrites it.
|
||||
FIELD_NAME = 'zammad_form_url'.freeze
|
||||
|
||||
def verify(submission)
|
||||
# Real users never touch the hidden field, so any content at all — including
|
||||
# whitespace — flags an automated client. (blank? would let " " through.)
|
||||
return true if submission.params[FIELD_NAME].to_s.empty?
|
||||
|
||||
Rails.logger.debug 'Form spam protection: rejected submission (honeypot field was filled in).'
|
||||
false
|
||||
end
|
||||
end
|
||||
32
lib/form_spam_protection/verifier.rb
Normal file
32
lib/form_spam_protection/verifier.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Runs the spam-protection checks for a single form submission. The checks are
|
||||
# split into stateless request checks (honeypot) and the single-use CAPTCHA
|
||||
# challenge, so the controller can run the challenge only after field validation
|
||||
# has passed (otherwise a field error would consume a solved token).
|
||||
class FormSpamProtection::Verifier
|
||||
def initialize(submission)
|
||||
@submission = submission
|
||||
end
|
||||
|
||||
# @return [Boolean] true when all enabled stateless request checks pass.
|
||||
def request_valid?
|
||||
request_checks.all? { |check| check.verify(@submission) }
|
||||
end
|
||||
|
||||
# @return [Boolean] true when the configured CAPTCHA passes, or none is configured.
|
||||
def challenge_valid?
|
||||
provider = FormSpamProtection::Captcha.configured_provider
|
||||
return true if provider.nil?
|
||||
|
||||
provider.verify(@submission)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_checks
|
||||
checks = []
|
||||
checks << FormSpamProtection::Honeypot.new if Setting.get('form_ticket_create_honeypot')
|
||||
checks
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,10 @@
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.zammad-form-captcha {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.zammad-form-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -386,8 +386,7 @@ $(function() {
|
||||
}
|
||||
|
||||
Plugin.prototype.init = function () {
|
||||
var _this = this,
|
||||
params = {}
|
||||
var _this = this
|
||||
|
||||
_this.log('debug', 'init', this._src)
|
||||
|
||||
@@ -420,11 +419,28 @@ $(function() {
|
||||
_this.log('debug', 'endpoint_config: ' + _this.endpoint_config)
|
||||
_this.log('debug', 'endpoint_submit: ' + _this.endpoint_submit)
|
||||
|
||||
// load config
|
||||
// Inline forms load the config now; modal forms load it when opened, so each
|
||||
// modal open gets a fresh single-use captcha challenge (and token).
|
||||
if (!this.options.modal) {
|
||||
_this.loadConfig()
|
||||
_this.render()
|
||||
}
|
||||
else {
|
||||
this.$element.off('click.zammad-form').on('click.zammad-form', function (e) {
|
||||
e.preventDefault()
|
||||
_this.render()
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fetch the form configuration (endpoint, token, spam protection) from the server
|
||||
Plugin.prototype.loadConfig = function() {
|
||||
var _this = this
|
||||
var params = {}
|
||||
if (this.options.test) {
|
||||
params.test = true
|
||||
}
|
||||
|
||||
params.fingerprint = this.fingerprint()
|
||||
|
||||
$.ajax({
|
||||
@@ -436,6 +452,7 @@ $(function() {
|
||||
}).done(function(data) {
|
||||
_this.log('debug', 'config:', data)
|
||||
_this._config = data
|
||||
_this.applySpamProtection()
|
||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||
if (jqXHR.status == 401) {
|
||||
_this.log('error', 'Faild to load form config, wrong authentication data!')
|
||||
@@ -447,21 +464,7 @@ $(function() {
|
||||
_this.log('error', 'Faild to load form config!')
|
||||
}
|
||||
_this.noConfig()
|
||||
});
|
||||
|
||||
// show form
|
||||
if (!this.options.modal) {
|
||||
_this.render()
|
||||
}
|
||||
|
||||
// bind form on call
|
||||
else {
|
||||
this.$element.off('click.zammad-form').on('click.zammad-form', function (e) {
|
||||
e.preventDefault()
|
||||
_this.render()
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// load css
|
||||
@@ -478,22 +481,58 @@ $(function() {
|
||||
Plugin.prototype.submit = function() {
|
||||
var _this = this
|
||||
|
||||
// check min modal open time
|
||||
if (_this.modalOpenTime) {
|
||||
var currentTime = new Date().getTime()
|
||||
var diff = currentTime - _this.modalOpenTime.getTime()
|
||||
_this.log('debug', 'currentTime', currentTime)
|
||||
_this.log('debug', 'modalOpenTime', _this.modalOpenTime.getTime())
|
||||
_this.log('debug', 'diffTime', diff)
|
||||
if (diff < 1000*10) {
|
||||
alert('Sorry, you look like a robot!')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// disable form
|
||||
_this.$form.find('button').prop('disabled', true)
|
||||
|
||||
// score-based captcha (reCAPTCHA v3 / Enterprise): fetch a fresh token, then send
|
||||
if (_this._scoreCaptcha) {
|
||||
_this.executeScoreCaptcha(function() { _this.sendSubmit() })
|
||||
return
|
||||
}
|
||||
|
||||
_this.sendSubmit()
|
||||
}
|
||||
|
||||
// fetch a token from an invisible score-based provider, fill the hidden field, then continue
|
||||
Plugin.prototype.executeScoreCaptcha = function(done) {
|
||||
var _this = this
|
||||
var captcha = _this._scoreCaptcha
|
||||
var provider = _this.resolveGlobal(captcha.global)
|
||||
|
||||
if (!provider || typeof provider.execute !== 'function') {
|
||||
// provider script not ready; let the server reject it as spam
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
var run = function() {
|
||||
provider.execute(captcha.site_key, { action: captcha.action || 'submit' }).then(function(token) {
|
||||
_this.$form.find('[name="' + captcha.response_field + '"]').val(token)
|
||||
done()
|
||||
}).catch(function() { done() })
|
||||
}
|
||||
|
||||
if (typeof provider.ready === 'function') {
|
||||
provider.ready(run)
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
// resolve a dotted global path (e.g. "grecaptcha.enterprise") from window
|
||||
Plugin.prototype.resolveGlobal = function(path) {
|
||||
var obj = window
|
||||
var parts = (path || '').split('.')
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (!obj) return null
|
||||
obj = obj[parts[i]]
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
Plugin.prototype.sendSubmit = function() {
|
||||
var _this = this
|
||||
|
||||
$.ajax({
|
||||
method: 'post',
|
||||
url: _this.endpoint_submit,
|
||||
@@ -515,8 +554,9 @@ $(function() {
|
||||
// Deprecated code, can be removed in future versions:
|
||||
_this.$form.find('[name=' + key + ']').closest('.form-group').addClass('has-error')
|
||||
})
|
||||
if (data.errors.token) {
|
||||
alert(data.errors.token)
|
||||
var alertMessage = data.errors.token || data.errors.spam
|
||||
if (alertMessage) {
|
||||
alert(alertMessage)
|
||||
}
|
||||
_this.$form.find('button').prop('disabled', false)
|
||||
return
|
||||
@@ -563,12 +603,117 @@ $(function() {
|
||||
}
|
||||
}
|
||||
|
||||
// inject honeypot field and captcha widget into the rendered form (idempotent;
|
||||
// safe to call again when the config is refreshed, e.g. on each modal open)
|
||||
Plugin.prototype.applySpamProtection = function() {
|
||||
var _this = this
|
||||
if (!_this.$form) {
|
||||
return
|
||||
}
|
||||
|
||||
// clear any previous injection so a refreshed config (new captcha challenge) re-renders cleanly
|
||||
_this.$form.find('.zammad-form-honeypot, .zammad-form-captcha, .js-zammad-form-score').remove()
|
||||
_this._scoreCaptcha = null
|
||||
|
||||
var config = _this._config.spam_protection
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
var $anchor = _this.$form.find('button[type=submit]')
|
||||
|
||||
// honeypot: an off-screen field that real users never fill in
|
||||
if (config.honeypot && config.honeypot.field) {
|
||||
var honeypot = $('<div class="zammad-form-honeypot" aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;height:0;overflow:hidden;"><input type="text" name="' + config.honeypot.field + '" tabindex="-1" autocomplete="off" value=""></div>')
|
||||
_this.insertSpamElement(honeypot, $anchor)
|
||||
}
|
||||
|
||||
if (config.captcha) {
|
||||
_this.renderCaptcha(config.captcha, $anchor)
|
||||
}
|
||||
}
|
||||
|
||||
Plugin.prototype.insertSpamElement = function($element, $anchor) {
|
||||
if ($anchor && $anchor.length) {
|
||||
$element.insertBefore($anchor)
|
||||
} else {
|
||||
this.$form.append($element)
|
||||
}
|
||||
}
|
||||
|
||||
// render the configured captcha provider widget
|
||||
Plugin.prototype.renderCaptcha = function(captcha, $anchor) {
|
||||
var _this = this
|
||||
|
||||
// score-based, invisible captcha (reCAPTCHA v3 / Enterprise): no visible widget,
|
||||
// a fresh token is fetched on submit via the provider's execute() call.
|
||||
if (captcha.type === 'score') {
|
||||
var $token = $('<input type="hidden" class="js-zammad-form-score" name="' + captcha.response_field + '" value="">')
|
||||
_this.insertSpamElement($token, $anchor)
|
||||
_this._scoreCaptcha = captcha
|
||||
_this.loadScript(captcha.script_url, false)
|
||||
return
|
||||
}
|
||||
|
||||
var $container = $('<div class="zammad-form-captcha"></div>')
|
||||
_this.insertSpamElement($container, $anchor)
|
||||
|
||||
// ALTCHA uses a custom element with an inline, signed proof-of-work challenge
|
||||
if (captcha.type === 'altcha') {
|
||||
var $altcha = $('<altcha-widget hidefooter></altcha-widget>')
|
||||
$altcha.attr('name', captcha.response_field)
|
||||
$altcha.attr('auto', 'onload')
|
||||
// v3: the widget fetches a fresh, single-use challenge from the server URL.
|
||||
// In the admin preview (test mode) the channel may still be disabled, so carry
|
||||
// the test flag through or the challenge endpoint would answer 403.
|
||||
var challengeUrl = captcha.challenge_url
|
||||
if (_this.options.test) {
|
||||
challengeUrl += (challengeUrl.indexOf('?') === -1 ? '?' : '&') + 'test=1'
|
||||
}
|
||||
$altcha.attr('challenge', challengeUrl)
|
||||
$container.append($altcha)
|
||||
_this.loadScript(captcha.script_url, true)
|
||||
return
|
||||
}
|
||||
|
||||
// token-based providers with a visible widget (Turnstile, hCaptcha, Friendly Captcha)
|
||||
var $widget = $('<div class="' + captcha.widget_class + '" data-sitekey="' + captcha.site_key + '"></div>')
|
||||
$container.append($widget)
|
||||
|
||||
// When the provider script is already loaded (e.g. a modal opened after an inline form
|
||||
// loaded it), the one-time auto-scan won't pick up this late widget, so initialize it
|
||||
// explicitly. Otherwise load the script and let it render the widget on load.
|
||||
var provider = _this.resolveGlobal(captcha.global)
|
||||
if (provider && captcha.widget_instance && typeof provider.WidgetInstance === 'function') {
|
||||
// Friendly Captcha's widget has no render(); late widgets need an explicit WidgetInstance.
|
||||
new provider.WidgetInstance($widget[0], { sitekey: captcha.site_key, startMode: 'auto' })
|
||||
} else if (provider && typeof provider.render === 'function') {
|
||||
provider.render($widget[0], { sitekey: captcha.site_key })
|
||||
} else {
|
||||
_this.loadScript(captcha.script_url, false)
|
||||
}
|
||||
}
|
||||
|
||||
// load an external script once
|
||||
Plugin.prototype.loadScript = function(url, isModule) {
|
||||
if (document.querySelector('script[src="' + url + '"]')) {
|
||||
return
|
||||
}
|
||||
var script = document.createElement('script')
|
||||
script.src = url
|
||||
script.async = true
|
||||
script.defer = true
|
||||
if (isModule) {
|
||||
script.type = 'module'
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
// render form
|
||||
Plugin.prototype.render = function(e) {
|
||||
var _this = this
|
||||
_this.closeModal()
|
||||
_this.modalOpenTime = new Date()
|
||||
_this.log('debug', 'modalOpenTime:', _this.modalOpenTime)
|
||||
_this._scoreCaptcha = null
|
||||
|
||||
var element = "<div class=\"" + _this.options.prefixCSS + "modal\">\
|
||||
<div class=\"" + _this.options.prefixCSS + "modal-backdrop js-zammad-form-modal-backdrop\"></div>\
|
||||
@@ -618,6 +763,15 @@ $(function() {
|
||||
this.$modal = $element
|
||||
this.$form = $form
|
||||
|
||||
// Modal forms refresh the config on every open so the captcha challenge/token
|
||||
// is fresh (a reused single-use challenge would be rejected as a replay).
|
||||
if (this.options.modal) {
|
||||
_this.loadConfig()
|
||||
}
|
||||
|
||||
// honeypot / captcha (applied now if config is ready, and again when loadConfig resolves)
|
||||
_this.applySpamProtection()
|
||||
|
||||
// bind on close
|
||||
$element.find('.js-zammad-form-modal-backdrop').off('click.zammad-form').on('click.zammad-form', function (e) {
|
||||
e.preventDefault()
|
||||
|
||||
80
spec/lib/form_spam_protection/captcha/altcha_spec.rb
Normal file
80
spec/lib/form_spam_protection/captcha/altcha_spec.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'shared_examples/secret_free_frontend_config'
|
||||
|
||||
RSpec.describe FormSpamProtection::Captcha::Altcha do
|
||||
subject(:provider) { described_class.new }
|
||||
|
||||
let(:challenge) { provider.challenge }
|
||||
|
||||
it_behaves_like 'a captcha that keeps secrets out of the frontend config'
|
||||
|
||||
# Brute-force the proof-of-work number the widget would compute in the browser.
|
||||
def solve(challenge)
|
||||
number = (0..challenge[:maxnumber]).find { |n| Digest::SHA256.hexdigest("#{challenge[:salt]}#{n}") == challenge[:challenge] }
|
||||
|
||||
{
|
||||
algorithm: challenge[:algorithm],
|
||||
challenge: challenge[:challenge],
|
||||
number:,
|
||||
salt: challenge[:salt],
|
||||
signature: challenge[:signature],
|
||||
}
|
||||
end
|
||||
|
||||
def encode(solution)
|
||||
Base64.strict_encode64(solution.to_json)
|
||||
end
|
||||
|
||||
def submission(payload)
|
||||
FormSpamProtection::Submission.new(params: { 'altcha' => payload }, request: nil)
|
||||
end
|
||||
|
||||
it 'exposes a challenge URL to the widget (fetched fresh, not embedded)', :aggregate_failures do
|
||||
config = provider.frontend_config
|
||||
|
||||
expect(config).to include(provider: 'altcha', type: 'altcha', response_field: 'altcha')
|
||||
expect(config[:challenge_url]).to end_with('/form_captcha_challenge')
|
||||
expect(config).not_to have_key(:challenge)
|
||||
end
|
||||
|
||||
it 'generates a signed, time-limited proof-of-work challenge' do
|
||||
expect(provider.challenge).to include(:algorithm, :challenge, :salt, :signature, :maxnumber)
|
||||
end
|
||||
|
||||
it 'accepts a correctly solved challenge' do
|
||||
expect(provider.verify(submission(encode(solve(challenge))))).to be(true)
|
||||
end
|
||||
|
||||
it 'rejects a missing solution' do
|
||||
expect(provider.verify(submission(nil))).to be(false)
|
||||
end
|
||||
|
||||
it 'rejects a malformed payload' do
|
||||
expect(provider.verify(submission('not-base64-json'))).to be(false)
|
||||
end
|
||||
|
||||
it 'rejects a tampered solution number' do
|
||||
expect(provider.verify(submission(encode(solve(challenge).merge(number: -1))))).to be(false)
|
||||
end
|
||||
|
||||
it 'rejects a forged signature' do
|
||||
expect(provider.verify(submission(encode(solve(challenge).merge(signature: 'deadbeef'))))).to be(false)
|
||||
end
|
||||
|
||||
it 'rejects an expired challenge' do
|
||||
solution = solve(challenge)
|
||||
|
||||
travel_to(2.hours.from_now) do
|
||||
expect(provider.verify(submission(encode(solution)))).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a reused solution (replay protection)', :aggregate_failures do
|
||||
payload = encode(solve(challenge))
|
||||
|
||||
expect(provider.verify(submission(payload))).to be(true)
|
||||
expect(provider.verify(submission(payload))).to be(false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'shared_examples/token_verification'
|
||||
require_relative 'shared_examples/secret_free_frontend_config'
|
||||
|
||||
RSpec.describe FormSpamProtection::Captcha::FriendlyCaptcha do
|
||||
subject(:provider) { described_class.new }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site', 'secret' => 'secret' })
|
||||
end
|
||||
|
||||
it_behaves_like 'a token-verification captcha',
|
||||
response_field: 'frc-captcha-solution',
|
||||
verify_url: 'https://api.friendlycaptcha.com/api/v1/siteverify'
|
||||
it_behaves_like 'a captcha that keeps secrets out of the frontend config'
|
||||
|
||||
it 'exposes a widget that is initialized explicitly for late (dynamically added) forms', :aggregate_failures do
|
||||
config = provider.frontend_config
|
||||
|
||||
expect(config).to include(provider: 'friendly_captcha', widget_class: 'frc-captcha', global: 'friendlyChallenge')
|
||||
expect(config[:widget_instance]).to be(true)
|
||||
end
|
||||
|
||||
it 'verifies using the solution/sitekey parameter shape' do
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
instance_double(UserAgent::Result, success?: true, code: 200, body: '{"success":true}')
|
||||
)
|
||||
|
||||
provider.verify(FormSpamProtection::Submission.new(
|
||||
params: { 'frc-captcha-solution' => 'solution' },
|
||||
request: instance_double(ActionDispatch::Request, remote_ip: '203.0.113.1'),
|
||||
))
|
||||
|
||||
expect(UserAgent).to have_received(:post).with(
|
||||
'https://api.friendlycaptcha.com/api/v1/siteverify',
|
||||
hash_including(solution: 'solution', secret: 'secret', sitekey: 'site'),
|
||||
hash_including(verify_ssl: true),
|
||||
)
|
||||
end
|
||||
end
|
||||
46
spec/lib/form_spam_protection/captcha/hcaptcha_spec.rb
Normal file
46
spec/lib/form_spam_protection/captcha/hcaptcha_spec.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'shared_examples/token_verification'
|
||||
require_relative 'shared_examples/secret_free_frontend_config'
|
||||
|
||||
RSpec.describe FormSpamProtection::Captcha::Hcaptcha do
|
||||
subject(:provider) { described_class.new }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site', 'secret' => 'secret' })
|
||||
end
|
||||
|
||||
it_behaves_like 'a token-verification captcha',
|
||||
response_field: 'h-captcha-response',
|
||||
verify_url: 'https://api.hcaptcha.com/siteverify'
|
||||
it_behaves_like 'a captcha that keeps secrets out of the frontend config'
|
||||
|
||||
it 'exposes the widget configuration', :aggregate_failures do
|
||||
expect(provider.frontend_config).to include(
|
||||
provider: 'hcaptcha',
|
||||
type: 'widget',
|
||||
widget_class: 'h-captcha',
|
||||
response_field: 'h-captcha-response',
|
||||
global: 'hcaptcha',
|
||||
site_key: 'site',
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends the secret, response and remote IP to siteverify' do
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
instance_double(UserAgent::Result, success?: true, code: 200, body: '{"success":true}')
|
||||
)
|
||||
|
||||
provider.verify(FormSpamProtection::Submission.new(
|
||||
params: { 'h-captcha-response' => 'token' },
|
||||
request: instance_double(ActionDispatch::Request, remote_ip: '203.0.113.1'),
|
||||
))
|
||||
|
||||
expect(UserAgent).to have_received(:post).with(
|
||||
'https://api.hcaptcha.com/siteverify',
|
||||
hash_including(secret: 'secret', response: 'token', remoteip: '203.0.113.1'),
|
||||
hash_including(verify_ssl: true),
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'shared_examples/secret_free_frontend_config'
|
||||
|
||||
RSpec.describe FormSpamProtection::Captcha::RecaptchaEnterprise do
|
||||
subject(:provider) { described_class.new }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site', 'project_id' => 'proj', 'api_key' => 'key' })
|
||||
end
|
||||
|
||||
it_behaves_like 'a captcha that keeps secrets out of the frontend config'
|
||||
|
||||
def submission(response)
|
||||
FormSpamProtection::Submission.new(
|
||||
params: { 'g-recaptcha-response' => response },
|
||||
request: instance_double(ActionDispatch::Request, remote_ip: '203.0.113.1'),
|
||||
)
|
||||
end
|
||||
|
||||
def stub_assessment(data:, http_success: true, code: 200)
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
instance_double(UserAgent::Result, success?: http_success, code:, data:)
|
||||
)
|
||||
end
|
||||
|
||||
it 'exposes an invisible enterprise score widget configuration', :aggregate_failures do
|
||||
config = provider.frontend_config
|
||||
|
||||
expect(config).to include(provider: 'recaptcha_enterprise', type: 'score', global: 'grecaptcha.enterprise', response_field: 'g-recaptcha-response')
|
||||
expect(config[:script_url]).to include('enterprise.js?render=site')
|
||||
end
|
||||
|
||||
it 'assesses the token via the enterprise API and accepts a valid, high-score response', :aggregate_failures do
|
||||
stub_assessment(data: { 'tokenProperties' => { 'valid' => true }, 'riskAnalysis' => { 'score' => 0.9 } })
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(true)
|
||||
expect(UserAgent).to have_received(:post).with(
|
||||
'https://recaptchaenterprise.googleapis.com/v1/projects/proj/assessments',
|
||||
{ event: { token: 'token', siteKey: 'site', expectedAction: 'submit' } },
|
||||
hash_including(json: true, headers: { 'X-Goog-Api-Key' => 'key' }),
|
||||
)
|
||||
end
|
||||
|
||||
it 'rejects an invalid token' do
|
||||
stub_assessment(data: { 'tokenProperties' => { 'valid' => false }, 'riskAnalysis' => { 'score' => 0.9 } })
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(false)
|
||||
end
|
||||
|
||||
it 'rejects a score below the minimum' do
|
||||
stub_assessment(data: { 'tokenProperties' => { 'valid' => true }, 'riskAnalysis' => { 'score' => 0.1 } })
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(false)
|
||||
end
|
||||
|
||||
it 'rejects a missing response token without calling the API', :aggregate_failures do
|
||||
allow(UserAgent).to receive(:post)
|
||||
|
||||
expect(provider.verify(submission(''))).to be(false)
|
||||
expect(UserAgent).not_to have_received(:post)
|
||||
end
|
||||
|
||||
it 'rejects when project ID or API key is not configured' do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site' })
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(false)
|
||||
end
|
||||
|
||||
it 'fails closed when the assessment request fails' do
|
||||
stub_assessment(data: nil, http_success: false, code: 500)
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(false)
|
||||
end
|
||||
end
|
||||
65
spec/lib/form_spam_protection/captcha/recaptcha_spec.rb
Normal file
65
spec/lib/form_spam_protection/captcha/recaptcha_spec.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'shared_examples/token_verification'
|
||||
require_relative 'shared_examples/secret_free_frontend_config'
|
||||
|
||||
RSpec.describe FormSpamProtection::Captcha::Recaptcha do
|
||||
subject(:provider) { described_class.new }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site', 'secret' => 'secret' })
|
||||
end
|
||||
|
||||
it_behaves_like 'a token-verification captcha',
|
||||
response_field: 'g-recaptcha-response',
|
||||
verify_url: 'https://www.google.com/recaptcha/api/siteverify',
|
||||
valid_body: { success: true, score: 0.9 }
|
||||
it_behaves_like 'a captcha that keeps secrets out of the frontend config'
|
||||
|
||||
def submission(response)
|
||||
FormSpamProtection::Submission.new(
|
||||
params: { 'g-recaptcha-response' => response },
|
||||
request: instance_double(ActionDispatch::Request, remote_ip: '203.0.113.1'),
|
||||
)
|
||||
end
|
||||
|
||||
def stub_siteverify(body)
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
instance_double(UserAgent::Result, success?: true, code: 200, body: body.to_json)
|
||||
)
|
||||
end
|
||||
|
||||
it 'exposes an invisible score widget configuration', :aggregate_failures do
|
||||
config = provider.frontend_config
|
||||
|
||||
expect(config).to include(provider: 'recaptcha', type: 'score', response_field: 'g-recaptcha-response', global: 'grecaptcha', action: 'submit')
|
||||
expect(config[:script_url]).to include('render=site')
|
||||
expect(config[:widget_class]).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects a successful response with a score below the minimum' do
|
||||
stub_siteverify(success: true, score: 0.1)
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(false)
|
||||
end
|
||||
|
||||
it 'accepts a response whose score exactly meets the minimum' do
|
||||
stub_siteverify(success: true, score: 0.5)
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(true)
|
||||
end
|
||||
|
||||
it 'rejects an unsuccessful response regardless of score' do
|
||||
stub_siteverify(success: false, score: 0.9)
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(false)
|
||||
end
|
||||
|
||||
it 'honors a configured minimum score' do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'secret' => 'secret', 'min_score' => '0.95' })
|
||||
stub_siteverify(success: true, score: 0.9)
|
||||
|
||||
expect(provider.verify(submission('token'))).to be(false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Every provider's frontend configuration is served to the public form widget, so
|
||||
# it must never carry the secret or API key. Uses a distinctive value so the
|
||||
# assertion can't pass by coincidence regardless of where the value is nested.
|
||||
RSpec.shared_examples 'a captcha that keeps secrets out of the frontend config' do
|
||||
subject(:provider) { described_class.new }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_options', {
|
||||
'sitekey' => 'site',
|
||||
'secret' => 'super-secret-value',
|
||||
'api_key' => 'super-secret-value',
|
||||
'project_id' => 'proj',
|
||||
})
|
||||
end
|
||||
|
||||
it 'never exposes the secret or API key to the frontend' do
|
||||
expect(provider.frontend_config.to_s).not_to include('super-secret-value')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Behaviour shared by every CAPTCHA provider that verifies a response token via a
|
||||
# server-side "siteverify" HTTP call. Guarantees each provider rejects missing
|
||||
# tokens, missing secrets and bad responses, and — most importantly — fails
|
||||
# closed when the provider is unreachable or replies with garbage.
|
||||
#
|
||||
# @param response_field [String] the form field carrying the token
|
||||
# @param verify_url [String] the expected siteverify endpoint
|
||||
# @param valid_body [Hash] a provider response that should pass (default: success)
|
||||
RSpec.shared_examples 'a token-verification captcha' do |params|
|
||||
subject(:provider) { described_class.new }
|
||||
|
||||
let(:response_field) { params.fetch(:response_field) }
|
||||
let(:verify_url) { params.fetch(:verify_url) }
|
||||
let(:valid_body) { params.fetch(:valid_body, { success: true }) }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site', 'secret' => 'topsecret' })
|
||||
end
|
||||
|
||||
def token_submission(response)
|
||||
FormSpamProtection::Submission.new(
|
||||
params: { response_field => response },
|
||||
request: instance_double(ActionDispatch::Request, remote_ip: '203.0.113.1'),
|
||||
)
|
||||
end
|
||||
|
||||
def stub_siteverify(body:, http_success: true, code: 200)
|
||||
serialized = body.is_a?(String) ? body : body.to_json
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
instance_double(UserAgent::Result, success?: http_success, code:, body: serialized)
|
||||
)
|
||||
end
|
||||
|
||||
it 'verifies a valid response token against the expected endpoint', :aggregate_failures do
|
||||
stub_siteverify(body: valid_body)
|
||||
|
||||
expect(provider.verify(token_submission('token'))).to be(true)
|
||||
expect(UserAgent).to have_received(:post).with(verify_url, anything, hash_including(verify_ssl: true))
|
||||
end
|
||||
|
||||
it 'rejects an invalid response token' do
|
||||
stub_siteverify(body: { success: false })
|
||||
|
||||
expect(provider.verify(token_submission('token'))).to be(false)
|
||||
end
|
||||
|
||||
it 'rejects a missing response token without calling the provider', :aggregate_failures do
|
||||
allow(UserAgent).to receive(:post)
|
||||
|
||||
expect(provider.verify(token_submission(''))).to be(false)
|
||||
expect(UserAgent).not_to have_received(:post)
|
||||
end
|
||||
|
||||
it 'rejects when no secret is configured, without calling the provider', :aggregate_failures do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site' })
|
||||
allow(UserAgent).to receive(:post)
|
||||
|
||||
expect(provider.verify(token_submission('token'))).to be(false)
|
||||
expect(UserAgent).not_to have_received(:post)
|
||||
end
|
||||
|
||||
it 'fails closed when the provider request fails' do
|
||||
stub_siteverify(body: valid_body, http_success: false, code: 500)
|
||||
|
||||
expect(provider.verify(token_submission('token'))).to be(false)
|
||||
end
|
||||
|
||||
it 'fails closed on a malformed provider response' do
|
||||
stub_siteverify(body: 'not-json')
|
||||
|
||||
expect(provider.verify(token_submission('token'))).to be(false)
|
||||
end
|
||||
end
|
||||
45
spec/lib/form_spam_protection/captcha/turnstile_spec.rb
Normal file
45
spec/lib/form_spam_protection/captcha/turnstile_spec.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'shared_examples/token_verification'
|
||||
require_relative 'shared_examples/secret_free_frontend_config'
|
||||
|
||||
RSpec.describe FormSpamProtection::Captcha::Turnstile do
|
||||
subject(:provider) { described_class.new }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site', 'secret' => 'secret' })
|
||||
end
|
||||
|
||||
it_behaves_like 'a token-verification captcha',
|
||||
response_field: 'cf-turnstile-response',
|
||||
verify_url: 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
it_behaves_like 'a captcha that keeps secrets out of the frontend config'
|
||||
|
||||
it 'exposes the widget configuration', :aggregate_failures do
|
||||
expect(provider.frontend_config).to include(
|
||||
provider: 'turnstile',
|
||||
type: 'widget',
|
||||
widget_class: 'cf-turnstile',
|
||||
response_field: 'cf-turnstile-response',
|
||||
site_key: 'site',
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends the secret, response and remote IP to siteverify' do
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
instance_double(UserAgent::Result, success?: true, code: 200, body: '{"success":true}')
|
||||
)
|
||||
|
||||
provider.verify(FormSpamProtection::Submission.new(
|
||||
params: { 'cf-turnstile-response' => 'token' },
|
||||
request: instance_double(ActionDispatch::Request, remote_ip: '203.0.113.1'),
|
||||
))
|
||||
|
||||
expect(UserAgent).to have_received(:post).with(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
hash_including(secret: 'secret', response: 'token', remoteip: '203.0.113.1'),
|
||||
hash_including(verify_ssl: true),
|
||||
)
|
||||
end
|
||||
end
|
||||
49
spec/lib/form_spam_protection/captcha_spec.rb
Normal file
49
spec/lib/form_spam_protection/captcha_spec.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe FormSpamProtection::Captcha do
|
||||
describe '.providers' do
|
||||
it 'auto-discovers exactly the concrete providers' do
|
||||
expect(described_class.providers).to contain_exactly(
|
||||
FormSpamProtection::Captcha::Altcha,
|
||||
FormSpamProtection::Captcha::Turnstile,
|
||||
FormSpamProtection::Captcha::Hcaptcha,
|
||||
FormSpamProtection::Captcha::FriendlyCaptcha,
|
||||
FormSpamProtection::Captcha::Recaptcha,
|
||||
FormSpamProtection::Captcha::RecaptchaEnterprise,
|
||||
)
|
||||
end
|
||||
|
||||
it 'maps each provider key back to its class', :aggregate_failures do
|
||||
described_class.providers.each do |klass|
|
||||
expect(described_class.by_key(klass.key)).to eq(klass)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.by_key' do
|
||||
it 'returns nil for a blank key', :aggregate_failures do
|
||||
expect(described_class.by_key('')).to be_nil
|
||||
expect(described_class.by_key(nil)).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for an unknown key' do
|
||||
expect(described_class.by_key('nope')).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '.configured_provider' do
|
||||
it 'returns nil when no provider is configured' do
|
||||
Setting.set('form_ticket_create_captcha_provider', '')
|
||||
|
||||
expect(described_class.configured_provider).to be_nil
|
||||
end
|
||||
|
||||
it 'returns an instance of the configured provider' do
|
||||
Setting.set('form_ticket_create_captcha_provider', 'turnstile')
|
||||
|
||||
expect(described_class.configured_provider).to be_a(FormSpamProtection::Captcha::Turnstile)
|
||||
end
|
||||
end
|
||||
end
|
||||
26
spec/lib/form_spam_protection/honeypot_spec.rb
Normal file
26
spec/lib/form_spam_protection/honeypot_spec.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe FormSpamProtection::Honeypot do
|
||||
subject(:honeypot) { described_class.new }
|
||||
|
||||
def submission(params)
|
||||
FormSpamProtection::Submission.new(params:, request: nil)
|
||||
end
|
||||
|
||||
let(:field) { described_class::FIELD_NAME }
|
||||
|
||||
it 'passes when the honeypot field is absent or empty', :aggregate_failures do
|
||||
expect(honeypot.verify(submission({}))).to be(true)
|
||||
expect(honeypot.verify(submission({ field => '' }))).to be(true)
|
||||
end
|
||||
|
||||
it 'rejects when the honeypot field is filled in' do
|
||||
expect(honeypot.verify(submission({ field => 'http://spam.example.com' }))).to be(false)
|
||||
end
|
||||
|
||||
it 'rejects when the honeypot field contains only whitespace' do
|
||||
expect(honeypot.verify(submission({ field => ' ' }))).to be(false)
|
||||
end
|
||||
end
|
||||
46
spec/lib/form_spam_protection/verifier_spec.rb
Normal file
46
spec/lib/form_spam_protection/verifier_spec.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe FormSpamProtection::Verifier do
|
||||
def submission(params: {})
|
||||
FormSpamProtection::Submission.new(params:, request: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_honeypot', true)
|
||||
Setting.set('form_ticket_create_captcha_provider', '')
|
||||
end
|
||||
|
||||
describe '#request_valid?' do
|
||||
it 'passes a clean submission' do
|
||||
expect(described_class.new(submission).request_valid?).to be(true)
|
||||
end
|
||||
|
||||
it 'fails when the honeypot is filled in' do
|
||||
expect(described_class.new(submission(params: { FormSpamProtection::Honeypot::FIELD_NAME => 'x' })).request_valid?).to be(false)
|
||||
end
|
||||
|
||||
it 'skips the honeypot check when it is disabled' do
|
||||
Setting.set('form_ticket_create_honeypot', false)
|
||||
|
||||
expect(described_class.new(submission(params: { FormSpamProtection::Honeypot::FIELD_NAME => 'x' })).request_valid?).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#challenge_valid?' do
|
||||
it 'passes when no captcha provider is configured' do
|
||||
expect(described_class.new(submission).challenge_valid?).to be(true)
|
||||
end
|
||||
|
||||
it 'runs the configured captcha provider' do
|
||||
Setting.set('form_ticket_create_captcha_provider', 'turnstile')
|
||||
Setting.set('form_ticket_create_captcha_options', { 'secret' => 's' })
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
instance_double(UserAgent::Result, success?: true, code: 200, body: '{"success":false}')
|
||||
)
|
||||
|
||||
expect(described_class.new(submission(params: { 'cf-turnstile-response' => 'tok' })).challenge_valid?).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
63
spec/lib/form_spam_protection_spec.rb
Normal file
63
spec/lib/form_spam_protection_spec.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe FormSpamProtection do
|
||||
def submission(params)
|
||||
described_class::Submission.new(params:, request: nil)
|
||||
end
|
||||
|
||||
describe '.frontend_config' do
|
||||
before do
|
||||
Setting.set('form_ticket_create_honeypot', true)
|
||||
Setting.set('form_ticket_create_captcha_provider', '')
|
||||
end
|
||||
|
||||
it 'includes the honeypot field when enabled' do
|
||||
expect(described_class.frontend_config[:honeypot]).to eq({ field: FormSpamProtection::Honeypot::FIELD_NAME })
|
||||
end
|
||||
|
||||
it 'omits the honeypot when disabled' do
|
||||
Setting.set('form_ticket_create_honeypot', false)
|
||||
|
||||
expect(described_class.frontend_config).not_to have_key(:honeypot)
|
||||
end
|
||||
|
||||
it 'omits the captcha when no provider is configured' do
|
||||
expect(described_class.frontend_config).not_to have_key(:captcha)
|
||||
end
|
||||
|
||||
it 'includes the configured captcha provider configuration' do
|
||||
Setting.set('form_ticket_create_captcha_provider', 'altcha')
|
||||
|
||||
expect(described_class.frontend_config[:captcha]).to include(provider: 'altcha', type: 'altcha')
|
||||
end
|
||||
|
||||
it 'never leaks the captcha secret to the frontend' do
|
||||
Setting.set('form_ticket_create_captcha_provider', 'turnstile')
|
||||
Setting.set('form_ticket_create_captcha_options', { 'site_key' => 'site', 'secret' => 'topsecret' })
|
||||
|
||||
expect(described_class.frontend_config.to_s).not_to include('topsecret')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.verify_request' do
|
||||
before { Setting.set('form_ticket_create_honeypot', true) }
|
||||
|
||||
it 'fails when a stateless request check rejects the submission (honeypot filled)' do
|
||||
expect(described_class.verify_request(submission({ FormSpamProtection::Honeypot::FIELD_NAME => 'spam' }))).to be(false)
|
||||
end
|
||||
|
||||
it 'passes a clean submission' do
|
||||
expect(described_class.verify_request(submission({}))).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.verify_challenge' do
|
||||
it 'passes when no captcha provider is configured' do
|
||||
Setting.set('form_ticket_create_captcha_provider', '')
|
||||
|
||||
expect(described_class.verify_challenge(submission({}))).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -241,6 +241,8 @@ RSpec.describe SystemReport, current_user_id: 1, type: :model do
|
||||
'form_allowed_params',
|
||||
'vectordb_knowledge_base_category_ids',
|
||||
'vectordb_knowledge_base_chunking_strategy',
|
||||
'form_ticket_create_honeypot',
|
||||
'form_ticket_create_captcha_provider'
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -192,6 +192,174 @@ RSpec.describe 'Form', type: :request do
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
describe 'spam protection' do
|
||||
let(:fingerprint) { SecureRandom.hex(40) }
|
||||
let(:token) { json_response['token'] }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create', true)
|
||||
post '/api/v1/form_config', params: { fingerprint: }, as: :json
|
||||
end
|
||||
|
||||
def submit(extra = {})
|
||||
post '/api/v1/form_submit', params: {
|
||||
fingerprint:,
|
||||
token:,
|
||||
name: 'Bob Smith',
|
||||
email: 'discard@zammad.com',
|
||||
title: 'test',
|
||||
body: 'hello',
|
||||
}.merge(extra), as: :json
|
||||
end
|
||||
|
||||
it 'exposes the honeypot configuration via form_config' do
|
||||
expect(json_response.dig('spam_protection', 'honeypot', 'field')).to eq(FormSpamProtection::Honeypot::FIELD_NAME)
|
||||
end
|
||||
|
||||
it 'rejects submissions that fill in the honeypot field' do
|
||||
submit(FormSpamProtection::Honeypot::FIELD_NAME => 'http://spam.example.com')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response.dig('errors', 'spam')).to be_present
|
||||
expect(json_response['ticket']).to be_nil
|
||||
end
|
||||
|
||||
context 'with a configured captcha provider' do
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_provider', 'turnstile')
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'k', 'secret' => 's' })
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
instance_double(UserAgent::Result, success?: true, code: 200, body: { success: captcha_success }.to_json)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when the captcha verification fails' do
|
||||
let(:captcha_success) { false }
|
||||
|
||||
it 'rejects the submission' do
|
||||
submit('cf-turnstile-response': 'token')
|
||||
|
||||
expect(json_response.dig('errors', 'spam')).to be_present
|
||||
expect(json_response['ticket']).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the captcha verification succeeds' do
|
||||
let(:captcha_success) { true }
|
||||
|
||||
it 'creates the ticket' do
|
||||
submit('cf-turnstile-response': 'token')
|
||||
|
||||
expect(json_response.dig('ticket', 'number')).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a field is invalid' do
|
||||
let(:captcha_success) { true }
|
||||
|
||||
it 'returns the field error without consuming the captcha', :aggregate_failures do
|
||||
submit('cf-turnstile-response': 'token', email: 'invalid')
|
||||
|
||||
expect(json_response.dig('errors', 'email')).to be_present
|
||||
expect(json_response.dig('errors', 'spam')).to be_nil
|
||||
expect(UserAgent).not_to have_received(:post)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the honeypot is filled in' do
|
||||
let(:captcha_success) { true }
|
||||
|
||||
it 'rejects via the honeypot without verifying the captcha', :aggregate_failures do
|
||||
submit('cf-turnstile-response': 'token', FormSpamProtection::Honeypot::FIELD_NAME => 'spam')
|
||||
|
||||
expect(json_response.dig('errors', 'spam')).to be_present
|
||||
expect(json_response['ticket']).to be_nil
|
||||
expect(UserAgent).not_to have_received(:post)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ALTCHA verifies in-process (no external service), so the full flow can be
|
||||
# exercised end-to-end: fetch a challenge, solve it like the browser widget
|
||||
# would, submit, and create a ticket.
|
||||
context 'with the ALTCHA provider (proof-of-work, verified in-process)' do
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_provider', 'altcha')
|
||||
token # memoize the form token before the challenge request replaces the response
|
||||
end
|
||||
|
||||
def solved_altcha
|
||||
get '/api/v1/form_captcha_challenge', as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
challenge = json_response
|
||||
expect(challenge).to include('maxnumber', 'salt', 'challenge')
|
||||
|
||||
number = (0..challenge['maxnumber']).find { |n| Digest::SHA256.hexdigest("#{challenge['salt']}#{n}") == challenge['challenge'] }
|
||||
|
||||
Base64.strict_encode64({
|
||||
algorithm: challenge['algorithm'],
|
||||
challenge: challenge['challenge'],
|
||||
number:,
|
||||
salt: challenge['salt'],
|
||||
signature: challenge['signature'],
|
||||
}.to_json)
|
||||
end
|
||||
|
||||
it 'creates a ticket for a correctly solved challenge', :aggregate_failures do
|
||||
submit(altcha: solved_altcha)
|
||||
|
||||
expect(json_response.dig('errors', 'spam')).to be_nil
|
||||
expect(json_response.dig('ticket', 'number')).to be_present
|
||||
end
|
||||
|
||||
it 'rejects a submission with no solved challenge', :aggregate_failures do
|
||||
submit
|
||||
|
||||
expect(json_response.dig('errors', 'spam')).to be_present
|
||||
expect(json_response['ticket']).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects a replayed solution', :aggregate_failures do
|
||||
payload = solved_altcha
|
||||
|
||||
submit(altcha: payload)
|
||||
expect(json_response.dig('ticket', 'number')).to be_present
|
||||
|
||||
submit(altcha: payload)
|
||||
expect(json_response.dig('errors', 'spam')).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'CAPTCHA challenge endpoint' do
|
||||
it 'serves a signed challenge for a provider that issues one (ALTCHA)', :aggregate_failures do
|
||||
Setting.set('form_ticket_create', true)
|
||||
Setting.set('form_ticket_create_captcha_provider', 'altcha')
|
||||
get '/api/v1/form_captcha_challenge', as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to include('algorithm' => 'SHA-256')
|
||||
expect(json_response['salt']).to be_present
|
||||
expect(json_response['signature']).to be_present
|
||||
end
|
||||
|
||||
it 'is not found when the provider issues no server-side challenge' do
|
||||
Setting.set('form_ticket_create', true)
|
||||
Setting.set('form_ticket_create_captcha_provider', 'turnstile')
|
||||
get '/api/v1/form_captcha_challenge', as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'is forbidden when the form is disabled' do
|
||||
Setting.set('form_ticket_create', false)
|
||||
get '/api/v1/form_captcha_challenge', as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'form_allowed_params Setting', db_strategy: :reset do
|
||||
let(:fingerprint) { SecureRandom.hex(40) }
|
||||
let(:token) { json_response['token'] }
|
||||
|
||||
@@ -51,9 +51,6 @@ RSpec.describe 'Form', authenticated_as: true, type: :system do
|
||||
fill_in 'Message', with: 'message here'
|
||||
fill_in 'Email', with: 'somebody@notexistinginanydomainspacealsonothere.nowhere'
|
||||
|
||||
# We need to wait 10 seconds, because otherwise we are detected as a robot.
|
||||
sleep 10
|
||||
|
||||
click_on 'Submit'
|
||||
|
||||
expect(page).to have_css('.has-error [name=email]').and have_no_button(type: 'submit', disabled: true)
|
||||
@@ -62,27 +59,16 @@ RSpec.describe 'Form', authenticated_as: true, type: :system do
|
||||
end
|
||||
|
||||
shared_examples 'submitting valid form fields' do
|
||||
it 'submits form filled slowly succesfully' do
|
||||
it 'submits the form successfully' do
|
||||
within form_context do
|
||||
fill_in 'Name', with: 'some sender'
|
||||
fill_in 'Message', with: 'message here'
|
||||
fill_in 'Email', with: 'discard@discard.zammad.org'
|
||||
sleep 10
|
||||
click_on 'Submit'
|
||||
|
||||
expect(page).to have_text('Thank you for your inquiry')
|
||||
end
|
||||
end
|
||||
|
||||
it 'fails to submit form filled too fast' do
|
||||
within form_context do
|
||||
fill_in 'Name', with: 'some sender'
|
||||
fill_in 'Message', with: 'message here'
|
||||
fill_in 'Email', with: 'discard@discard.zammad.org'
|
||||
click_on 'Submit'
|
||||
accept_alert('Sorry, you look like a robot!')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'submitting fails due to throttling' do
|
||||
@@ -91,7 +77,6 @@ RSpec.describe 'Form', authenticated_as: true, type: :system do
|
||||
fill_in 'Name', with: 'some sender'
|
||||
fill_in 'Message', with: 'message here'
|
||||
fill_in 'Email', with: 'discard@discard.zammad.org'
|
||||
sleep 10
|
||||
# Avoid await_empty_ajax_queue.
|
||||
execute_script('$("button:submit").trigger("click")')
|
||||
accept_alert('The form could not be submitted!')
|
||||
@@ -208,6 +193,67 @@ RSpec.describe 'Form', authenticated_as: true, type: :system do
|
||||
|
||||
it_behaves_like 'submitting fails due to throttling'
|
||||
end
|
||||
|
||||
context 'with honeypot protection' do
|
||||
let(:form_context) { form_inline_selector }
|
||||
let(:honeypot) { "#{form_inline_selector} input[name='#{FormSpamProtection::Honeypot::FIELD_NAME}']" }
|
||||
|
||||
before do
|
||||
Setting.set('form_ticket_create_honeypot', true)
|
||||
visit path
|
||||
end
|
||||
|
||||
it 'injects the off-screen honeypot field into the form' do
|
||||
expect(page).to have_css(honeypot, visible: :all)
|
||||
end
|
||||
|
||||
it 'rejects a submission that fills the honeypot field' do
|
||||
within form_context do
|
||||
fill_in 'Name', with: 'some sender'
|
||||
fill_in 'Email', with: 'discard@discard.zammad.org'
|
||||
fill_in 'Message', with: 'message here'
|
||||
end
|
||||
|
||||
# the field is off-screen, so set it the way an automated client would
|
||||
execute_script("document.querySelector(\"#{honeypot}\").value = 'http://spam.example.com'")
|
||||
|
||||
accept_alert do
|
||||
within(form_context) { click_on 'Submit' }
|
||||
end
|
||||
|
||||
expect(page).to have_no_text('Thank you for your inquiry')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a CAPTCHA provider configured' do
|
||||
let(:form_context) { form_inline_selector }
|
||||
|
||||
context 'when it renders a widget (Turnstile)' do
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_provider', 'turnstile')
|
||||
Setting.set('form_ticket_create_captcha_options', { 'sitekey' => 'site', 'secret' => 'secret' })
|
||||
visit path
|
||||
end
|
||||
|
||||
it 'injects the widget container with the configured site key' do
|
||||
expect(page).to have_css("#{form_inline_selector} .cf-turnstile[data-sitekey='site']", visible: :all)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is ALTCHA (server-issued challenge)' do
|
||||
before do
|
||||
Setting.set('form_ticket_create_captcha_provider', 'altcha')
|
||||
visit path
|
||||
end
|
||||
|
||||
it 'injects the widget pointed at the challenge endpoint', :aggregate_failures do
|
||||
expect(page).to have_css("#{form_inline_selector} altcha-widget", visible: :all)
|
||||
|
||||
challenge = find("#{form_inline_selector} altcha-widget", visible: :all)['challenge']
|
||||
expect(challenge).to end_with('/form_captcha_challenge')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature is disabled' do
|
||||
|
||||
Reference in New Issue
Block a user