Step 16: Preventing Spam with an API

Preventing Spam with an API

I have decided to use the free service to demonstrate how to call an API and how to make the call “out of band”.

Sign-up for a free account on akismet.com and get the Akismet API key.

Depending on Symfony HTTPClient Component

Instead of using a library that abstracts the Akismet API, we will do all the API calls directly. Doing the HTTP calls ourselves is more efficient (and allows us to benefit from all the Symfony debugging tools like the integration with the Symfony Profiler).

To make API calls, use the Symfony HttpClient Component:

Create a new class under named SpamChecker to wrap the logic of calling the Akismet API and interpreting its responses:

src/SpamChecker.php

  1. namespace App;
  2. use App\Entity\Comment;
  3. use Symfony\Contracts\HttpClient\HttpClientInterface;
  4. class SpamChecker
  5. {
  6. private $client;
  7. private $endpoint;
  8. public function __construct(HttpClientInterface $client, string $akismetKey)
  9. {
  10. $this->client = $client;
  11. $this->endpoint = sprintf('https://%s.rest.akismet.com/1.1/comment-check', $akismetKey);
  12. }
  13. /**
  14. * @return int Spam score: 0: not spam, 1: maybe spam, 2: blatant spam
  15. *
  16. * @throws \RuntimeException if the call did not work
  17. */
  18. public function getSpamScore(Comment $comment, array $context): int
  19. {
  20. $response = $this->client->request('POST', $this->endpoint, [
  21. 'body' => array_merge($context, [
  22. 'blog' => 'https://guestbook.example.com',
  23. 'comment_author' => $comment->getAuthor(),
  24. 'comment_author_email' => $comment->getEmail(),
  25. 'comment_content' => $comment->getText(),
  26. 'comment_date_gmt' => $comment->getCreatedAt()->format('c'),
  27. 'blog_lang' => 'en',
  28. 'blog_charset' => 'UTF-8',
  29. 'is_test' => true,
  30. ]),
  31. ]);
  32. $headers = $response->getHeaders();
  33. if ('discard' === ($headers['x-akismet-pro-tip'][0] ?? '')) {
  34. }
  35. $content = $response->getContent();
  36. if (isset($headers['x-akismet-debug-help'][0])) {
  37. throw new \RuntimeException(sprintf('Unable to check for spam: %s (%s).', $content, $headers['x-akismet-debug-help'][0]));
  38. }
  39. return 'true' === $content ? 1 : 0;
  40. }
  41. }

The HTTP client request() method submits a POST request to the Akismet URL ($this->endpoint) and passes an array of parameters.

The getSpamScore() method returns 3 values depending on the API call response:

  • 2: if the comment is a “blatant spam”;
  • 1: if the comment might be spam;
  • 0: if the comment is not spam (ham).

Tip

Use the special akismet-guaranteed-spam@example.com email address to force the result of the call to be spam.

Using Environment Variables

patch_file

  1. --- a/config/services.yaml
  2. +++ b/config/services.yaml
  3. @@ -12,6 +12,7 @@ services:
  4. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
  5. bind:
  6. $photoDir: "%kernel.project_dir%/public/uploads/photos"
  7. + $akismetKey: "%env(AKISMET_KEY)%"
  8. # makes classes in src/ available to be used as services
  9. # this creates a service per class whose id is the fully-qualified class name

We certainly don’t want to hard-code the value of the Akismet key in the services.yaml configuration file, so we are using an environment variable instead (AKISMET_KEY).

It is then up to each developer to set a “real” environment variable or to store the value in a .env.local file:

.env.local

  1. AKISMET_KEY=abcdef

For production, a “real” environment variable should be defined.

That works well, but managing many environment variables might become cumbersome. In such a case, Symfony has a “better” alternative when it comes to storing secrets.

Instead of using many environment variables, Symfony can manage a vault where you can store many secrets. One key feature is the ability to commit the vault in the repository (but without the key to open it). Another great feature is that it can manage one vault per environment.

Secrets are environment variables in disguise.

Add the Akismet key in the vault:

  1. Please type the secret value:
  2. >
  3. [OK] Secret "AKISMET_KEY" encrypted in "config/secrets/dev/"; you can commit it.

Because this is the first time we have run this command, it generated two keys into the config/secret/dev/ directory. It then stored the AKISMET_KEY secret in that same directory.

Secrets can also be overridden by setting an environment variable of the same name.

Checking Comments for Spam

One simple way to check for spam when a new comment is submitted is to call the spam checker before storing the data into the database:

patch_file

  1. --- a/src/Controller/ConferenceController.php
  2. @@ -7,6 +7,7 @@ use App\Entity\Conference;
  3. use App\Form\CommentFormType;
  4. use App\Repository\CommentRepository;
  5. use App\Repository\ConferenceRepository;
  6. +use App\SpamChecker;
  7. use Doctrine\ORM\EntityManagerInterface;
  8. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  9. use Symfony\Component\HttpFoundation\File\Exception\FileException;
  10. @@ -35,7 +36,7 @@ class ConferenceController extends AbstractController
  11. }
  12. #[Route('/conference/{slug}', name: 'conference')]
  13. - public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
  14. + public function show(Request $request, Conference $conference, CommentRepository $commentRepository, SpamChecker $spamChecker, string $photoDir): Response
  15. {
  16. $comment = new Comment();
  17. $form = $this->createForm(CommentFormType::class, $comment);
  18. @@ -53,6 +54,17 @@ class ConferenceController extends AbstractController
  19. }
  20. $this->entityManager->persist($comment);
  21. +
  22. + $context = [
  23. + 'user_ip' => $request->getClientIp(),
  24. + 'user_agent' => $request->headers->get('user-agent'),
  25. + 'referrer' => $request->headers->get('referer'),
  26. + 'permalink' => $request->getUri(),
  27. + ];
  28. + if (2 === $spamChecker->getSpamScore($comment, $context)) {
  29. + throw new \RuntimeException('Blatant spam, go away!');
  30. + }
  31. +
  32. $this->entityManager->flush();
  33. return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);

Check that it works fine.

For production, SymfonyCloud supports setting sensitive environment variables:

  1. $ symfony var:set --sensitive AKISMET_KEY=abcdef

But as discussed above, using Symfony secrets might be better. Not in terms of security, but in terms of secret management for the project’s team. All secrets are stored in the repository and the only environment variable you need to manage for production is the decryption key. That makes it possible for anyone in the team to add production secrets even if they don’t have access to production servers. The setup is a bit more involved though.

First, generate a pair of keys for production use:

Re-add the Akismet secret in the production vault but with its production value:

  1. $ APP_ENV=prod symfony console secrets:set AKISMET_KEY

The last step is to send the decryption key to SymfonyCloud by setting a sensitive variable:

  1. $ symfony var:set --sensitive SYMFONY_DECRYPTION_SECRET=`php -r 'echo base64_encode(include("config/secrets/prod/prod.decrypt.private.php"));'`

You can add and commit all files; the decryption key has been added in automatically, so it will never be committed. For more safety, you can remove it from your local machine as it has been deployed now:

  1. $ rm -f config/secrets/prod/prod.decrypt.private.php

This work, including the code samples, is licensed under a license.