Add honeypot protection to contact forms
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-23 15:38:34 +01:00
parent d629b745c4
commit 4bf0d5052c
10 changed files with 312 additions and 106 deletions

View File

@@ -64,7 +64,6 @@ class MarketingController extends Controller
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|email|max:255', 'email' => 'required|email|max:255',
'message' => 'required|string|max:1000', 'message' => 'required|string|max:1000',
'nickname' => 'present|size:0',
]); ]);
$locale = app()->getLocale(); $locale = app()->getLocale();

View File

@@ -6,6 +6,7 @@ use App\Support\LocaleConfig;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
use Spatie\Honeypot\Honeypot;
class HandleInertiaRequests extends Middleware class HandleInertiaRequests extends Middleware
{ {
@@ -67,6 +68,7 @@ class HandleInertiaRequests extends Middleware
'error' => fn () => $request->session()->get('error'), 'error' => fn () => $request->session()->get('error'),
'verification' => fn () => $request->session()->get('verification'), 'verification' => fn () => $request->session()->get('verification'),
], ],
'honeypot' => fn () => new Honeypot(config('honeypot')),
]; ];
} }
} }

View File

@@ -22,6 +22,7 @@
"minishlink/web-push": "*", "minishlink/web-push": "*",
"sentry/sentry-laravel": "*", "sentry/sentry-laravel": "*",
"simplesoftwareio/simple-qrcode": "^4.2", "simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-honeypot": "*",
"spatie/laravel-translatable": "^6.11", "spatie/laravel-translatable": "^6.11",
"staudenmeir/belongs-to-through": "^2.17", "staudenmeir/belongs-to-through": "^2.17",
"stripe/stripe-php": "*", "stripe/stripe-php": "*",

78
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5e1d60e650853d6113b01e1adaf49d65", "content-hash": "a4956012b0e374c8f74b61a892e6b984",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -6804,6 +6804,82 @@
], ],
"time": "2024-05-17T09:06:10+00:00" "time": "2024-05-17T09:06:10+00:00"
}, },
{
"name": "spatie/laravel-honeypot",
"version": "4.6.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-honeypot.git",
"reference": "62ec9dbecd2a17a4e2af62b09675f89813295cac"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-honeypot/zipball/62ec9dbecd2a17a4e2af62b09675f89813295cac",
"reference": "62ec9dbecd2a17a4e2af62b09675f89813295cac",
"shasum": ""
},
"require": {
"illuminate/contracts": "^11.0|^12.0",
"illuminate/encryption": "^11.0|^12.0",
"illuminate/http": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0",
"illuminate/validation": "^11.0|^12.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.2",
"spatie/laravel-package-tools": "^1.9",
"symfony/http-foundation": "^7.0|^8.0"
},
"require-dev": {
"livewire/livewire": "^3.0",
"orchestra/testbench": "^9.0|^10.0",
"pestphp/pest": "^2.0|^3.0|^4.0",
"pestphp/pest-plugin-livewire": "^1.0|^2.1|^3.0|^4.0",
"spatie/pest-plugin-snapshots": "^1.1|^2.1",
"spatie/phpunit-snapshot-assertions": "^4.2|^5.1",
"spatie/test-time": "^1.2.1"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Honeypot\\HoneypotServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\Honeypot\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Preventing spam submitted through forms",
"homepage": "https://github.com/spatie/laravel-honeypot",
"keywords": [
"laravel-honeypot",
"spatie"
],
"support": {
"source": "https://github.com/spatie/laravel-honeypot/tree/4.6.2"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
}
],
"time": "2025-11-28T09:57:48+00:00"
},
{ {
"name": "spatie/laravel-package-tools", "name": "spatie/laravel-package-tools",
"version": "1.92.7", "version": "1.92.7",

View File

@@ -25,6 +25,20 @@ interface Props {
packages: Package[]; packages: Package[];
} }
interface HoneypotPayload {
enabled: boolean;
nameFieldName: string;
validFromFieldName: string;
encryptedValidFrom: string;
}
type ContactFormData = {
name: string;
email: string;
message: string;
[key: string]: string;
};
const heroBulletIcons = [Sparkles, ShieldCheck, Camera]; const heroBulletIcons = [Sparkles, ShieldCheck, Camera];
const howStepIcons = [QrCode, Smartphone, ShieldCheck]; const howStepIcons = [QrCode, Smartphone, ShieldCheck];
@@ -36,13 +50,24 @@ const Home: React.FC<Props> = ({ packages }) => {
variant: heroCtaVariant, variant: heroCtaVariant,
trackClick: trackHeroCtaClick, trackClick: trackHeroCtaClick,
} = useCtaExperiment('home_hero_cta'); } = useCtaExperiment('home_hero_cta');
const { flash } = usePage<{ flash?: { success?: string } }>().props; const { flash, honeypot } = usePage<{ flash?: { success?: string }; honeypot?: HoneypotPayload }>().props;
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
const { data, setData, post, processing, errors, reset } = useForm({ const honeypotDefaults = React.useMemo(() => {
if (!honeypot?.enabled) {
return {};
}
return {
[honeypot.nameFieldName]: '',
[honeypot.validFromFieldName]: honeypot.encryptedValidFrom,
};
}, [honeypot?.enabled, honeypot?.encryptedValidFrom, honeypot?.nameFieldName, honeypot?.validFromFieldName]);
const { data, setData, post, processing, errors, reset } = useForm<ContactFormData>({
name: '', name: '',
email: '', email: '',
message: '', message: '',
nickname: '', ...honeypotDefaults,
}); });
const viewportOnce = { once: true, amount: 0.25 }; const viewportOnce = { once: true, amount: 0.25 };
@@ -619,16 +644,26 @@ const Home: React.FC<Props> = ({ packages }) => {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{honeypot?.enabled ? (
<div className="hidden" aria-hidden>
<input <input
type="text" type="text"
name="nickname" name={honeypot.nameFieldName}
value={data.nickname} id={honeypot.nameFieldName}
onChange={(event) => setData('nickname', event.target.value)} value={data[honeypot.nameFieldName] ?? ''}
className="hidden" onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
tabIndex={-1}
autoComplete="off" autoComplete="off"
aria-hidden tabIndex={-1}
/> />
<input
type="text"
name={honeypot.validFromFieldName}
value={data[honeypot.validFromFieldName] ?? honeypot.encryptedValidFrom}
readOnly
tabIndex={-1}
/>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200"> <label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">

View File

@@ -6,15 +6,40 @@ import MarketingLayout from '@/layouts/mainWebsite';
import { Loader2, CheckCircle2 } from 'lucide-react'; import { Loader2, CheckCircle2 } from 'lucide-react';
import { motion, useReducedMotion } from 'framer-motion'; import { motion, useReducedMotion } from 'framer-motion';
interface HoneypotPayload {
enabled: boolean;
nameFieldName: string;
validFromFieldName: string;
encryptedValidFrom: string;
}
type ContactFormData = {
name: string;
email: string;
message: string;
[key: string]: string;
};
const Kontakt: React.FC = () => { const Kontakt: React.FC = () => {
const { data, setData, post, processing, errors, reset } = useForm({ const { honeypot, flash } = usePage<{ flash?: { success?: string }; honeypot?: HoneypotPayload }>().props;
const honeypotDefaults = React.useMemo(() => {
if (!honeypot?.enabled) {
return {};
}
return {
[honeypot.nameFieldName]: '',
[honeypot.validFromFieldName]: honeypot.encryptedValidFrom,
};
}, [honeypot?.enabled, honeypot?.encryptedValidFrom, honeypot?.nameFieldName, honeypot?.validFromFieldName]);
const { data, setData, post, processing, errors, reset } = useForm<ContactFormData>({
name: '', name: '',
email: '', email: '',
message: '', message: '',
nickname: '', ...honeypotDefaults,
}); });
const { flash } = usePage<{ flash?: { success?: string } }>().props;
const { t } = useTranslation('marketing'); const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes(); const { localizedPath } = useLocalizedRoutes();
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
@@ -51,16 +76,26 @@ const Kontakt: React.FC = () => {
</div> </div>
)} )}
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4"> <form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
{honeypot?.enabled ? (
<div className="hidden" aria-hidden>
<input <input
type="text" type="text"
name="nickname" name={honeypot.nameFieldName}
value={data.nickname} id={honeypot.nameFieldName}
onChange={(e) => setData('nickname', e.target.value)} value={data[honeypot.nameFieldName] ?? ''}
className="hidden" onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
tabIndex={-1}
autoComplete="off" autoComplete="off"
aria-hidden tabIndex={-1}
/> />
<input
type="text"
name={honeypot.validFromFieldName}
value={data[honeypot.validFromFieldName] ?? honeypot.encryptedValidFrom}
readOnly
tabIndex={-1}
/>
</div>
) : null}
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.name')}</label> <label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.name')}</label>
<input <input

View File

@@ -31,7 +31,7 @@ vi.mock('@inertiajs/react', () => ({
Head: () => null, Head: () => null,
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>, Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
useForm: () => ({ useForm: () => ({
data: { name: '', email: '', message: '', nickname: '' }, data: { name: '', email: '', message: '' },
setData: vi.fn(), setData: vi.fn(),
post: vi.fn(), post: vi.fn(),
processing: false, processing: false,

View File

@@ -37,7 +37,7 @@ vi.mock('@inertiajs/react', () => ({
Head: () => null, Head: () => null,
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>, Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
useForm: () => ({ useForm: () => ({
data: { name: '', email: '', message: '', nickname: '' }, data: { name: '', email: '', message: '' },
setData: vi.fn(), setData: vi.fn(),
post: vi.fn(), post: vi.fn(),
processing: false, processing: false,

View File

@@ -25,6 +25,7 @@ use App\Support\LocaleConfig;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use Spatie\Honeypot\ProtectAgainstSpam;
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';
@@ -88,13 +89,13 @@ Route::prefix('{locale}')
Route::get('/contact', [MarketingController::class, 'contactView']) Route::get('/contact', [MarketingController::class, 'contactView'])
->name('marketing.contact'); ->name('marketing.contact');
Route::post('/contact', [MarketingController::class, 'contact']) Route::post('/contact', [MarketingController::class, 'contact'])
->middleware('throttle:contact-form') ->middleware(['throttle:contact-form', ProtectAgainstSpam::class])
->name('marketing.contact.submit'); ->name('marketing.contact.submit');
Route::get('/kontakt', [MarketingController::class, 'contactView']) Route::get('/kontakt', [MarketingController::class, 'contactView'])
->name('kontakt'); ->name('kontakt');
Route::post('/kontakt', [MarketingController::class, 'contact']) Route::post('/kontakt', [MarketingController::class, 'contact'])
->middleware('throttle:contact-form') ->middleware(['throttle:contact-form', ProtectAgainstSpam::class])
->name('kontakt.submit'); ->name('kontakt.submit');
Route::get('/blog', [MarketingController::class, 'blogIndex'])->name('blog'); Route::get('/blog', [MarketingController::class, 'blogIndex'])->name('blog');

View File

@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature\Marketing;
use App\Mail\ContactConfirmation;
use App\Mail\ContactRequest;
use Illuminate\Support\Facades\Mail;
use Spatie\Honeypot\EncryptedTime;
use Spatie\Honeypot\Honeypot;
use Tests\TestCase;
class ContactFormTest extends TestCase
{
public function test_contact_form_accepts_valid_submission(): void
{
config(['mail.contact_address' => 'contact@example.com']);
Mail::fake();
$honeypot = new Honeypot(config('honeypot'));
$response = $this->from('/de/kontakt')->post('/de/kontakt', [
'name' => 'Test User',
'email' => 'user@example.com',
'message' => 'Hello there!',
$honeypot->nameFieldName() => '',
$honeypot->validFromFieldName() => (string) EncryptedTime::create(now()->subSeconds(5)),
]);
$response->assertRedirect('/de/kontakt');
$response->assertSessionHas('success');
Mail::assertSent(ContactRequest::class);
Mail::assertQueued(ContactConfirmation::class, function (ContactConfirmation $mail) {
return $mail->hasTo('user@example.com');
});
}
public function test_contact_form_blocks_spam_when_honeypot_filled(): void
{
Mail::fake();
$honeypot = new Honeypot(config('honeypot'));
$response = $this->from('/de/kontakt')->post('/de/kontakt', [
'name' => 'Spam Bot',
'email' => 'spam@example.com',
'message' => 'Spam message',
$honeypot->nameFieldName() => 'filled',
$honeypot->validFromFieldName() => (string) EncryptedTime::create(now()->subSeconds(5)),
]);
$response->assertOk();
$response->assertContent('');
Mail::assertNothingSent();
}
}