Formularz kontaktowy bez spamu i bez CAPTCHA: 6 zabezpieczeń z kodem PHP

Jak zatrzymać spam w formularzu kontaktowym bez uciążliwej CAPTCHA: pułapka na boty, bilet czasowy, limity, walidacja e-maila i treści. Kod PHP do skopiowania i pułapka, przez którą formularz może rozsyłać spam.

Ilustracja: formularz kontaktowy z niebieską tarczą, od której odbijają się małe roboty
Ilustracja wygenerowana przez AI

Formularz kontaktowy to najprostsza droga klienta do Ciebie i jednocześnie ulubiony cel botów. Spam w skrzynce to jeszcze pół biedy. Gorzej, gdy źle napisany formularz sam zaczyna rozsyłać spam z Twojej domeny, a Twoje maile lądują potem w folderach „Spam” u klientów.

Najczęstsza odpowiedź na spam to CAPTCHA: zaznacz obrazki z hydrantami. Działa, ale kosztuje. Część osób rezygnuje z wysłania wiadomości, a reCAPTCHA przesyła dane odwiedzających do Google. W tym artykule pokazuję 6 zabezpieczeń, których klient w ogóle nie zauważy. Tak działa formularz na mojej stronie i w projektach, które robię dla klientów.

1. Pułapka na boty (honeypot)

Boty wypełniają wszystkie pola, które znajdą w kodzie. Dodajesz więc pole, którego człowiek nie widzi. Jeśli przyjdzie wypełnione, wysłał je bot:

<div class="hp" aria-hidden="true">
  <label for="www">Zostaw to pole puste</label>
  <input id="www" name="website" type="text" tabindex="-1" autocomplete="off">
</div>
.hp { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
if (trim($_POST['website'] ?? '') !== '') {
    // udajemy sukces: bot nie dowie się, że trafił w pułapkę, i nie będzie próbował dalej
    echo json_encode(['ok' => true]);
    exit;
}

Trzy szczegóły robią różnicę. Pole nazywa się zwyczajnie („website”), a nie „honeypot”. tabindex="-1" sprawia, że osoba poruszająca się klawiaturą do niego nie trafi. aria-hidden ukrywa je przed czytnikami ekranu. Nie używaj display: none, bo część botów pomija takie pola.

2. Bilet czasowy

Człowiek potrzebuje kilkudziesięciu sekund, żeby wypełnić formularz. Bot wysyła go w ułamku sekundy, często bez wczytywania strony. Przy wczytaniu formularza wydajesz więc bilet z czasem i podpisem, a przy wysyłce sprawdzasz, czy jest prawdziwy i ile ma sekund:

const SEKRET = 'długi losowy ciąg, np. bin2hex(random_bytes(32)), trzymany poza kodem';

function bilet_nowy(): string
{
    $t = (string) time();
    return $t . '.' . hash_hmac('sha256', $t, SEKRET);
}

function bilet_ok(string $bilet): bool
{
    if (!preg_match('/^(\d{10})\.([a-f0-9]{64})$/', $bilet, $m)) return false;
    if (!hash_equals(hash_hmac('sha256', $m[1], SEKRET), $m[2])) return false;   // podrobiony
    $wiek = time() - (int) $m[1];
    return $wiek >= 3 && $wiek <= 7200;   // nie szybciej niż w 3 s i nie później niż po 2 h
}

Bilet możesz wstawić do formularza jako ukryte pole. Jeśli strona jest statyczna albo trzymana w pamięci podręcznej, pobierz go skryptem z osobnego adresu, gdy ktoś zacznie wypełniać formularz. Dodatkowy plus: bot bez JavaScriptu w ogóle go nie dostanie. Podpis HMAC sprawia, że bota nie da się nauczyć „wpisz czas sprzed minuty”, bo bez sekretu nie wygeneruje poprawnego podpisu.

3. Limity zgłoszeń

Nawet sprytny bot ma ograniczony czas. Limit na jedno połączenie, na przykład 5 wiadomości w 10 minut, i wspólny limit dzienny chronią skrzynkę przed zalewem:

function limit_ok(string $ip, int $max = 5, int $okno = 600): bool
{
    // skrót IP zamiast samego adresu: nie przechowujesz danych osobowych
    $plik = sys_get_temp_dir() . '/formularz-' . hash('sha256', $ip . SEKRET) . '.json';
    $fh = fopen($plik, 'c+');
    flock($fh, LOCK_EX);   // dwa zgłoszenia naraz nie nadpiszą sobie licznika
    $teraz = time();
    $czasy = array_filter(json_decode(stream_get_contents($fh), true) ?: [], fn($t) => $t > $teraz - $okno);
    $ok = count($czasy) < $max;
    if ($ok) $czasy[] = $teraz;
    ftruncate($fh, 0);
    rewind($fh);
    fwrite($fh, json_encode(array_values($czasy)));
    flock($fh, LOCK_UN);
    fclose($fh);
    return $ok;
}

if (!limit_ok($_SERVER['REMOTE_ADDR'] ?? '')) {
    http_response_code(429);
    exit;
}

Jeśli strona stoi za serwerem pośredniczącym (Cloudflare, Caddy, Nginx), REMOTE_ADDR to adres tego serwera, a nie klienta. Prawdziwy adres trzeba wtedy wziąć z nagłówka ustawianego przez Twój serwer, ale tylko wtedy, gdy ufasz temu, kto go ustawia. Inaczej bot wpisze sobie dowolny adres i obejdzie limit.

4. Walidacja e-maila: pułapka, przez którą formularz rozsyła spam

To najważniejszy punkt w tym artykule. Typowy formularz w PHP wysyła wiadomość do Ciebie i ustawia nagłówek Reply-To na adres klienta, żeby odpowiedzieć jednym kliknięciem. Adres sprawdza się zwykle tak:

filter_var($email, FILTER_VALIDATE_EMAIL)

Problem w tym, że według standardu adres e-mail może mieć część przed @ w cudzysłowie, a w niej znaki poprzedzone ukośnikiem, także znak nowej linii. Sprawdziłem to w PHP 8.3 i 8.4: adres w cudzysłowie z ukośnikiem i nową linią w środku przechodzi tę walidację. Wstawiony do nagłówka Reply-To dopisuje nowy nagłówek, na przykład Bcc: z listą obcych adresów. Twój formularz wysyła wtedy spam z Twojej domeny, przez Twój serwer pocztowy.

Rozwiązanie jest proste. Do formularza wystarczą zwykłe adresy, więc odrzuć cudzysłowy, ukośniki i znaki sterujące:

function email_ok(string $e): bool
{
    return strlen($e) <= 120
        && filter_var($e, FILTER_VALIDATE_EMAIL) !== false
        && !preg_match('/[\x00-\x20\x7F"\\\\(),:;<>\[\]]/', $e);
}

Druga zasada: do nagłówków maila nie wstawiaj niczego od użytkownika poza tak sprawdzonym adresem. Temat i imię najlepiej kodować (=?UTF-8?B?...?=) albo użyć biblioteki do wysyłki poczty, na przykład PHPMailer czy Symfony Mailer, które same pilnują nagłówków. Ten błąd znalazłem i poprawiłem podczas audytu bezpieczeństwa własnej strony, więc wiem, że dotyczy prawdziwego kodu, a nie tylko podręczników.

5. Treść i domena nadawcy

Spam ma swoje nawyki. Kilka prostych reguł odsiewa sporo z tego, co przejdzie przez pułapki:

$linki = static fn(string $s): int => preg_match_all('~https?://|www\.~i', $s);
if ($linki($imie) > 0 || $linki($tresc) > 3) {
    odrzuc('linki');   // link w imieniu albo więcej niż 3 linki w treści
}

// domena adresu e-mail musi przyjmować pocztę (rekord MX albo A)
$domena = substr(strrchr($email, '@') ?: '', 1);
if ($domena === '' || (!checkdnsrr($domena, 'MX') && !checkdnsrr($domena, 'A'))) {
    odrzuc('domena');
}

Sprawdzanie domeny ma jeszcze jedną zaletę: wyłapuje literówki prawdziwych klientów, na przykład gmial.com. Zamiast odrzucać po cichu, poproś wtedy o poprawienie adresu, bo inaczej Twoja odpowiedź nigdy nie dojdzie.

6. Zadanie obliczeniowe dla kosztownych formularzy

Jeśli wysłanie formularza coś kosztuje, na przykład uruchamia AI, płatne API albo duży raport, warto dołożyć zadanie obliczeniowe (proof-of-work). Przeglądarka w tle szuka liczby, która razem z otrzymanym ciągiem daje skrót SHA-256 zaczynający się od kilkunastu zerowych bitów. Człowiekowi zajmuje to ułamek sekundy w tle, a bot, który chce wysłać tysiąc zgłoszeń, musi zapłacić tysiąc razy czasem procesora. Tak chronię narzędzia na mojej stronie, takie jak Zbadaj stronę i audyt konkurencji. Do zwykłego formularza kontaktowego zwykle nie jest potrzebne.

Czego nie robić

  • Nie mów botowi, dlaczego go odrzuciłeś. Odpowiedź „pole website powinno być puste” to instrukcja obejścia pułapki.
  • Nie zapisuj treści odrzuconych wiadomości. Wystarczą liczniki: ile odrzuceń i z jakiego powodu. Mniej danych to mniej obowiązków z RODO.
  • Nie blokuj krajów ani całych sieci na ślepo. Klient na urlopie albo w firmie z VPN-em też chce napisać.
  • Nie zapominaj o komunikatach dla ludzi. Gdy limit zadziała na prawdziwego klienta, pokaż numer telefonu albo adres e-mail, a nie sam kod błędu.

Kiedy jednak CAPTCHA?

Jeśli mimo tych zabezpieczeń spam dalej przychodzi, zwykle oznacza to człowieka albo bota sterującego prawdziwą przeglądarką. Wtedy CAPTCHA ma sens. Wybierz rozwiązanie niewidoczne dla większości osób i nastawione na prywatność, opisz je w polityce prywatności i włącz tylko tam, gdzie problem naprawdę występuje.

Podsumowanie

Pułapka na boty, bilet czasowy i limity zatrzymują automatyczny spam bez żadnej CAPTCHA. Poprawna walidacja e-maila sprawia, że formularz nie zamieni się w rozsyłarkę spamu z Twojej domeny. Jeśli maile z Twojej firmy i tak lądują w spamie u klientów, sprawdź też ustawienia domeny, o których piszę w artykule SPF, DKIM i DMARC krok po kroku.

Chcesz, żebym sprawdził formularze i bezpieczeństwo Twojej strony? Zamów audyt bezpieczeństwa albo napisz do mnie. Odpowiadam w 24 godziny, a nowi klienci dostają 20% rabatu na pierwsze zlecenie.

Najczęstsze pytania

Usługi związane z tematem

Masz podobny projekt?

Opisz, czego potrzebujesz, a w 24 godziny odeślę wycenę. Nowi klienci dostają 20% rabatu na pierwsze zlecenie.

Wyceń projektUmów rozmowę (20 min)

Czytaj dalej

AI11 października 20269 min czytania

Bezpieczny chatbot AI na stronie firmy: 8 zasad z przykładami kodu

Jak zabezpieczyć chatbota AI na stronie: prompt injection, podrabiana historia rozmowy, bezpieczne wyświetlanie odpowiedzi, limity kosztów, prywatność i AI Act. Zasady z działającego wdrożenia, z kodem PHP i JavaScript.