Preventing Spam with an API
Anyone can submit a feedback. Even robots, spammers, and more. We could add some "captcha" to the form to somehow be protected from robots, or we can use some third-party APIs.
I have decided to use the free Akismet service to demonstrate how to call an API and how to make the call "out of band".
Signing up on Akismet
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).
Designing a Spam Checker Class
Create a new class under src/
named SpamChecker
to wrap the logic of calling the Akismet API and interpreting its responses:
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
The SpamChecker
class relies on an $akismetKey
argument. Like for the upload directory, we can inject it via a bind
container setting:
1 2 3 4 5 6 7 8 9 10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -12,6 +12,7 @@ services:
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
string $photoDir: "%kernel.project_dir%/public/uploads/photos"
+ string $akismetKey: "%env(AKISMET_KEY)%"
# makes classes in src/ available to be used as services
# 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:
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.
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
$ symfony console secrets:set AKISMET_KEY
1 2 3 4
Please type the secret value:
>
[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.
For development secrets, you can decide to commit the vault and the keys that have been generated in the config/secret/dev/
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:
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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -7,6 +7,7 @@ use App\Entity\Conference;
use App\Form\CommentFormType;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
+use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
@@ -35,7 +36,7 @@ class ConferenceController extends AbstractController
}
#[Route('/conference/{slug}', name: 'conference')]
- public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
+ public function show(Request $request, Conference $conference, CommentRepository $commentRepository, SpamChecker $spamChecker, string $photoDir): Response
{
$comment = new Comment();
$form = $this->createForm(CommentFormType::class, $comment);
@@ -53,6 +54,17 @@ 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()]);
Check that it works fine.
Managing Secrets in Production
For production, Platform.sh supports setting sensitive environment variables:
1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:AKISMET_KEY --value=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:
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 theprod
environment:1
$ APP_RUNTIME_ENV=prod symfony console secrets:generate-keys
Re-add the Akismet secret in the production vault but with its production value:
1
$ symfony console secrets:set AKISMET_KEY --env=prod
The last step is to send the decryption key to Platform.sh by setting a sensitive variable:
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"));'`
You can add and commit all files; the decryption key has been added in .gitignore
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
Going Further