reCaptcha With Lambda Part 1

By Adrian | November 12, 2019

“You need to add reCaptcha to your webforms” - Its advice I’ve given out to security teams each time I see a malicious link or some spam pusher in the resulting email. Its the poor user who cops the brunt of them, increasing the chance of a click, increasing that chance of compromise. Reading through formspam is just a waste of time for everyone. I recall an instance where an internal securiy team miscofigured a tool they were using, set it to run overnight and that mailbox ended up with 35k+ emails in it. To top it off, it was delivered to a Lotus Notes mailbox which just caused that system to hang due to the sheer volume of email. Not a fun exercise for that person to go through.

So, in the interests of practicing what I preach, I decided to do just that myself. But I had to make it harder on myself for a number of reasons. The main ones being, I am not a web developer, NodeJS, HTML/CSS isn’t my thing, this solution needs be completed via serverless means and any snippets of code, or any shred of a walkthrough just didn’t want to behave for me. So lots of trial and error testing and eventually I was able to cobble together a solution. I know its not perfect, but it seems to be working just fine. If my implementation is wrong, please feel free to contact me!.

Theres a few parts to get this solution working.

Inital configuration

First of all I needed to get a set of keys from Google reCaptcha. I opted for v2 I am not a Robot checkbox.

google-recaptcha-setup

The output gave me the site key (client) and secret key (server)

google-recaptcha-keys

Server Side Config

As my blog is running serverless (that is to say a static website on S3) there is no server for me to run the backend validation against. This is where SNS, Lambda, API gateway and IAM come into the picture.

Create a SNS Topic

In the Simple Notification Service console, I created a new topic called reCaptcha_prod.

create-sns-topic

After I setup the topic, I created a subscription for myself

sns-create-email-subscription

And confirmed that subscription when the email arrived

sns-confirm-subscription

For the final part of the SNS setup, I copied the ARN. This is needed in the server side Lamdba function.

sns-setup-complete

Function dependencies

Because I needed to build a NodeJS project with the axiom package for this to operate I needed to add a package.json file

{
  "name": "myProject",
  "version": "0.0.1",
  "private": true,
  "scripts": {},
  "dependencies": {
    "aws-sdk": "^2.229.1",
    "axios": "^0.18.0"
  }
}

Next I had to run an npm install to get all the required packages. In retrospect I’m thinking there has to be an easier way to perform a POST request without requiring axiom. Maybe its just me, and I really don’t want to try and figure that out at this time.

Code for the function

With all the packages downloaded, I added the following index.js file to the project directory. On the client side, when the Submit button is pressed, it makes a call out to API gateway, which then runs the code from Lambda. Substitute the reCaptchaSecren and snsTopic variables with your own values.

This code basically performs the following:

  • back end validation with reCaptcha
  • logging to cloudwatch
  • creates an SNS topic which in turn sends the email out.

index.js

'use strict';
const AWS = require("aws-sdk");
AWS.config.update({region: 'us-east-1'});

const axios = require('axios');
const reCapUrl = "https://www.google.com/recaptcha/api/siteverify";

// This is the reCaptcha secret key
const reCaptchaSecret = "<YOUR RECAPTCHA SECRET HERE>";

// This is the SNS Topic configured so you can be emailed.
const snsTopic = "<YOUR SNS TOPIC IN HERE>";

module.exports.webhook = async (event, context, callback) => {
  console.log("Starting ContactForm Processing for website form.");
  console.log(event)

  let body = event.body;
  let headers = event.headers;

  // process the urlencoded body of the form submit and put it in a map structure
  let parts = body.split('&');
  let result = [];
  
  // grab the params
  for (let i = 0, len = parts.length; i < len; i++) {
     let kVal = parts[i].split('=');
     // replace the + space then decode
     let key = decodeURIComponent(kVal[0].replace(/\+/g, ' '));
     result[key] = decodeURIComponent(kVal[1].replace(/\+/g, ' '));
  }
  
  // Enable to inspect the params in Amazon Cloudwatch
  // console.log(result);
  
  // verify the result by POSTing to google backend with secret and
  // frontend recaptcha token as payload
  let verifyResult = await axios ({
    method: 'post',
    url: reCapUrl,
    params: {
        secret: reCaptchaSecret,
        response: result["g-recaptcha-response"]
    },
    headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        "Accept": "*/*"
    }

  });

  // Enable to see the result
  // console.log(verifyResult);

  if (verifyResult.status === 200) {
    // Response was ok - but could still be a failed validation

  if (verifyResult.data["success"] === true) {
    let emailbody = "Contact form sumitted via:" + headers.origin +
    "\n\nFrom:"+result["name"]+
    "\nEmail: "+result["email"]+
    "\nComment: "+result["comment"]+
    "\n\nIP: " +headers["X-Forwarded-For"]+
    "\nUser Agent: " +headers["User-Agent"];

    // Create publish parameters
    var params = {
      Message: emailbody,
      TopicArn: snsTopic
    };

    // Create promise and SNS service object
    var publishTextPromise = new AWS.SNS({apiVersion: '2010-03-31'}).publish(params).promise();

    // Handle promise's fulfilled/rejected states
    publishTextPromise.then(
    function(data) {
      console.log("Message: " + params.Message + " was sent to the topic " + params.TopicArn);
      console.log("MessageID is " + data.MessageId);
      }).catch(
        function(err) {
          console.error(err, err.stack);
        });

      // now we return a HTTP 302 together with a URL to redirect the
      // browser to success URL
      callback(null, {
      statusCode: 302,
      headers: {
        Location: headers.origin,
      }

    });

  }
  
  } else {
    console.log("reCaptcha check failed. Most likely SPAM.");
  }
};

With the dependencies and code all prepared, I needed to zip up the contents to upload into Lambda. This is because the project contains external packages.

zip-lambda-function

Uploading the function

With the ZIP file in hand, I went to the Lambda console and selected Create Function

lambda-function-setup

  1. Select Author from Scratch
  2. Give the function a name
  3. Choose Node.js 10.x as the Runtime
  4. Select Create a new role with basic Lambda permissions
  5. Select Create Function

Under Function Code, I changed the Code Entry Type to Upload a .zip file and selected the bundled zip file I created. I also changed the Handler from index.handler to index.webhook to match the function name in the script.

lambda-function-code

When I pressed Save the zip file was uploaded.

After the function was saved I select Actions -> Publish new version -> Publish.

lambda-publish-function

Add the API Gateway Hook

Now that the function was ready, under the Designer section, I selected + Add Trigger.

lambda-add-trigger

  1. Select API Gateway
  2. Select Create a new API
  3. Select Open
  4. Press Add

Back in the Lambda screen I selected API Gateway and made a copy of the API Endpoint URL. This is what went into my client side HTML code.

lambda-api-configured

Update IAM Role to allow the function to publish to SNS

With the function created, I just needed to ensure that it could send results out to SNS, so I opened the IAM console

iam-search-roles

  1. Select Roles
  2. Search for reCaptcha_prod
  3. Select the role

From the Summary, I selected Add Inline policy and completed the following:

iam-create-inline-policy

  1. Selected SNS for the service
  2. Selected Write/Publish for the actions
  3. Selected the specific ARN for my SNS topic

On the review page, I gave the policy a name and pressed Create Policy

iam-create-inline-policy-2

Client Side Config

This is basically the HTML/CSS contact.html code that I put on my website. Hugo was happy to accept the HTML file, as long as it has a hugo meta header on it.

You will need your own API Gateway and Google reCaptcha site key for this.

+++
title = "Contact"
id = "contact"
+++

<h1> Want to contact me?</h1>

<h3><strong>I'd love to hear from you!</strong></h3>

<p>Hit me up on <b>Twitter</b>: @agoodcloud_blog or use the form below!<br><br></p>

<!-- Main (Home) section -->
<div id="v-Container">
    <script>
        $(document).ready(function() {

            $("#submit").click(function(e) {
                e.preventDefault();

                var name = $("#name").val(),
                    email = $("#email").val(),
                    comment = $("#comment").val();

                $.ajax({
                    type: "POST",
                    url: '<YOUR AWS API GATEWAY URL IN HERE>',
                    contentType: 'application/json',
                    data: JSON.stringify({
                        'name': name,
                        'email': email,
                        'comment': comment
                    }),
                    success: function(res){
                        $('#contact-submit').text('Email was sent.');
                    },
                    error: function(){
                        $('#contact-submit').text('Error.');
                    }
                });
            })
        });

    </script>

    <script src="https://www.google.com/recaptcha/api.js"></script>
    <div class="container"><form id="contact"  action="<YOUR AWS API GATEWAY URL IN HERE>" method="post" enctype="application/json">
        <p><input id="name" tabindex="1" name="name" required="" type="text" autofocus="" placeholder="Your name" size="40"/></p>
        <p><input tabindex="2" name="email" required="" type="email" placeholder="Email address" size="40" /></p>
        <p><textarea id="comment" name="comment" tabindex="3" required="" placeholder="Type your comment here" rows="4" cols="40"></textarea></p>
        <div class="g-recaptcha" ; data-sitekey="<YOUR GOOGLE RECAPTCHA CLIENT KEY IN HERE>" data-callback="ccb"></div><br>
        <button id="contact-submit" disabled name="submit" type="submit" data-submit="...Sending">Submit</button>
        </form></div>

        <script>
            function ccb(response) {
            document.getElementById("contact-submit").disabled = false;
            }
        </script>

    </div>

Recap to date

This is getting rather long now, but to recap the steps that I’ve taken so far:

  • Get Google reCaptcha v2 keys
  • Created the SNS topic (Noted the ARN)
  • Created a nodeJS project
    • Added package.json file
    • ran npm install to bundle the dependencies
    • Added index.js file
    • Included reCaptcha secret
    • Included SNS topic ARN
    • Zipped up the project
  • Created Lambda function with the zip file
  • Added API gateway hook
  • Updated IAM so that the Lambda function could publish to SNS
  • Created the client side code
    • Included the API gateway url

With that I will leave this post here and next cover off how I integrated and tested it with my blog.

Before I forget

Credit where credit is due. I could not have done this without these resources as a guide: