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:
- AJAX API (instead of form API
a=b&c=d...
) stops 99% of spam in my case. - 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:
- Configuring in
yaml
file and deploying AWS Lambda function in 1 command. - Abstracting us from FaaS provider: we can switch it from AWS to Google Cloud with some effort.
- Integrated with AWS CloudFormation: we can write a CloudFormation configuration in the same
yaml
file. - 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:
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
- Add new AWS IAM user. Let’s give a user the name
serverless-mailer
. CheckProgrammatic access
underAccess type
then clicknext
. - On the permissions screen, click on the
Attach existing policies directly
tab, search forAdministratorAccess
in the list, check it, and clicknext
. - The confirmation screen shows the user
Access key ID
andSecret access key
. - Run in a CLI, replacing
ACCESS_KEY_ID
andSECRET_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:
'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):
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:
functions:
staticSiteMailer:
handler: handler.staticSiteMailer
Rename it in the handler:
module.exports.staticSiteMailer = async event => {
Configure HTTP handler
Let’s set HTTP handler path and method and allow preflight requests:
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:
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:
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:
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:
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:
'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
:
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:
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):
'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:
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
- My email was blocked twice by mail service providers because of spam into it from a contact form.
- Employees spend time filtering out spam messages manually.
Ways to prevent contact form spam
There the following ways to fight contact form spam:
- 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 inaction
attribute of a form. - 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.
- 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 likefield1
. The downside of this method: some software like password manager browser plugins can auto-fill such fields and we can filter out valid submissions. - Time measuring: if the time between form loading and submission was too small (e.g. < 1s) - most likely it’s a spammer.
- 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.
- 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:
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
- 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.
- 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.
- We need logs in production for better observability, let’s add them.
- 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:
// ...
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 Lambda is billed by memory usage and time, so we let’s reduce memory requirements by setting memorySize
:
provider:
name: aws
runtime: nodejs12.x
memorySize: 128
Look for links in form fields
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):
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.
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.