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 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.
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:
run a Nginx/Caddy/Traefik/pf reverse proxy as a separate app
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