Skip to content

Ochrona przed spamem za pomocą AI

Każdy może przesłać opinię. Nawet roboty, spamerzy itd. Możemy dodać zabezpieczenie CAPTCHA do formularza, aby w jakiś sposób ochronić się przed robotami, możemy też użyć zewnętrznych API.

Postanowiłem skorzystać z dużego modelu językowego (ang. Large Language Model), aby zdecydować, czy komentarz jest spamem, i pokazać, jak używać AI w aplikacji Symfony oraz jak wykonywać takie kosztowne wywołania „poza widoczną warstwą”.

Uzyskanie klucza API do AI

Symfony AI obsługuje wielu dostawców modeli: OpenAI, Anthropic, Google Gemini, Mistral, a nawet modele lokalne za pomocą Ollama. Ten rozdział używa OpenAI: zarejestruj się na platform.openai.com i utwórz klucz API. Jeśli wolisz innego dostawcę, kod pozostaje taki sam; zmienia się tylko konfiguracja.

Zależność od Symfony AI Bundle

Zamiast samodzielnie wywoływać API HTTP modelu, użyjemy Symfony AI Bundle. Dostarcza on abstrakcję platformy dla dostawców modeli (każdy dostawca jest dostępny jako osobny pakiet mostka) oraz agenta, który opakowuje model, aby wykonywać wywołania; korzysta też ze wszystkich narzędzi Symfony do debugowania, takich jak integracja z Symfony Profiler:

1
$ symfony composer req symfony/ai-bundle symfony/ai-agent symfony/ai-open-ai-platform

Note

Symfony AI to młody zestaw komponentów, wciąż eksperymentalny: jego API mogą ewoluować szybciej niż reszta Symfony.

Przepis mostka OpenAI skonfigurował już dla nas platformę; odwołuje się do zmiennej środowiskowej OPENAI_API_KEY (i dodał dla niej pustą wartość domyślną w .env):

config/packages/ai_open_ai_platform.yaml
1
2
3
4
ai:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'

Skonfiguruj na tej podstawie domyślnego agenta:

config/packages/ai.yaml
1
2
3
4
5
ai:
    agent:
        default:
            platform: 'ai.platform.openai'
            model: 'gpt-5-mini'

Korzystanie ze zmiennych środowiskowych

Z pewnością nie chcemy zapisywać na stałe wartości klucza w konfiguracji; dlatego jest ona odczytywana ze zmiennej środowiskowej OPENAI_API_KEY.

Następnie każdy programista ustawia faktyczną zmienną środowiskową lub zapisuje jej wartość w pliku .env.local:

.env.local
1
OPENAI_API_KEY=sk-...

W przypadku środowiska produkcyjnego, należy zdefiniować faktyczną zmienną środowiskową.

Działa to nieźle, ale zarządzanie wieloma zmiennymi środowiskowymi może stać się uciążliwe. W takim przypadku Symfony pozwala lepiej rozwiązać przechowywanie poufnych danych (ang. secrets).

Przechowywanie poufnych danych (ang. secrets)

Zamiast używać wielu zmiennych środowiskowych, Symfony może zarządzać sejfem, w którym można przechowywać wiele poufnych danych. Jedną z kluczowych funkcji jest możliwość zapisywania sejfu w repozytorium (jednak bez klucza do jego otwarcia). Kolejną świetną cechą tego rozwiązania jest to, że możemy zarządzać jednym sejfem w ramach jednego środowiska.

Poufne dane są zamaskowanymi zmiennymi środowiskowymi.

Dodaj klucz API OpenAI do sejfu:

1
$ symfony console secrets:set OPENAI_API_KEY
1
2
3
4
Please type the secret value:
>

[OK] Secret "OPENAI_API_KEY" encrypted in "config/secrets/dev/"; you can commit it.

Ponieważ uruchamiamy to polecenie po raz pierwszy, w katalogu config/secret/dev/ pojawiły się dwa klucze. Następnie w tym samym katalogu został zapisany OPENAI_API_KEY.

Podczas prac w środowisku deweloperskim, możesz zdecydować się na zapisanie w repozytorium sejfu oraz kluczy, które zostały wygenerowane w katalogu config/secret/dev/.

Wartości poufnych danych mogą być nadpisane ustawieniem zmiennej środowiskowej o tej samej nazwie.

Aby odczytać poufne dane z sejfu, użyj secrets:reveal:

1
$ symfony console secrets:reveal OPENAI_API_KEY

Projektowanie klasy Spam Checker

Utwórz nową klasę w katalogu src/ pod nazwą SpamChecker, aby opakować logikę pytania modelu, czy komentarz jest spamem:

src/SpamChecker.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
namespace App;

use App\Entity\Comment;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Exception\ExceptionInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

class SpamChecker
{
    public function __construct(
        private AgentInterface $agent,
    ) {
    }

    /**
     * @return int Spam score: 0: not spam, 1: maybe spam, 2: blatant spam
     */
    public function getSpamScore(Comment $comment, array $context): int
    {
        $messages = new MessageBag(
            Message::forSystem(<<<PROMPT
                You moderate comments submitted to a conference guestbook.
                Classify the comment as "ham", "maybe spam", or "blatant spam".
                Only answer with the classification.
                PROMPT),
            Message::ofUser(sprintf(<<<COMMENT
                IP: %s
                User agent: %s
                Author: %s (%s)
                Comment: %s
                COMMENT,
                $context['user_ip'] ?? '',
                $context['user_agent'] ?? '',
                $comment->getAuthor(),
                $comment->getEmail(),
                $comment->getText(),
            )),
        );

        try {
            $answer = strtolower($this->agent->call($messages)->getContent());
        } catch (ExceptionInterface) {
            // when the model cannot answer, let a human moderate the comment
            return 1;
        }

        return match (true) {
            str_contains($answer, 'blatant spam') => 2,
            str_contains($answer, 'maybe spam') => 1,
            default => 0,
        };
    }
}

System prompt informuje model o jego roli i ogranicza jego odpowiedzi; wiadomość użytkownika zawiera komentarz oraz kontekst jego wysłania (adres IP, user agent).

Metoda getSpamScore() zwraca trzy wartości w zależności od odpowiedzi modelu:

  • 2 jeśli komentarz jest „rażącym spamem” (ang. blatant spam);
  • 1 jeśli komentarz może być spamem lub gdy model jest nieosiągalny;
  • 0 jeśli komentarz nie jest spamem (ham).

Wyjście modelu to wolny tekst, nawet gdy prompt go ogranicza: parsuj je swobodnie (zamień na małe litery, użyj str_contains()). A gdy model w ogóle nie może odpowiedzieć, zamiast zawodzić, przejdź na moderację przez człowieka: AI powinno pomagać administratorowi, nigdy nie blokować księgi gości.

Tip

Spróbuj przesłać komentarz, który wygląda na rażący spam, np. "Buy cheap watches at http://example.com/!!!", aby zobaczyć model w akcji.

Sprawdzanie komentarzy pod kątem spamu

Podczas wysyłania nowego komentarza, prostym sposobem na sprawdzenie, czy nie jest on spamem, jest wykorzystanie obiektu klasy SpamChecker przed zapisaniem danych do bazy danych:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
--- i/src/Controller/ConferenceController.php
+++ w/src/Controller/ConferenceController.php
@@ -7,7 +7,8 @@ use App\Entity\Conference;
 use App\Form\CommentType;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
+use App\SpamChecker;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bridge\Doctrine\Attribute\MapEntity;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -34,8 +35,9 @@ final class ConferenceController extends AbstractController
         Request $request,
         #[MapEntity(mapping: ['slug' => 'slug'])]
         Conference $conference,
         CommentRepository $commentRepository,
+        SpamChecker $spamChecker,
         #[Autowire('%photo_dir%')] string $photoDir,
         #[MapQueryParameter] int $offset = 0,
     ): Response {
         $comment = new Comment();
@@ -48,6 +50,17 @@ final class ConferenceController extends AbstractController
             }

             $this->entityManager->persist($comment);
+
+            $context = [
+                'user_ip' => $request->getClientIp(),
+                'user_agent' => $request->headers->get('user-agent'),
+                'referrer' => $request->headers->get('referer'),
+                'permalink' => $request->getUri(),
+            ];
+            if (2 === $spamChecker->getSpamScore($comment, $context)) {
+                throw new \RuntimeException('Blatant spam, go away!');
+            }
+
             $this->entityManager->flush();

             return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);

Sprawdź, czy dobrze to działa.

Ograniczanie liczby wysyłanych komentarzy (ang. rate limiting)

Wykrywanie spamu chroni witrynę przed wyrafinowanymi spamerami. Uzupełniającą i znacznie tańszą ochroną jest ograniczenie tego, jak szybko ten sam klient może wysyłać komentarze: nikt zgodnie z prawem nie publikuje dziesiątek komentarzy na godzinę w księdze gości.

Dodaj komponent Symfony Rate Limiter:

1
$ symfony composer req rate-limiter

Skonfiguruj limiter, który akceptuje najwyżej 5 komentarzy na godzinę od tego samego klienta:

config/packages/rate_limiter.yaml
1
2
3
4
5
6
7
8
9
10
11
12
framework:
    rate_limiter:
        comment_submission:
            policy: 'fixed_window'
            limit: 5
            interval: '1 hour'

when@test:
    framework:
        rate_limiter:
            comment_submission:
                limit: 1000

Zautomatyzowane testy zgodnie z prawem wysyłają wiele komentarzy w krótkim czasie, więc limit jest podniesiony dla środowiska test.

Wymuś limiter na wysyłanych komentarzach za pomocą atrybutu #[RateLimit]; domyślnie identyfikuje on klientów na podstawie ich adresu IP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- i/src/Controller/ConferenceController.php
+++ w/src/Controller/ConferenceController.php
@@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
+use Symfony\Component\HttpKernel\Attribute\RateLimit;
 use Symfony\Component\Routing\Attribute\Route;

 final class ConferenceController extends AbstractController
@@ -31,6 +32,7 @@ final class ConferenceController extends AbstractController
         ]);
     }

+    #[RateLimit('comment_submission', methods: ['POST'])]
     #[Route('/conference/{slug}', name: 'conference')]
     public function show(
         Request $request,

Zwróć uwagę na argument methods: przeglądanie strony konferencji to żądanie GET i nie może być ograniczane; ograniczane są tylko wysyłane komentarze (żądania POST).

Gdy limit zostanie osiągnięty, Symfony automatycznie zwraca odpowiedź 429 Too Many Requests z nagłówkiem HTTP Retry-After, który informuje klienta, kiedy może ponowić próbę.

Ten sam komponent chroni również formularz logowania administratora przed atakami brute-force; włączenie ograniczania logowania (ang. login throttling) na firewallu zajmuje jedną linię:

1
2
3
4
5
6
7
8
9
10
--- i/config/packages/security.yaml
+++ w/config/packages/security.yaml
@@ -19,6 +19,7 @@ security:
         main:
             lazy: true
             provider: app_user_provider
+            login_throttling: ~
             form_login:
                 login_path: app_login
                 check_path: app_login

Domyślnie Symfony blokuje adres IP po 5 nieudanych próbach logowania na tę samą nazwę użytkownika w ciągu minuty (udane logowanie zeruje licznik). Użyj opcji max_attempts i interval, aby dostosować politykę.

Zarządzanie poufnymi danymi w środowisku produkcyjnym

W środowisku produkcyjnym, Upsun obsługuje ustawianie poufnych zmiennych środowiskowych:

1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:OPENAI_API_KEY --value=sk-abcdef

Ale jak wspomniano powyżej, użycie sejfu przechowującego poufne dane może być lepsze. Nie zwiększa bezpieczeństwa, ale ułatwia zarządzanie nimi w zespole projektowym. Wszystkie poufne dane są przechowywane w repozytorium, a jedyną zmienną środowiskową, o którą musisz zadbać w środowisku produkcyjnym, jest klucz odszyfrowujący. Dzięki temu każdy w zespole może dodać poufne dane, nawet jeśli nie ma dostępu do serwerów produkcyjnych. Konfiguracja jest jednak nieco bardziej skomplikowana.

Po pierwsze, wygeneruj parę kluczy do użytku produkcyjnego:

1
$ symfony console secrets:generate-keys --env=prod

On Linux and similiar OSes, use APP_RUNTIME_ENV=prod instead of --env=prod as this avoids compiling the application for the prod environment:

1
$ APP_RUNTIME_ENV=prod symfony console secrets:generate-keys

Ponownie wprowadź klucz do API OpenAI w sejfie produkcyjnym, ale z wartością dla środowiska produkcyjnego:

1
$ symfony console secrets:set OPENAI_API_KEY --env=prod

Ostatnim krokiem jest wysłanie klucza odszyfrowującego do Upsun poprzez ustawienie poufnej zmiennej:

1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:SYMFONY_DECRYPTION_SECRET --value=`php -r 'echo base64_encode(include("config/secrets/prod/prod.decrypt.private.php"));'`

Możesz dodawać i zatwierdzać (ang. commit) w repozytorium wszystkie pliki; klucz odszyfrowujący został dodany do .gitignore automatycznie, więc nigdy nie zostanie w nim zatwierdzony. Dla większego bezpieczeństwa można go usunąć z maszyny lokalnej, ponieważ został już wdrożony:

1
$ rm -f config/secrets/prod/prod.decrypt.private.php
This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version