Add honeypot protection to contact forms
This commit is contained in:
@@ -64,7 +64,6 @@ class MarketingController extends Controller
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'message' => 'required|string|max:1000',
|
||||
'nickname' => 'present|size:0',
|
||||
]);
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Support\LocaleConfig;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Spatie\Honeypot\Honeypot;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
@@ -67,6 +68,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'error' => fn () => $request->session()->get('error'),
|
||||
'verification' => fn () => $request->session()->get('verification'),
|
||||
],
|
||||
'honeypot' => fn () => new Honeypot(config('honeypot')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"minishlink/web-push": "*",
|
||||
"sentry/sentry-laravel": "*",
|
||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||
"spatie/laravel-honeypot": "*",
|
||||
"spatie/laravel-translatable": "^6.11",
|
||||
"staudenmeir/belongs-to-through": "^2.17",
|
||||
"stripe/stripe-php": "*",
|
||||
|
||||
78
composer.lock
generated
78
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
|
||||
"content-hash": "a4956012b0e374c8f74b61a892e6b984",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -6804,6 +6804,82 @@
|
||||
],
|
||||
"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",
|
||||
"version": "1.92.7",
|
||||
|
||||
@@ -25,6 +25,20 @@ interface Props {
|
||||
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 howStepIcons = [QrCode, Smartphone, ShieldCheck];
|
||||
|
||||
@@ -36,13 +50,24 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
variant: heroCtaVariant,
|
||||
trackClick: trackHeroCtaClick,
|
||||
} = 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 { 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: '',
|
||||
email: '',
|
||||
message: '',
|
||||
nickname: '',
|
||||
...honeypotDefaults,
|
||||
});
|
||||
|
||||
const viewportOnce = { once: true, amount: 0.25 };
|
||||
@@ -619,16 +644,26 @@ const Home: React.FC<Props> = ({ packages }) => {
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{honeypot?.enabled ? (
|
||||
<div className="hidden" aria-hidden>
|
||||
<input
|
||||
type="text"
|
||||
name="nickname"
|
||||
value={data.nickname}
|
||||
onChange={(event) => setData('nickname', event.target.value)}
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
name={honeypot.nameFieldName}
|
||||
id={honeypot.nameFieldName}
|
||||
value={data[honeypot.nameFieldName] ?? ''}
|
||||
onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
|
||||
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="flex flex-col gap-2">
|
||||
<label htmlFor="name" className="text-sm font-semibold text-gray-600 dark:text-gray-200">
|
||||
|
||||
@@ -6,15 +6,40 @@ import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
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 { 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: '',
|
||||
email: '',
|
||||
message: '',
|
||||
nickname: '',
|
||||
...honeypotDefaults,
|
||||
});
|
||||
|
||||
const { flash } = usePage<{ flash?: { success?: string } }>().props;
|
||||
const { t } = useTranslation('marketing');
|
||||
const { localizedPath } = useLocalizedRoutes();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
@@ -51,16 +76,26 @@ const Kontakt: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
|
||||
{honeypot?.enabled ? (
|
||||
<div className="hidden" aria-hidden>
|
||||
<input
|
||||
type="text"
|
||||
name="nickname"
|
||||
value={data.nickname}
|
||||
onChange={(e) => setData('nickname', e.target.value)}
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
name={honeypot.nameFieldName}
|
||||
id={honeypot.nameFieldName}
|
||||
value={data[honeypot.nameFieldName] ?? ''}
|
||||
onChange={(event) => setData(honeypot.nameFieldName, event.target.value)}
|
||||
autoComplete="off"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name={honeypot.validFromFieldName}
|
||||
value={data[honeypot.validFromFieldName] ?? honeypot.encryptedValidFrom}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
<input
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock('@inertiajs/react', () => ({
|
||||
Head: () => null,
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
useForm: () => ({
|
||||
data: { name: '', email: '', message: '', nickname: '' },
|
||||
data: { name: '', email: '', message: '' },
|
||||
setData: vi.fn(),
|
||||
post: vi.fn(),
|
||||
processing: false,
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock('@inertiajs/react', () => ({
|
||||
Head: () => null,
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
useForm: () => ({
|
||||
data: { name: '', email: '', message: '', nickname: '' },
|
||||
data: { name: '', email: '', message: '' },
|
||||
setData: vi.fn(),
|
||||
post: vi.fn(),
|
||||
processing: false,
|
||||
|
||||
@@ -25,6 +25,7 @@ use App\Support\LocaleConfig;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Spatie\Honeypot\ProtectAgainstSpam;
|
||||
|
||||
require __DIR__.'/auth.php';
|
||||
require __DIR__.'/settings.php';
|
||||
@@ -88,13 +89,13 @@ Route::prefix('{locale}')
|
||||
Route::get('/contact', [MarketingController::class, 'contactView'])
|
||||
->name('marketing.contact');
|
||||
Route::post('/contact', [MarketingController::class, 'contact'])
|
||||
->middleware('throttle:contact-form')
|
||||
->middleware(['throttle:contact-form', ProtectAgainstSpam::class])
|
||||
->name('marketing.contact.submit');
|
||||
|
||||
Route::get('/kontakt', [MarketingController::class, 'contactView'])
|
||||
->name('kontakt');
|
||||
Route::post('/kontakt', [MarketingController::class, 'contact'])
|
||||
->middleware('throttle:contact-form')
|
||||
->middleware(['throttle:contact-form', ProtectAgainstSpam::class])
|
||||
->name('kontakt.submit');
|
||||
|
||||
Route::get('/blog', [MarketingController::class, 'blogIndex'])->name('blog');
|
||||
|
||||
57
tests/Feature/Marketing/ContactFormTest.php
Normal file
57
tests/Feature/Marketing/ContactFormTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user