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:
Dominik Klein
2026-06-24 19:36:34 +02:00
parent f9a030a371
commit 6489c2edc5
39 changed files with 2394 additions and 388 deletions

View File

@@ -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": {

View File

@@ -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')

View File

@@ -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">

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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',

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -11,6 +11,10 @@
margin-bottom: 15px;
}
.zammad-form-captcha {
margin-bottom: 15px;
}
.zammad-form-control {
display: block;
width: 100%;

View File

@@ -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()

View 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

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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'] }

View File

@@ -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