Abuse protection
Sign-in only
Everything else on this page bounds traffic that is still allowed to arrive. require_authentication
decides whether it arrives, which makes it the strongest cost brake the package can offer — it
removes the anonymous surface rather than measuring it:
VISUAL_FEEDBACK_REQUIRE_AUTHENTICATION=true
With it on, a guest gets no floating button, no trigger and no form, and a submit from a guest
session is refused with RejectionReason::AuthenticationRequired — an enum case rather than a
validation error, so a log can tell "somebody left a field empty" from "somebody submitted to a
form they were never shown".
Both halves are needed and only one of them holds. The Livewire component is registered by name and is therefore addressable without the page that draws it, so hiding the trigger is presentation and the refusal in the submit pipeline is enforcement. That is also why a host cannot build this half as well from outside the package.
"Signed in" is answered by the ResolvesReporter contract — the same one that decides whose name
is on a report. There is deliberately no separate guard-name setting: a host on a non-default guard
already has to bind their own resolver for the reporter's identity to be right, and a second answer
could only ever disagree with the first.
⚠️ If you published the views, your copies of fab.blade.php, trigger.blade.php and
scripts.blade.php still ask the old question and will keep drawing the trigger for a guest. The
submit refusal is unaffected — the form simply refuses instead of being absent. Re-publish or diff
those three against this release.
It ships off, unlike the instance ceiling below, and the asymmetry is deliberate: a ceiling that is too low costs an install some reports on its worst day, while turning this on for a host who never asked would delete their entire guest audience silently.
The built-in floor
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 value does switch a third of it off, and it is deliberate: abuse.min_fill_seconds = 0
disarms the time trap, because the whole check is gated on the minimum being above zero. It
exists for suites — a test submits instantly, which is precisely what the trap rejects — and
that is also how it reaches a published config file and then production. Nothing logs it and
php artisan about does not mention it, so if you are auditing an install, read the value.
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.closedrefuses 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 guest bucket comes from, and why trustProxies decides it. A signed-in reporter is
counted per account. A guest is counted per IP address, and the address is whatever
request()->ip() returns — which is a statement about your middleware, not about the visitor:
- Behind nginx, a load balancer or a CDN with no
trustProxies(),ip()is the proxy's address. Every guest then shares one bucket. That is worse than it sounds, because the limiter runs before the honeypot and the time trap: a bot's rejected attempts spend tokens too, so five bot requests can leave real visitors reading the rate-limit message for an hour. - With
trustProxies(at: '*')— the framework default on managed platforms —ip()is the left-mostX-Forwarded-Forentry, which a direct client sets itself. Rotate that header and the buckets are unlimited. Whether your edge overwrites the header is a fact about your platform, and this package cannot know it.
Trust an explicit list of ranges rather than '*' wherever you do not control the hop in front of
you. On IPv6 the bucket is the visitor's /64, not the single address — an assignment is at least
that large and the interface identifier costs nothing to change, so keying on the whole address
would be no limit at all.
0 is not a value for either PER-SUBJECT limit, and it is for the instance one above.
rate_limit and guest_rate_limit are read as positive integers, so 0 — written meaning either
"off" or "block everyone" — is silently read as the shipped default of 5. The floor cannot be turned off; use a high number if you want it out of the
way. min_fill_seconds is the one part of the floor that 0 really does disarm, and that is
documented above.
Both of those count per SENDER, and a distributed one never meets either. A thousand addresses
that each stay under the guest limit are a thousand reports an hour between them, and on the mail
channel that is a thousand messages with their attachments at a provider billing per message and
per byte. abuse.global_rate_limit is the ceiling for the whole application — one bucket, no
subject in it, 1000 per hour by default:
// config/visual-feedback.php
'abuse' => [
'global_rate_limit' => 1_000, // 0 switches it off
],
It ships switched on rather than off, and that is a decision worth stating plainly: the installs
most exposed to a surprise bill are the ones that never read a changelog, so a cost brake nobody
opts into protects nobody. The number is chosen to be invisible to any plausible feedback volume.
If your form really takes more than a thousand reports an hour, say so here rather than inherit a
ceiling — and note that 0 means off, unlike the two per-subject limits, where 0 is read as the
default.
The order is load-bearing and worth knowing if you are reasoning about an attack: the instance bucket is only counted for attempts that already got past their own limit. So one address can contribute at most its own hourly share, and a lone attacker cannot exhaust the application's ceiling to silence everybody else.
Reaching it dispatches InstanceRateLimitReached — once per window, on the attempt that
reaches the cap, which is the last one still accepted — and writes an error line. That is one
report before anything is refused, so it is worth wiring to something that wakes a person up.
Refusals after that carry RejectionReason::GlobalRateLimited, which is deliberately not
RateLimited: one says a sender had their share, the other says the application did.
And a submission with no address at all shares one bucket. The widget always supplies it; a
second adapter built on the same pipeline might not, and then all of its guests share the unknown
key. That direction is deliberate — a transport that cannot say who is calling gets the strictest
treatment, not an exemption — but it is worth knowing before you build one.
Which cache counts them. The limiter counts in the store cache.limiter names, or in your
default store when it names none. Two stores count nothing and raise nothing while doing it:
array is per-process, so every request counts 1 and the limit is never reached; null discards
the increment, so the count is always 0. Neither throws, so abuse.on_error never fires and no
line appears in the log — the limits are simply absent. CACHE_STORE=array is Laravel's own
testing default, which is the ordinary way to end up there.
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.
What a rejected attempt writes to your PERMANENT disk is a different question, and the answer is now nothing. The two floor arms that cost nothing to ask — the honeypot and the time trap, both pure — run before the attachments and the capture are copied from the temporary disk to the configured one. A bot that trips either causes no write at all, where it used to cause up to five files plus a capture, discarded again on the same request. On object storage those were billed PUT requests, paid for before any protection had run.
Only the free arms move. A rejection that comes later — a rate limit, a failed challenge, a listener veto, the instance ceiling, the sign-in switch — still stores first and cleans up after, because those reporters are usually real people and the pipeline has to reach its verdict the ordinary way. Nothing about the decoy success, the rejection event or the rate-limit accounting changes either.
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.
An exception in your gate is a PASS, unless you say otherwise. If check() throws, the
submission is logged at error level and then allowed — the floor's verdict stands and your
gate is skipped for that request. abuse.on_error is not the switch for this; that one governs the
built-in rate limiter only. Yours is abuse.drivers.<name>.on_error, keyed by the same name
abuse.driver selects:
// config/visual-feedback.php
'abuse' => [
'driver' => 'acme-shield',
'drivers' => [
'acme-shield' => ['on_error' => 'closed'],
],
],
open is the default for every driver, named here or not, and is what ships. It is the right
default for most installs: an outage at a challenge provider should not take a feedback form
offline, and the floor is still there.
closed is for the installs where "the floor is still there" is not the reassurance it sounds
like. The floor is a honeypot, a time trap and five reports per hour per guest IP — fine when a
report costs you a row in a table, expensive when every report is a mail with attachments. Under
closed a submission is refused while your gate is not answering, and the rejection carries its
own reason, RejectionReason::GateUnavailable, so a burst of them reads as your incident rather
than as an attack. The error line is written either way.
It is per driver rather than one switch over "additional gates" because the trade is not the same for all of them: a paid challenge provider and a gate that only reads a header are worth very different amounts of downtime.
The pass is what you get by default, and it matters most for the shape nobody writes on purpose. A client controls the challenge payload
completely, so challenge: {} or {"token": ["x"]} reaches your gate as a missing key or an array
where you expected a string — and in PHP both of those raise, which means a challenge you believe is
mandatory has just been skipped by sending a malformed field. Read the payload defensively and
return a rejection rather than letting anything throw:
$token = $attempt->challenge['token'] ?? null;
if (! is_string($token) || $token === '') {
return AbuseDecision::reject(RejectionReason::ChallengeFailed, visible: true);
}
The floor still runs underneath either way, so a throwing gate under open degrades to the
honeypot, the time trap and the rate limits rather than to nothing — and under closed the floor
still decides first, so a honeypot hit is still reported as a honeypot hit.
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
'abuse' => [
'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.