Photo by Marc-Olivier Jodoin on Unsplash

Using Lambda@Edge IP Allowlisting in Serverless

Learn how to easily and quickly enhance serverless security using Lambda@Edge IP Allowlisting with TypeScript and the AWS CDK to safeguard your ephemeral and non-production environments from unauthorised access.

Serverless Advocate
7 min readOct 7, 2023

--

Introduction

Previously this article discussed the use of IP ‘WhiteListing’ as opposed to ‘AllowListing’ as I wasn’t aware of the potential culture connotations of this word as discussed here: https://www.ncsc.gov.uk/blog-post/terminology-its-not-black-and-white

So, you have non-production environments (including ephemeral environments), that you don’t want anybody to access other than your engineering team — but how do you do this?

This article will cover a quick and easy way to utilise Lambda@Edge and Amazon CloudFront to restrict anybody accessing your serverless applications to requests from a selection of IP addresses. There are also other alternative options such as AWS WAF too of course which we will cover in a subsequent article.

This quick article was inspired by a LinkedIn post by Yan Cui discussing ephemeral environments and conditional statements in Serverless.

Why would we want to restrict access by IP?

Good question!

Most organisations that build out serverless applications don’t want competitors to see what they are currently working on and what they might be about to release to the market. It is typically just production that is publicly accessible.

👇 Before we go any further — please connect with me on LinkedIn for future blog posts and Serverless news https://www.linkedin.com/in/lee-james-gilmore/

What is Lambda@Edge and CloudFront?

Let’s first cover at a high-level what Lambda@Edge and Amazon CloudFront are.

Lambda@Edge is a feature of Amazon CloudFront that lets you run code closer to users of your application, which improves performance and reduces latency. With Lambda@Edge, you don’t have to provision or manage infrastructure in multiple locations around the world. You pay only for the compute time you consume — there is no charge when your code is not running. — https://aws.amazon.com/lambda/edge/

You can use Lambda functions to change CloudFront requests and responses at the following trigger points:

  • After CloudFront receives a request from a viewer (viewer request)
  • Before CloudFront forwards the request to the origin (origin request)
  • After CloudFront receives the response from the origin (origin response)
  • Before CloudFront forwards the response to the viewer (viewer response)

We can therefore ensure that we run a Lambda Edge function whenever a user makes a request to one of our CloudFront Distributions (Viewer Request), which will check the IP address that the request is coming from. If it matches our IP allowlist we allow it through, else we block the request.

Let’s walk through the code

Let’s now cover how we may want to do this with some basic AWS CDK and TypeScript code.

Firstly, we need to store our IP addresses in config so we can have them in one place which is easy to update.

const ipAllowListing = [
'81.134.1.234',
'67.23.11.458',
];

💡 Note: This is likely to be the IP address of your VPN solution which will have a static IP address.

This would typically be passed through into our deterministic stage specific config which is passed through into our CDK stacks (you can also pull this at deploy time from Secrets Manager of course):

...
[Stage.staging]: {
env: {
account: '111111111111',
region: Region.dublin,
},
stateful: {
userTableName: 'user-table-staging',
},
stateless: {},
client: {
bucketName: 'client-staging-bucket',
},
shared: {
powerToolsMetricsNamespace: `service-${Stage.staging}`,
powerToolServiceName: `api-service-${Stage.staging}`,
canaryNotificationEmail: 'your.email@gmail.com',
domainName: 'your-domain.io',
domainCertificateArn:
'arn:aws:acm:us-east-1:123456789101:certificate/cea121d3-19da-4193-b353-4b104811a4f4',
ipAllowListing,
},
stageName: Stage.staging,
},
[Stage.prod]: {
...
}
[Stage.develop]: {
...
}
[Stage.feature]: {
...
}

💡 Note: The article below goes into how we can use static deterministic configuration as a best practice in CDK apps:

We can then create a new custom L3 construct so we can utilise this PrivacyLambda across any of our CloudFront Distributions (whether that is for S3 hosted web apps or protecting our APIs in Amazon API Gateway or AWS AppSync):

import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as lambda from 'aws-cdk-lib/aws-lambda';

import { Construct } from 'constructs';

interface SimplePrivacyEdgeFunctionProps
extends Pick<
cloudfront.experimental.EdgeFunctionProps,
'runtime' | 'functionName'
> {
/**
* The stage name for the edge function
*/
stageName: string;
/**
* The runtime for the edge function
*/
runtime: lambda.Runtime;
/**
* A comma seperated list of ip addresses which are allow listed e.g. ['1.1.1.1', '2.2.2.2']
*/
allowListedIpAddresses: string[];
}

type FixedPrivacyEdgeFunctionProps = Omit<
cloudfront.experimental.EdgeFunctionProps,
'runtime'
>;

function returnFunctionCode(allowListedIpAddresses: string[]) {
const allowedIPs = allowListedIpAddresses.map((ip) => `'${ip}'`).join(', ');
return `
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const response = event.Records[0].cf.response;
const clientIP = request.clientIp;

const allowedIPs = [${allowedIPs}];

if (!allowedIPs.includes(clientIP)) {
return {
status: '403',
statusDescription: 'Forbidden',
body: 'Access denied',
headers: {
'content-type': [{ key: 'Content-Type', value: 'text/plain' }],
},
};
}

return request;
};
`;
}

export class PrivacyEdgeLambda extends Construct {
public readonly edgeFunction: cloudfront.experimental.EdgeFunction;

constructor(
scope: Construct,
id: string,
props: SimplePrivacyEdgeFunctionProps
) {
super(scope, id);

const fixedProps: FixedPrivacyEdgeFunctionProps = {
tracing: lambda.Tracing.ACTIVE,
code: lambda.Code.fromInline(
returnFunctionCode(props.allowListedIpAddresses)
),
handler: 'index.handler',
};

this.edgeFunction = new cloudfront.experimental.EdgeFunction(
this,
'PrivacyLambda' + id,
{
// fixed props
...fixedProps,
// custom props
...props,
}
);
}
}

💡 Note: For more information on creating Custom L3 CDK constructs, including the code below, please view the following article:

We can see that the basic code of the Lambda function is simply reading the clientIp address the request is coming from, and ensuring that it exists in the array of allow listed IP addresses that are included from our static configuration (allowedIps):

exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const response = event.Records[0].cf.response;
const clientIP = request.clientIp;

const allowedIPs = [${allowedIPs}];

if (!allowedIPs.includes(clientIP)) {
return {
status: '403',
statusDescription: 'Forbidden',
body: 'Access denied',
headers: {
'content-type': [{ key: 'Content-Type', value: 'text/plain' }],
},
};
}

return request;
};

We can then utilise this in our Stateless CDK stack by first creating the PrivacyLambda instance:

...
const privacyEdgeFunction = new PrivacyEdgeLambda(this, 'IpAllowList', {
runtime: lambda.Runtime.NODEJS_18_X,
allowListedIpAddresses: ipAllowListing,
stageName: stageName,
}).edgeFunction;
...

And then passing the the instance of the PrivacyLambda into our custom API CloudFront Distribution construct in our Stateless stack:

new ApiCloudFrontDistribution(this, 'Distribution', {
stageName: stageName,
domainName: domainName,
domainCertificateArn: domainCertificateArn,
api,
enabled: true,
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
comment: `${stageName} api distribution`,
edgeFunction: privacyEdgeFunction,
}).distribution;

We can also do exactly the same in our Stateful or Client CDK stacks with our custom S3 Web CloudFront Distribution construct:

const cloudFrontDistribution = new WebCloudFrontDistribution(
this,
'Distribution',
{
enabled: true,
stageName: stageName,
subDomain,
removalPolicy: RemovalPolicy.DESTROY,
originAccessIdentity,
bucket: this.bucket,
domainCertArn: domainCertificateArn,
edgeFunction: privacyEdgeFunction,
}
).distribution;

We can then use the power of AWS CDK and conditionally utilise the PrivacyLambda construct based on the Stage i.e. only use if the Stage being deployed is not Production:

new ApiCloudFrontDistribution(this, 'Distribution', {
stageName: stageName,
domainName: domainName,
domainCertificateArn: domainCertificateArn,
api,
enabled: true,
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
comment: `${stageName} api distribution`,
edgeFunction: !Stage.prod ? privacyEdgeFunction : undefined,
}).distribution;

Specifically this line:

...
edgeFunction: !Stage.prod ? privacyEdgeFunction : undefined,
...

Where are stage is a config lookup rather than using “magic strings”:

export const enum Stage {
featureDev = 'featureDev',
staging = 'staging',
prod = 'prod',
develop = 'develop',
}

Once deployed, this now means that we will invoke the Lambda@Edge function to check the IP address is allow listed any time somebody tries to access our non-production environments.

Wrapping up

I hope you enjoyed this quick article, and if you did then please feel free to share and feedback!

Please go and subscribe on my YouTube channel for similar content!

I would love to connect with you also on any of the following:

https://www.linkedin.com/in/lee-james-gilmore/
https://twitter.com/LeeJamesGilmore

If you enjoyed the posts please follow my profile Lee James Gilmore for further posts/series, and don’t forget to connect and say Hi 👋

Please also use the ‘clap’ feature at the bottom of the post if you enjoyed it! (You can clap more than once!!)

About me

Hi, I’m Lee, an AWS Community Builder, Blogger, AWS certified cloud architect and Global Head of Technology & Architecture based in the UK; currently working for City Electrical Factors (UK) & City Electric Supply (US), having worked primarily in full-stack JavaScript on AWS for the past 6 years.

I consider myself a serverless advocate with a love of all things AWS, innovation, software architecture and technology.

*** The information provided are my own personal views and I accept no responsibility on the use of the information. ***

You may also be interested in the following:

--

--

Global Head of Technology & Architecture | Serverless Advocate | Mentor | Blogger | AWS x 7 Certified 🚀