Sendy is Insecure: How Not to Implement reCAPTCHA

Sendy's reCAPTCHA implementation doesn't do anything.

EDIT: We did it - Sendy has released a patch (v4.0.3.3) for this issue! I recommend upgrading if you’re affected. Thanks to Sendy for the quick response to this blog post, and thanks to every reader who helped make this happen.

A few months ago, I switched from Mailchimp to Sendy, a self-hosted email newsletter alternative. I wrote a whole post about why I switched from Mailchimp to Sendy, but the gist is that Mailchimp got too expensive too fast.

Since then, I’ve been generally satisfied with Sendy. Until one day, this happened:

Spam signups to my Sendy email list
Spam signups to my Sendy email list

Sendy sees these email addresses as distinct, but they’re actually largely duplicates because of the Gmail period trick. That means these email addresses were getting all of my emails (including the double opt-in ones) multiple times, which is an easy way for me to get reported for spam.

This incident finally spurred me to invest in protecting my email newsletter (which I really should’ve done in the first place). Luckily for me, Sendy comes with reCAPTCHA v2 support built-in! I thought it’d be easy to setup, but for some reason I couldn’t get it to work with my custom subscribe form, so I reached out to Sendy for help:

email1

Here’s what he responded with the next day:

email2

This line stuck out to me:

There’s no way to implement Google’s reCAPTCHA in an API.

That can’t be right - the reCAPTCHA documentation has a dedicated section on Server Side Validation!

I did some further investigation into Sendy’s standard subscribe form in an attempt to understand what Ben meant. Here’s an abbreviated version of the HTML behind that form:

<form action="https://sendy.victorzhou.com/subscribe" method="POST">
  <div>
    <label for="name">Name</label>
    <input type="text" name="name" id="name">
  </div>
  <div>
    <label for="email">Email</label>
    <input type="email" name="email" id="email">
  </div>
  <input type="hidden" name="list" value="LIST_ID_HERE">
  <input type="hidden" name="subform" value="yes">
  <div class="g-recaptcha" data-sitekey="SITE_KEY_HERE">
    <!-- some more reCAPTCHA stuff here -->
  </div>
  <a>Subscribe to list</a>
</form>

The first thing I noticed was the value of the form’s action attribute: https://sendy.victorzhou.com/subscribe. Interestingly enough, that’s the exact URL where the official Sendy API lives.

Now, we know the standard subscribe form:

  1. Has a working server-side validated reCAPTCHA implementation (I tested it).
  2. Uses the official Sendy Subscribe API.

Those two facts refute this claim:

There’s no way to implement Google’s reCAPTCHA in an API.

Additionally, why would Ben tell me that reCAPTCHA would ”take effect for for the standard subscribe forms that Sendy provides, not the subscribe API“?

<form action="https://sendy.victorzhou.com/subscribe" method="POST">
  <div>
    <label for="name">Name</label>
    <input type="text" name="name" id="name">
  </div>
  <div>
    <label for="email">Email</label>
    <input type="email" name="email" id="email">
  </div>
  <input type="hidden" name="list" value="LIST_ID_HERE">
  <input type="hidden" name="subform" value="yes">  <div class="g-recaptcha" data-sitekey="SITE_KEY_HERE">
    <!-- some more reCAPTCHA stuff here -->
  </div>
  <a>Subscribe to list</a>
</form>

Did you notice that highlighted line before? Why would the official Sendy form include a hardcoded subform field that’s not documented in the Sendy API?

Yup, you (might have) guessed it. The subform field enables server-side reCAPTCHA verification. If that field isn’t set to yes, server-side reCAPTCHA is completely disabled.

I sent what I’d found to Ben immediately:

email3

His response was not what I was hoping for:

email4

Oh no. 🤦🏻‍♂️

email5

The rest of this email chain doesn’t really go anywhere. That’s why I’m writing this post - please patch this issue, Ben!

Come on, Victor, is this really such a big deal?

Yes. Recall this quote from my most recent email to Ben:

What good is reCAPTCHA if anybody with a computer can write a script in 5 minutes to spam your email list with thousands of fake signups?

Okay, it’s a little exaggerated (you’d obviously need some programming / web dev experience), but I stand by that point. Let me prove it to you.

Here’s a Node.js script that spams a Sendy email list:

spam-sendy.js
// This code is meant ONLY AS AN EXAMPLE.
// DO NOT USE - it's illegal and will get you in trouble.
const request = require('request');
for (let i = 0; i < 100; i++) {
  request.post('https://sendy.victorzhou.com/subscribe').form({
    email: `sendy-vulnerability-${i}@victorzhou.com`,
    list: 'TEST_LIST_ID_HERE',
  });
}
WARNING: DO NOT use this code to attack a real email list without permission. That's super illegal and can get you in serious trouble.

That’s 7 lines of code to send as many spam signups as you want. To test this, I set up a test list on my actual Sendy account, enabled reCAPTCHA for it using my real reCAPTCHA keys, and ran the script.

spam result

It works. If you’re currently using Sendy’s reCAPTCHA implementation, now you know: it’s not doing what you think.

So how do I protect my email list?!

Well, ideally Sendy would release a patch that fixes this issue. Then you’d just have to update your installation and you’d be good to go! If you’re an affected Sendy user, you can help by emailing hello@sendy.co to ask for a patch. Feel free to link this post as a reference - the more support we can get, the better.

In case Sendy doesn’t release a fix soon, here’s how you can modify your Sendy installation yourself to fix this:

  1. Open subscribe.php in the root directory of your Sendy installation.
  2. Find where the reCAPTCHA verification happens. For me (Sendy v4.0.3.1), it started at this line of code:
if($recaptcha_secretkey!='')
You can probably just search the file for this line of code.
  1. Move that entire if statement block outside of the $subform check. Here’s what the result looked like for me:
subscribe.php
// This is where the reCAPTCHA logic should end up
if ($recaptcha_secretkey != '') {
  // the rest of the reCAPTCHA code here
}

if ($subform) {
  // Some IP Address logic here
  // ...

  // This is where the reCAPTCHA logic used to be
  // ...

  // Some more stuff afterwards
  // ...
}

You can also contact me if you have issues protecting your Sendy subscribe form and I’ll do my best to help.

In Conclusion,

Please don’t implement reCAPTCHA like this.

I write about ML, Web Dev, and more. Subscribe to get new posts by email!



This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

This blog is open-source on Github.

At least this isn't a full screen popup

That would be more annoying. Anyways, consider subscribing to my newsletter to get new posts by email! I write about ML, Web Dev, and more.



This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.