Skip to main content

Abuse protection

abuse.driver selects the gate. The built-in one is on by default and needs no service:

  • a honeypot field a human never sees and a bot fills — hidden by CSS, in two ways, so that a policy forbidding style attributes cannot expose it. Under the WireKit tree there is one condition on that; the CSP section of the Integration contract has it,
  • a server-anchored time trap (abuse.min_fill_seconds) that a client cannot fake,
  • per-user and per-guest-IP rate limits, counted on every attempt, including the ones that fail validation.

The honeypot and the time trap reject silently — a bot gets the same success screen as a human, so it learns nothing. A rate limit is visible, because a real person hitting it deserves to be told.

The floor cannot be switched off. Whatever abuse.driver says, the honeypot, the time trap and the rate limits run underneath it — so a challenge provider being down, or a typo in the driver name, can never leave the form unprotected. builtin and none both mean the floor alone; none is the explicit way to decline an additional gate even when one is registered.

One third of that floor needs a cache, and by default it yields rather than blocks. The honeypot and the time trap read nothing outside the request — no cache, no disk, no network — so there is nothing an outage can take from them. The rate limits are counted through Laravel's limiter and therefore through your cache backend, and abuse.on_error decides what happens when that call fails:

  • open (the shipped default) lets the submission through. You lose up to an hour of counting, and the log line names which half was lost. The honeypot and the time trap keep judging the same request.
  • closed refuses instead. That is the right choice where a missed rate limit costs more than a missed report, and it means a cache outage stops the form.

Neither is the safe answer in general, which is why it is a setting and why it is named here rather than left in the config file. What is not true either way is that the floor becomes nothing: two of its three parts do not depend on anything that can be down.

Where the floor begins, and the one surface in front of it. All of the above runs on submit. File uploads do not go through submit: they ride Livewire's global upload endpoint, which writes the file to the temporary disk as soon as the reporter picks it — before this package sees anything, and whether or not they ever press send. So the rate limits above do not bound how much can be uploaded, and the limits that do are Livewire's, in config/livewire.php. Set them; the defaults are wide, and the widget is meant for a public page. Integration contract has the two keys and the numbers to use.

Turning it off

VISUAL_FEEDBACK_ENABLED=false is the kill switch, and it needs no code change:

VISUAL_FEEDBACK_ENABLED=false

What that means precisely, because "off" is worth being exact about when you are reaching for it during an incident:

  • the widget renders one empty hidden element — no floating button, no form, nothing focusable, and no layout gap where it used to be;
  • <x-visual-feedback::fab>, <x-visual-feedback::trigger> and <x-visual-feedback::scripts> render nothing at all, so a trigger you placed yourself disappears with it and the capture bundle is not requested either;
  • the submit path refuses every request, before it touches a rate limiter, a cache or a disk. That is the half that matters: the Livewire component is registered by name and is reachable without the page that draws it, so a tab that was already open still reaches the server.

A page that was open when you flipped the switch tells its reporter the form is off rather than showing a success screen for a report nobody received. The rejection fires a ReportRejected event carrying RejectionReason::Disabled, so a listener can tell "switched off" from "under attack" — the two look identical from the outside otherwise.

Everything else — the built-in floor, your channels, retention — is unaffected and resumes the moment you switch it back.

Adding your own gate. Register a factory under a driver name and select it:

use Pushery\VisualFeedback\Contracts\AbuseGate;
use Pushery\VisualFeedback\Facades\VisualFeedback;

// in a service provider's boot()
VisualFeedback::extendAbuse('acme-shield', fn (): AbuseGate => new AcmeShieldGate(...));
VISUAL_FEEDBACK_ABUSE_DRIVER=acme-shield

Your gate implements one method — check(ReportAttempt): AbuseDecision — and layers on top of the floor: it can reject an attempt the floor allowed, never allow one the floor rejected. The factory runs only when the configuration names it, so a registered-but-unselected gate costs nothing. Name a driver with no gate registered and you get the floor alone plus a warning in the log — never silence.

Wiring an interactive challenge. A gate judging identity, origin or timing needs nothing beyond what ReportAttempt already carries. A challenge the person on the page has to solve — Turnstile, a proof-of-work puzzle, anything with a widget — needs two more things, and the package provides both.

Point abuse.challenge_view at a Blade view. It is rendered inside the form, in both view trees, wrapped in wire:ignore:

// config/visual-feedback.php
'challenge_view' => 'partials.my-challenge',
{{-- resources/views/partials/my-challenge.blade.php --}}
<div class="cf-turnstile" data-sitekey="{{ config('services.turnstile.key') }}"
data-callback="onChallengeSolved"></div>
<input type="hidden" wire:model="challenge.token">

<script>
// The provider hands the token to a global function; this one puts it in the input and
// fires the event Livewire listens for. Setting `.value` alone changes nothing on the
// server — the property only moves when an `input` event does.
window.onChallengeSolved = (token) => {
const field = document.querySelector('.visual-feedback-challenge input[type="hidden"]');

if (field) {
field.value = token;
field.dispatchEvent(new Event('input', { bubbles: true }));
}
};
</script>

wire:ignore is not defensive here, it is required: a challenge widget is third-party DOM with its own JavaScript, and Livewire's morphing would tear it out from under itself on the next update. That is the failure everyone wiring one of these by hand meets first.

On a narrow screen the region scrolls sideways, and that is the intended behavior. Measured in a real browser at a 320px viewport: the form gives this region 238px, while a Turnstile widget is 300px wide by specification and cannot be told otherwise. Rather than clip it — which is what happens by default, silently, leaving the right-hand part of a challenge unreachable with no scrollbar to hint at it — the region scrolls horizontally. Your reporter can reach the whole widget; the page itself never scrolls sideways. If you would rather it fit outright, a provider offering a compact variant is the only lever, since the width is theirs and not ours.

Whatever your markup binds into challenge arrives on ReportAttempt::$challenge, verbatim. The package never reads a key out of it and the built-in floor ignores it entirely — it is a claim from the browser, and the gate that asked for it is the only thing that may trust it.

Say whether the reporter is told. A rejection is silent by default, which is right for anything a bot triggers: a honeypot hit and a forged token both get the decoy success screen, so an attacker learns nothing. It is wrong for something a human can fail. Your gate decides:

use Pushery\VisualFeedback\Abuse\AbuseDecision;
use Pushery\VisualFeedback\Events\RejectionReason;

return AbuseDecision::reject(RejectionReason::ChallengeFailed, visible: true);

RejectionReason lives under Events, not under Abuse — it is the enum the ReportRejected event carries, and the gate names the same value the listener reads.

The built-in floor uses both: a honeypot hit stays silent, a rate limit is shown, because the person who hit it will be under it again within the hour.

Re-issue the token between reports. A challenge token is single-use, so the widget clears challenge whenever it resets — carrying a spent token into the next report would have the provider reject the replay and your gate reject a reporter who did nothing wrong. Clearing it is only half the problem: the form is removed on success, which takes the challenge region with it, and your widget's script does not run again when it comes back. So the widget dispatches a browser event for you to hook:

<script>
window.addEventListener('visual-feedback:challenge-reset', () => {
const container = document.querySelector('.visual-feedback-challenge .cf-turnstile');

if (! container) {
return;
}

container.innerHTML = '';

turnstile.render(container, {
sitekey: '{{ config('services.turnstile.key') }}',
callback: window.onChallengeSolved,
});
});
</script>

That has to be a render, not a reset(), and the distinction is the whole reason this paragraph exists. turnstile.reset() re-arms a widget that is still mounted. Here there is nothing to re-arm: the region was torn out with the form and comes back empty — the container element is rendered again by your partial, but the provider's script never mounted into it, because an implicitly rendered widget (class="cf-turnstile") is only picked up by the scan the provider's api.js runs when it loads, and that already happened. A reset() call finds no widget, does nothing visible, and leaves your gate rejecting a reporter who did nothing wrong — behind the decoy success screen, so nobody sees it happen.

The same shape applies to any other provider: on this event, mount a new instance into .visual-feedback-challenge rather than resetting the old one.

A view name that does not exist is a warning, never silence. Point challenge_view at a view that is not there and nothing renders — which on its own would leave a gate rejecting every reporter for a token that could never be produced, behind a decoy success screen. The package logs instead: the configured abuse challenge view does not exist, with the view name and the setting.