Ghost subscribe form hit by spam signups, hurting sender reputation

Today my blog started getting hit by a sudden burst of spam email signup attempts:

It looks like potentially someone is trying to validate a stolen or bought list, and then the owners of the email addresses that work are marking the confirmation emails (which they did not ask for) as spam. (At least, I can’t think of any other plausible way this would benefit the attacker.)

If possible, I would like to avoid hiding behind Cloudflare. I’m self-hosting on Fly.io, and using Postmark for transactional email, and Mailgun for newsletters.

I see in my logs POST //members/api/send-magic-link" 201, and my hunch is that someone is hitting the endpoint directly with a script.

I found promising solutions by @HauntedThemes:

SELECT * FROM subscribers WHERE subscribed_url != ‘’;
DELETE * FROM subscribers WHERE subscribed_url = ‘’;

And @dlford:

ALTER TABLE subscribers MODIFY subscribed_url varchar(2000) NOT NULL;

However, the Ghost DB schema has changed since then, and I’m not sure if these sorts of approaches are still recommended.

What mitigation would you recommended?

Thanks!

For anyone else with the same problem:

I ended up capitulating and putting my blog behind Cloudflare, but even with bot fight mode activated, it didn’t help. However, a quick look in the actual logs showed that it was a very basic script kiddie attack, and even though the IPs seemed to change (mostly US and UK so far), they hadn’t bothered to spoof the user agent:

"user-agent":"Python/3.12 aiohttp/3.9.5"

So I added a Cloudflare Web Application Firewall (WAF) custom rule to block requests to /api/send-magic-link when the user agent “contains” Python and aiohttp.

I’m still looking for a better solution, that I can implement at the Ghost or Fly.io level, without relying on Cloudflare.

2 Likes

Subsequent attacks have used python-requests user agent rather than aiohttp. I now block all python requests to that endpoint.

Test with:

curl -A "python" -X POST  -H 'Content-Type: application/json' -d '{email: email@example.com, emailType: \'signin\', integrityToken: integrityToken}' "https://yourghosturl.com/members/api/send-magic-link/"
curl -A "something else" -X POST  -H 'Content-Type: application/json' -d '{email: email@example.com, emailType: \'signin\', integrityToken: integrityToken}' "https://yourghosturl.com/members/api/send-magic-link/"

I looked into several other options in addition to Cloudflare. However, I’m using the (unofficial!) Ghost docker image on Alpine Linux on Fly, and:

  • fail2ban has unmet dependencies
  • iptables (not a good solution anyway) is missing the kernel module for the string extension.
  • neither Apache nor Nginx are running in the container

This seems to leave two options:

  1. run a Nginx/Caddy/Traefik/pf reverse proxy as a separate app
  2. hack around and add user-agent blocking to that endpoint in (my fork of) Ghost core

I have already made cursory attempts to use these approaches, and it turned out to be more complicated than I expected. But when I have more time, I will give another go at running my own firewall.

The moral of the story: good managed hosting is expensive for a reason :wink:

2 Likes