Contents

Serverless Contact Form for a Static Website

March 23, 2020 | 20 min read

I had a dynamic website with ~20 normal and ~1500 spam contact form submissions monthly (not this website). I was migrating it to JAM stack making it a static website. I needed a solution for a static website contact form handling: sending email for every form submission.

I was looking for a cheap (< $1) solution. Therefore my post is not about how to find an expensive solution and just use it in two clicks. Also, I needed to stop 90+% of spam.

If you have a static website and you need a contact form on it - you need an external backend to handle contact form submissions. There are 2 choices: use some service (SaaS) or build it on your own.

Analysis of solutions

Service with form widgets

Some services provide a contact form widget with a backend. A user can visually design a form on such a service and copy-paste it’s HTML into a website. The example of a service is elfsight.

I’ve decided to not proceed with such services because I would like to control an HTML code of a form.

Service for form submissions

I’ve found the following services:

Service Free plan limitations, monthly Price for ~1500 form submissions with spam protection, monthly
99inbound 100 submissions $16
Basin - $12.5
EmailMeForm 100 submissions $9.95
FabForm.io 250 submissions $10-$32.5
form.io 1000 submissions $25, but no spam protection mentioning
FormBackend 10 submissions, no spam protection $5, but I’m not sure
formcake 100 submissions $19.99
formcarry 100 submissions $40
Formcubes 200 submissions $4, but too low limits
FormKeep 50 submissions $7.5
Formspree 50 submissions $40
formX 100 submissions, no spam protection $49.99
Getform 100 submissions $7.5
Kwes Forms 50 submissions $29
liveform - $3
Slapform 50 submissions $16
SmartForms 50 submissions $20
Static Forms No limitations It’s free

No one of these services was free for my requirements (except Static Forms but I’ve found it too late). Therefore I decided to build own solution.

Build own solution

Integration platforms

DataFire

We can use DataFire to send an email by Gmail on each contact form submission. An example of repo for that is here, it’s described here. The service is free for my usage bandwidth. At the time of choosing I couldn’t find out how to handle spam. Now, at the time of writing this blog post, I understand that:

  1. AJAX API (instead of form API a=b&c=d...) stops 99% of spam in my case.
  2. Custom spam handling logic can be coded inside JS handler.

Maybe I’ll try this platform next time.

Zapier

Zapier is similar to DataFire but costs $49 for my usage requirements. So I’ve started looking for other solutions.

Run own server

We can run AWS EC2 or Google Cloud VM. For example, we can run f1-micro shared-core Google Cloud VM instance for a ~$4 monthly.

But running own VM has high operational/maintenance costs: disk capacity may end, need to constantly install security updates for OS/Nginx/etc, need to set up monitoring, alerting. Also, there is no scalability: spam attack can consume 100% of VM CPU and block normal contact form submission because of lack of CPU.

FaaS/Serverless contact form

Serverless or FaaS solutions are cheap to maintain and highly scalable. Also, their cost is typically very low. If a contact form handler runs in 500ms 1500 times per month and requires 128mb - it will cost $0.00186 monthly in eu-central-1 AWS zone.

Therefore let’s build serverless contact form handler.

Implementing serverless contact form

Architecture

As a FaaS platform, we can use AWS Lambda, Google Cloud Functions, Netlify Functions, etc. Their cost for such low volume calls is very small and isn’t a concern. Let’ choose AWS Lambda because it has the biggest ecosystem.

For email delivery, we can choose e.g. AWS Simple Email Service or AWS Simple Notification Service. The former allows more email customizations, like using an HTML message body. The latter is more focused on notifications: for example, we can send both SMS and Email, or send only push notification. The ability to send SMS is more important in our case so let’s use AWS SNS. But AWS SES is the valid option to use too, you can see a tutorial for that.

We need to save failed email deliveries and spam messages to a queue. AWS Simple Queue Service is a good fit for that. AWS SNS has built-in support for sending failed notifications to SQS dead letter queues (DLQ).

Let’s use AWS CloudFormation for setting up infrastructure: SQS, SNS, Lambda.

The next decision: how to program and deploy lambda. We can do it directly: write code, upload a binary to S3, deploy it through an admin panel. It requires much effort. Or we can do it in a more “infrastructure as a code” way: e.g. by using Serverless framework. It’s advantages:

  1. Configuring in yaml file and deploying AWS Lambda function in 1 command.
  2. Abstracting us from FaaS provider: we can switch it from AWS to Google Cloud with some effort.
  3. Integrated with AWS CloudFormation: we can write a CloudFormation configuration in the same yaml file.
  4. Has plugins, for example, a plugin for warming functions.

The main disadvantage of the Serverless framework is one more level of abstraction, extra complexity.

Let’s use the Serverless framework. Now we need to choose a programming language. It doesn’t make a difference which language to use for 100 lines of code program. So let’s choose one by minimizing cold start time: it’s JS.

An alternative way to send emails from a contact form is to go directly to AWS SES/SNS API from static website JS code, without using a lambda function. But in such a case, we have no way to process data for spam checking.

The final architecture is the following:

Contact form architecture

Init Serverless

Run the following commands inside the project directory:

yarn init
yarn add serverless
yarn serverless create --template aws-nodejs --name static-site-mailer

It creates the serverless project with serverless.yml and handler.js.

Set up AWS credentials

  1. Add new AWS IAM user. Let’s give a user the name serverless-mailer. Check Programmatic access under Access type then click next.
  2. On the permissions screen, click on the Attach existing policies directly tab, search for AdministratorAccess in the list, check it, and click next.
  3. The confirmation screen shows the user Access key ID and Secret access key.
  4. Run in a CLI, replacing ACCESS_KEY_ID and SECRET_ACCESS_KEY with the keys on the confirmation screen:
yarn sls config credentials -o --provider aws --key ACCESS_KEY_ID --secret SECRET_ACCESS_KEY

Understanding the handler

The initial handler is the following:

handler.js
'use strict';

module.exports.hello = async event => {
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'Go Serverless v1.0! Your function executed successfully!',
        input: event,
      },
      null,
      2
    ),
  };

  // Use this code if you don't use the http event with the LAMBDA-PROXY integration
  // return { message: 'Go Serverless v1.0! Your function executed successfully!', event };
};

It always returns 200 HTTP code with a fixed message and incoming event. The event variable contains incoming HTTP request and it’s metadata. We will look into it later.

Note that the function uses async instead of callbacks. It means that any calls inside our handler should also use async.

Let’s rename the handler

Initially, the serverless configuration file is the following (with comments removed):

serverless.yml
service: static-site-mailer

provider:
  name: aws
  runtime: nodejs12.x

functions:
  hello:
    handler: handler.hello

Let’s rename function hello to staticSiteMailer in configuration file:

serverless.yml
functions:
  staticSiteMailer:
    handler: handler.staticSiteMailer

Rename it in the handler:

handler.js
module.exports.staticSiteMailer = async event => {

Configure HTTP handler

Let’s set HTTP handler path and method and allow preflight requests:

serverless.yml
functions:
  staticSiteMailer:
    handler: handler.staticSiteMailer
    events:      - http:          method: post          path: static-site-mailer          cors: true

Let’s run it locally (no deployment to AWS):

$ yarn sls invoke local --function staticSiteMailer --data '{"body": "HTTP request body"}'
{
    "statusCode": 200,
    "body": "{\n  \"message\": \"Go Serverless v1.0! Your function executed successfully!\",\n  \"input\": {\n    \"body\": \"HTTP request body\"\n  }\n}"
}

The variable event gets value from --data arg.

Allocate resources by AWS CloudFormation

We can set up AWS resources (SQS, SNS, Lambda) by configuring AWS CloudFormation inside our serverless.yml.

Let’s configure Lambda and SNS:

serverless.yml
resources:
  Resources:
    ContactMessages:
      Type: AWS::SNS::Topic
      Properties:
        TopicName: contact-messages-${self:provider.stage}
    ContactMessagesSubscription:
      Type: AWS::SNS::Subscription
      Properties:
        TopicArn: !Ref ContactMessages
        Protocol: email
        Endpoint: contact@mydomain.com
        DeliveryPolicy:
          healthyRetryPolicy:
            numRetries: 20
            minDelayTarget: 30
            maxDelayTarget: 600
            backoffFunction: exponential
        RedrivePolicy:
          deadLetterTargetArn: !GetAtt ContactMessagesDLQ.Arn
    ContactMessagesDLQ: # Dead Letter Queue (DLQ) for undelivered messages
      Type: AWS::SQS::Queue
      Properties:
        QueueName: contact-messages-dlq-${self:provider.stage}
    ContactMessagesDLQPolicy:
      Type: AWS::SQS::QueuePolicy
      Properties:
        PolicyDocument:
          Version: '2012-10-17'
          Id: DLQFromSNSPolicy-${self:provider.stage}
          Statement:
            - Sid: Allow-SNS-SendMessage-${self:provider.stage}
              Effect: Allow
              Principal: "*"
              Action: sqs:SendMessage
              Resource: !GetAtt ContactMessagesDLQ.Arn
              Condition:
                ArnEquals:
                  aws:SourceArn: !Ref ContactMessages
        Queues: [!Ref ContactMessagesDLQ]

Replace contact@mydomain.com with your destination email.

Let’s configure access from Lambda to SNS:

serverless.yml
provider:
  name: aws
  runtime: nodejs12.x
  iamRoleStatements:    - Effect: Allow      Action: SNS:Publish      Resource: !Ref ContactMessages  region: eu-central-1 # maybe you need us-east-1  stage: ${opt:stage, 'dev'}

Also, we specify AWS region and use two stages: dev (default) and prod.

Deploy Lambda, SNS and SQS

Let’s deploy our code to AWS:

$ yarn sls deploy --verbose
...
endpoints:
  POST - https://SOME_ID.execute-api.eu-central-1.amazonaws.com/dev/static-site-mailerfunctions:
  staticSiteMailer: static-site-mailer-dev-staticSiteMailer
...

Deployment takes 1-2 minutes. In the output of this command we see URL of our lambda function (I’ve replaced my ID with SOME_ID):

https://SOME_ID.execute-api.eu-central-1.amazonaws.com/dev/static-site-mailer

Now we have two ways of testing our function: locally and by requesting a deployed version.

Let’s request our deployed function:

curl -XPOST -H "Content-Type: application/json" https://SOME_ID.execute-api.eu-central-1.amazonaws.com/dev/static-site-mailer -d '{"name": "name", "phone_or_email": "phone", "text": "text"}'
{
  "message": "Go Serverless v1.0! Your function executed successfully!",
  "input": ...
}
Full output showing input event
{
  "message": "Go Serverless v1.0! Your function executed successfully!",
  "input": {
    "resource": "/static-site-mailer",
    "path": "/static-site-mailer",
    "httpMethod": "POST",
    "headers": {
      "Accept": "*/*",
      "CloudFront-Forwarded-Proto": "https",
      "CloudFront-Is-Desktop-Viewer": "true",
      "CloudFront-Is-Mobile-Viewer": "false",
      "CloudFront-Is-SmartTV-Viewer": "false",
      "CloudFront-Is-Tablet-Viewer": "false",
      "CloudFront-Viewer-Country": "RU",
      "content-type": "application/json",
      "Host": "SOME_ID.execute-api.eu-central-1.amazonaws.com",
      "User-Agent": "curl/7.64.1",
      "Via": "2.0 HIDDEN.cloudfront.net (CloudFront)",
      "X-Amz-Cf-Id": "HIDDEN",
      "X-Amzn-Trace-Id": "Root=1-HIDDEN",
      "X-Forwarded-For": "HIDDEN, HIDDEN",
      "X-Forwarded-Port": "443",
      "X-Forwarded-Proto": "https"
    },
    "multiValueHeaders": {
      "Accept": [
        "*/*"
      ],
      "CloudFront-Forwarded-Proto": [
        "https"
      ],
      "CloudFront-Is-Desktop-Viewer": [
        "true"
      ],
      "CloudFront-Is-Mobile-Viewer": [
        "false"
      ],
      "CloudFront-Is-SmartTV-Viewer": [
        "false"
      ],
      "CloudFront-Is-Tablet-Viewer": [
        "false"
      ],
      "CloudFront-Viewer-Country": [
        "RU"
      ],
      "content-type": [
        "application/json"
      ],
      "Host": [
        "SOME_ID.execute-api.eu-central-1.amazonaws.com"
      ],
      "User-Agent": [
        "curl/7.64.1"
      ],
      "Via": [
        "2.0 HIDDEN.cloudfront.net (CloudFront)"
      ],
      "X-Amz-Cf-Id": [
        "HIDDEN"
      ],
      "X-Amzn-Trace-Id": [
        "Root=1-HIDDEN"
      ],
      "X-Forwarded-For": [
        "HIDDEN, HIDDEN"
      ],
      "X-Forwarded-Port": [
        "443"
      ],
      "X-Forwarded-Proto": [
        "https"
      ]
    },
    "queryStringParameters": null,
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "stageVariables": null,
    "requestContext": {
      "resourceId": "HIDDEN",
      "resourcePath": "/static-site-mailer",
      "httpMethod": "POST",
      "extendedRequestId": "HIDDEN",
      "requestTime": "22/Mar/2020:12:02:13 +0000",
      "path": "/dev/static-site-mailer",
      "accountId": "HIDDEN",
      "protocol": "HTTP/1.1",
      "stage": "dev",
      "domainPrefix": "HIDDEN",
      "requestTimeEpoch": 1584878533598,
      "requestId": "310b7e4b-d286-4e2d-983e-62bfccf32c71",
      "identity": {
        "cognitoIdentityPoolId": null,
        "accountId": null,
        "cognitoIdentityId": null,
        "caller": null,
        "sourceIp": "HIDDEN",
        "principalOrgId": null,
        "accessKey": null,
        "cognitoAuthenticationType": null,
        "cognitoAuthenticationProvider": null,
        "userArn": null,
        "userAgent": "curl/7.64.1",
        "user": null
      },
      "domainName": "SOME_ID.execute-api.eu-central-1.amazonaws.com",
      "apiId": "SOME_ID"
    },
    "body": "{\"name\": \"name\", \"phone_or_email\": \"phone\", \"text\": \"text\"}",
    "isBase64Encoded": false
  }
}

We may be interested in input.requestContext.identity to know an ip and user agent.

The local invocation (yarn sls invoke --function staticSiteMailer) takes 4-5 seconds. Requesting a deployed version by URL takes only 250ms. But it works slowly for “cold” requests: cold start time is ~400-1000ms for our function.

After deploying we’ve created AWS SNS topic and subscription, but we need to verify ownership of email contact@mydomain.com. We need to click on a link in a confirmation message in the mailbox. We can check that SNS topic subscription is confirmed here.

Make a HTML form

Let’s build a form with 3 fields: name, phone/email, message.

Using raw HTML + JS when React exists can be confusing, but it’s easier to understand as an example. Let’s construct simple HTML form using Bootstrap 3 and jQuery:

<div class="row">
  <div class="col-xs-8 col-xs-offset-2">
    <form class="form-horizontal" id="customer-contact-form" action="{{ site.contact_form_post_url }}" accept-charset="UTF-8" method="post">
      <div class="form-group contact_message_name">
        <label class="control-label" for="contact_message_name"><abbr title="Required">*</abbr> Name</label>
        <input class="form-control" placeholder="James Smith" type="text" name="name" id="contact_message_name" required />
      </div>
      <div class="form-group contact_message_phone_or_email">
        <label class="control-label" for="contact_message_phone_or_email"><abbr title="Required">*</abbr> Phone or Email</label>
        <input class="form-control" placeholder="me@gmail.com" type="text" name="phone_or_email" id="contact_message_phone_or_email" required />
      </div>
      <div class="form-group contact_message_text">
        <label class="control-label" for="contact_message_text"><abbr title="Required">*</abbr> Message</label>
        <textarea rows="10" class="form-control" placeholder="Message text" name="text" id="contact_message_text" required></textarea>
      </div>
      <br>
      <div class="alert alert-danger form-hidden" id="contact-form-failed-to-submit" role="alert">
        <strong>Failed to submit the form</strong> Please, try again.
      </div>
      <div class="alert alert-success form-hidden" id="contact-form-was-submitted" role="alert"><strong>Thank you!</strong> We will contact you.</div>
      <div class="form-actions">
        <button id="contact-form-loading-submit-button" name="button" class="btn btn-default btn-primary btn-lg form-hidden">Submitting...</button>
        <button id="contact-form-submit-button" name="button" type="submit" class="btn-default btn btn-primary btn-lg">Submit</button>
      </div>
    </form>
  </div>
</div>

Replace {{ site.contact_form_post_url }} with your lambda URL (it’s in the output of yarn sls deploy --verbose).

We need the following CSS:

.form-hidden {
   display: none;
}

It looks like:

Simple HTML contact form
Simple HTML contact form

Writing spaghetti code with jQuery after a few years with React makes me hurt, but it’s still simple for understanding and using. Add the following JavaScript code:

function initContactForm() {
  var contactForm = $('#customer-contact-form');
  contactForm.submit(function(event) {
    event.preventDefault();
    var submitBtn = $("#contact-form-submit-button");
    submitBtn.attr("disabled", true);
    $('#contact-form-failed-to-submit').addClass('form-hidden');

    var req = {
        name:           $('#contact_message_name').val(),
        phone_or_email: $('#contact_message_phone_or_email').val(),
        text:           $('#contact_message_text').val(),
    };

    submitBtn.addClass('form-hidden');
    $('#contact-form-loading-submit-button').removeClass('form-hidden');

    $.ajax({
        type: 'POST',
        url: contactForm.attr('action'),
        data: JSON.stringify(req),
        contentType: "application/json",
        dataType: 'json',
        encode: true,
    }).done(function() {
      $('#contact-form-loading-submit-button').addClass('form-hidden');
      $('#contact-form-was-submitted').removeClass('form-hidden');
      contactForm.find('.form-group').addClass('form-hidden');
    }).fail(function(err) {
      $('#contact-form-loading-submit-button').addClass('form-hidden');
      $('#contact-form-failed-to-submit').removeClass('form-hidden');
      submitBtn.attr('disabled', false).removeClass('form-hidden');
    });
  });
}

$(document).ready(initContactForm);

Don’t forget to load the jQuery and Bootstrap 3 libraries.

After a form submission a user sees the following:

Message about successful contact form submission
Message about successful contact form submission

Configure CORS

Let’s specify the following HTTP response headers:

headers: {
  'Access-Control-Allow-Origin': '*', // Required for CORS support to work
  'Access-Control-Allow-Credentials': false, // Required for cookies, authorization headers with HTTPS
}

It’s safer to specify a concrete origin instead of *, but for testing it’s ok.

Also, we replace an HTTP response with {"message": "OK"} to shorten output.

Let’s code it:

handler.js
'use strict';

module.exports.staticSiteMailer = async event => {
  return {
    statusCode: 200,
    headers: {      'Access-Control-Allow-Origin': '*', // Required for CORS support to work      'Access-Control-Allow-Credentials': false, // Required for cookies, authorization headers with HTTPS    },    body: JSON.stringify(
      {
        message: 'OK',      },
      null,
      2
    ),
  };
};

Run yarn sls deploy -v again and our function URL remained the same:

https://SOME_ID.execute-api.eu-central-1.amazonaws.com/dev/static-site-mailer

Test it by cURL:

curl -XPOST -H "Content-Type: application/json" https://SOME_ID.execute-api.eu-central-1.amazonaws.com/dev/static-site-mailer -d '{"name": "name", "phone_or_email": "phone", "text": "text"}'
{
  "message": "OK"
}

Also, we can run it locally: yarn sls invoke local --function staticSiteMailer.

Pass ARN of SNS topic

We need an ARN of SNS topic in our JS handler to be able to publish to it.

Let’s output the ARN into yaml variable ${self:provider.stage}:ContactMessagesSnsTopicArn:

serverless.yml
resources:
  # ...
  Outputs:
    ContactMessagesSnsTopicArn:
      Description: "Arn of SNS topic to publish"
      Value: !Ref ContactMessages
      Export:
        Name: ${self:provider.stage}:ContactMessagesSnsTopicArn

We can get it’s value from environment:

serverless.yml
functions:
  staticSiteMailer:
    handler: handler.staticSiteMailer
    environment:      SNS_TOPIC_ARN:        Fn::ImportValue: ${self:provider.stage}:ContactMessagesSnsTopicArn    # ...

Let’s deploy it: yarn sls deploy -v. If you see

An error occurred: static-site-mailer-dev - No export named dev:ContactMessagesSnsTopicArn found.

I don’t know how to fix this race condition. A workaround: comment out environment, then deploy, then uncomment.

Publish to AWS SNS from AWS Lambda

Now we can write publishing to SNS in our handler (aws-sdk is already in dependencies of the serverless, no need to fetch it):

handler.js
'use strict';

const aws = require('aws-sdk');
const sns = new aws.SNS();

const publishToSNS = message => sns.publish({
    Message: message,
    TopicArn: process.env.SNS_TOPIC_ARN,
  })
  .promise();

const buildEmailBody = (identity, form) => {
  return `${form.text}

Name: ${form.name}
Phone or Email: ${form.phone_or_email}
Service information: ${identity.sourceIp} - ${identity.userAgent}
`;
};

module.exports.staticSiteMailer = async event => {
  const reqData = JSON.parse(event.body);  const emailBody = buildEmailBody(event.requestContext.identity, reqData);  await publishToSNS(emailBody);
  return {
    statusCode: 200,
    headers: {
      'Access-Control-Allow-Origin': '*', // Required for CORS support to work
      'Access-Control-Allow-Credentials': false, // Required for cookies, authorization headers with HTTPS
    },
    body: JSON.stringify(
      {
        message: 'OK',
      },
      null,
      2
    ),
  };
};

Check again that your email address is confirmed in SNS panel.

It’s time to check email delivery:

yarn sls deploy -v
curl -XPOST -H "Content-Type: application/json" https://SOME_ID.execute-api.eu-central-1.amazonaws.com/dev/static-site-mailer \
  -d '{"name": "Denis Isaev", "phone_or_email": "idenx@yandex.com", "text": "Please, contact me"}'

It works! We see an email in the mailbox:

Delivered by SNS email with sensitive information removed
Delivered by SNS email with sensitive information removed

Production stage in serverless

Run time yarn sls --stage prod deploy -v for deploying to production stage because we use stage: ${opt:stage, 'dev'} in serverless.yml. Note, that the URL of deployed into production stage Lambda differs.

Now we have a production-ready solution but without spam protection.

Fight spam

Why fighting contact form spam

  1. My email was blocked twice by mail service providers because of spam into it from a contact form.
  2. Employees spend time filtering out spam messages manually.

Ways to prevent contact form spam

There the following ways to fight contact form spam:

  1. Use JSON API instead of classic form (key1=value1&key2=value2) API for a submit endpoint. It’s efficient: in my case only this step filtered out 99+% of spam. But it should be implemented carefully: spammer should get successful 200 response when trying to submit by classic form API. Otherwise, automated spam software can try sending a JSON request. The alternative is to keep a submit URL in JavaScript: don’t set it in action attribute of a form.
  2. Simple pattern checking: look for links, for example. In my case, 80% of spam messages contain links, and 15% emails or phones. But this method has false-positives so it should be applied carefully.
  3. Honeypot fields: make hidden fields in a form name like email. Automated spam software fills and sends them. Real users fill email in another visible field a name like field1. The downside of this method: some software like password manager browser plugins can auto-fill such fields and we can filter out valid submissions.
  4. Time measuring: if the time between form loading and submission was too small (e.g. < 1s) - most likely it’s a spammer.
  5. Recaptcha: it can be always visible or visible only for suspicious users. I don’t like this method because it hurts normal users and reduces form conversion.
  6. SaaS for spam checking, e.g. akismet, which is free for personal sites and blogs or solutions from Cloudflare. I guess it can be very efficient and costly for a commercial website.

Methods #1 and #2 are easy to implement. With conservative implementation, they can lead to almost no false-positives. Let’s start with them and use #3 and #4 only if it doesn’t help.

We already use a JSON API in our code, so we need to implement only pattern checking.

Patterns checking

Set up SQS DLQ

Let’s move a form submission into a separate SQS dead letter queue (DLQ) if the submission contains suspicious patterns. Amazon SQS automatically deletes messages that have been in a queue for a more than maximum message retention period. The default retention period is 4 days. However, you can set the message retention period up to 14 days.

Let’s set up SQS DLQ for spam messages:

serverless.yml
provider:
  # ...
  iamRoleStatements:
    # ...
    - Effect: Allow      Action: SQS:SendMessage      Resource: !GetAtt SpamContactMessagesDLQ.Arn
functions:
  staticSiteMailer:
    # ...
    environment:
      # ...
      SPAM_SQS_DLQ_URL:        Fn::ImportValue: ${self:provider.stage}:SpamContactMessagesDLQUrl
resources:
  Resources:
    # ...
    SpamContactMessagesDLQ:      Type: AWS::SQS::Queue      Properties:        QueueName: spam-contact-messages-${self:provider.stage}  Outputs:
    # ...
    SpamContactMessagesDLQUrl:      Description: "Arn of SQS DLQ for spam messages"      Value: !Ref SpamContactMessagesDLQ      Export:        Name: ${self:provider.stage}:SpamContactMessagesDLQUrl

Save invalid requests to SQS DLQ

  1. The maximum message size is 256 kb for both AWS SQS and SNS. Therefore let’s check message size and truncate it if needed for code reliability.
  2. Let’s check empty fields: in our form, all fields (name, phone or email, message) are required. If we have an empty field - likely it’s a spam.
  3. We need logs in production for better observability, let’s add them.
  4. Defend from an automated spam software that typically sends form-data requests, not JSON. Currently, such requests return an error:
$ curl -XPOST https://SOME_ID.execute-api.eu-central-1.amazonaws.com/prod/static-site-mailer -d "name=name&phone_or_email=phone&text=text"
{"message": "Internal server error"}

And it can lead spam software to try again with JSON request. Therefore we should always return 200.

Implement all these changes:

handler.js
// ...
const sqs = new aws.SQS();const maxValidStrLength = 64 * 1024;const truncateStrForAws = str => str.substring(0, Math.min(str.length, maxValidStrLength));const saveSpamMessage = (identity, message) => sqs.sendMessage({  MessageAttributes: {    sourceIp: {      DataType: 'String',      StringValue: identity.sourceIp,    },    userAgent: {      DataType: 'String',      StringValue: truncateStrForAws(identity.userAgent),    },  },  MessageBody: truncateStrForAws(message),  QueueUrl: process.env.SPAM_SQS_DLQ_URL,}).promise();
const okResponse = {
  statusCode: 200,
  headers: {
    'Access-Control-Allow-Origin': '*', // Required for CORS support to work
    'Access-Control-Allow-Credentials': false, // Required for cookies, authorization headers with HTTPS
  },
  body: JSON.stringify(
    {
      message: 'OK',
    },
    null,
    2
  ),
};

module.exports.staticSiteMailer = async event => {
  const identity = event.requestContext.identity  let reqData = null;  try {    reqData = JSON.parse(event.body);  } catch (e) {    console.info(`Got spam with invalid JSON, saving to SQS: ${event.body}`);    await saveSpamMessage(identity, event.body);    return okResponse;  }  const emailBody = buildEmailBody(identity, reqData);  if (!reqData.name || !reqData.phone_or_email || !reqData.text || emailBody.length > maxValidStrLength) {    console.info(`Got spam message, saving to SQS: ${emailBody}`);    await saveSpamMessage(identity, emailBody);    return okResponse; // Return 200: spammer shouldn't know we've detected them.  }
  console.info(`Got message, sending to SNS: ${emailBody}`);
  await publishToSNS(emailBody);
  return okResponse;
};

Test invalid requests

Let’s test sending not JSON request and filling not all fields:

$ curl -XPOST https://SOME_ID.execute-api.eu-central-1.amazonaws.com/prod/static-site-mailer -d "name=name&phone_or_email=phone&text=text"
{
  "message": "OK"
}
$ curl -XPOST -H "Content-Type: application/json" -d '{"name": "name", "text": "text"}' https://SOME_ID.execute-api.eu-central-1.amazonaws.com/prod/static-site-mailer
{
  "message": "OK"
}

In AWS CloudWatch logs we see that it works:

2020-03-22T16:18:11.087Z {request_id} INFO Got spam with invalid JSON, saving to SQS: name=name&phone_or_email=phone&text=text
2020-03-22T16:18:25.394Z {request_id} INFO Got spam message, saving to SQS:
text Name: name Phone or Email: undefined Service information: {ip} - curl/7.64.1

Reduce AWS Lambda memory usage

In CloudWatch logs we see that our lambda function eats 1024 MB but uses only ~100 MB:

AWS CloudWatch logs for AWS Lambda execution
AWS CloudWatch logs for AWS Lambda execution

AWS Lambda is billed by memory usage and time, so we let’s reduce memory requirements by setting memorySize:

serverless.yml
provider:
  name: aws
  runtime: nodejs12.x
  memorySize: 128

Now let’s add pattern checking. For my website, it may be ok to get links so we can’t just filter all domains. But a lot of spammers send links with HTML markup. Customers of my website don’t do that therefore false positives rate will be very low.

Let’s search patterns href= (links) and src= (images) in all fields (not in the text field only: spam can be in any field):

handler.js
const doesMessageContainSpamPatterns = msg => msg.includes('href=') || msg.includes('src=');
module.exports.staticSiteMailer = async event => {
    // ...
    if (!reqData.name || !reqData.phone_or_email || !reqData.text || emailBody.length > maxValidStrLength || doesMessageContainSpamPatterns(emailBody)) {        // ...
    }
    // ...
}

Now let’s fill form with the text:

<a href="https://spammer.com">Very good site</a>

In the CloudWatch logs we see that it works:

2020-03-22T16:46:23.878Z {request_id} INFO Got spam message, saving to SQS:
<a href="https://spammer.com">Very good site</a> Name: Spammer Phone or Email: spammer@gmail.com Service information: {ip} - Mozilla/5.0 ...

Spam fighting results

After 1 week of running in production, dozens of spam messages were trapped. All of them were not JSON form submissions: it looks like we don’t even need pattern checking.

SQS DLQ with spam messages
SQS DLQ with spam messages

But one spam message reached a mailbox: it has contained no links or emails, only a description of advertised services in a text field and a contact number in a phone or email field. Such spam ratio is ok for me. There more methods to implement to improve spam protection if needed.

Conclusion

We’ve successfully implemented a serverless backend for a contact form submissions. It sends form submission detail to an email if it’s not spam. It uses AWS Lambda, SQS, SNS and built on serverless framework. Because of that, it costs < $1 monthly. The downside is the cold start time - ~400-1000ms for our function.

If I did it again - I would have started with Static Forms or DataFire. I’ve found them too late. They are completely free for my bandwidth and can be easier to use.


© 2024
Denis Isaev

Hi, I'm Denis. If you're looking to reach out to me, here are the ways. Also, I'm available for mentorship and coaching.

Contents