Dynamically handling origins in HTTP APIs
ANY
and {proxy+}
routes in HTTP APIs. We can create a dynamic CORS validator using a Lambda function that handles not only static values but all types of origins.
1. The scenario
Alice was very happy with the custom preflight handler she created for her company’s application in HTTP API Gateway. But as the application (and the team) grew, she had to add more and more subdomains to the CORS handler in the API Gateway.
One day the QA lead asked her if the API works with dynamic subdomains. The reason was that testers created custom deployments based on the merge requests. Each deployment had a specific URL that was live until the test engineers had finished their valuable work. One of these URLs looked like this:
https://qa-101abc.example.com
The 101abc
part was dynamic and changed with each test deployment.
Alice’s current solution couldn’t handle this scenario. She added static values like http://localhost:3000
or https://dev.example.com
to the response header but didn’t know how to manage the unpredictable dynamic part.
2. Issues
Alice considered some potential solutions to the problem.
2.1. Wildcard character as a subdomain
It occurred to Alice that she could place a *
character to match all subdomains, like this:
Access-Control-Allow-Origin: 'https://*.example.com'
Unfortunately, CORS doesn’t allow the wildcard character as a replacement for all subdomains for a given domain. The CloudFormation stack didn’t deploy and rolled back with an error.
2.2. Wildcard character as a header response
We can use *
like this:
Access-Control-Allow-Origin: '*'
In this case, any origin (every domain) can access the backend resource. This is a misconfiguration that bad actors can exploit, so we don’t want to implement this solution.
2.3. Automatic CORS handling
Another issue in this scenario is that HTTP API automatically sends a response preflight OPTIONS
requests. It entirely ignores the headers that come back from the custom Lambda handler. That’s why Alice’s current solution has only the status code in the Lambda function’s response. It doesn’t make sense that she hardcodes the https://qa-101abc.example.com
because API Gateway will ignore it.
3. A solution
First, we should “turn off” the HTTP API’s automatic CORS response and dynamically validate the request origin in the Lambda function. Then we could return all CORS-related headers as the function’s response. The response headers would tell the browser if the request origin is allowed or not.
REST API Gateways allow mock integrations where we can set up custom response headers. We want something similar here with the Lambda function.
3.1. Turning off the automatic CORS handling
Let’s “turn off” the automatic CORS handling in HTTP API. This way, we can use the preflight handler Lambda function to return the CORS headers. The response to the browser’s OPTIONS
request will include the required headers.
We can “turn off” the HTTP API CORS handling by setting the payload version to 1
.
We should only do this for the Lambda integration we attached to the OPTIONS
route. We can leave other Lambda integrations that handle custom routes at version 2.
We can define the payload version in CDK when we create the Lambda integration instance:
const preflightIntegration = new HttpLambdaIntegration(
'SOME_ID_FOR_THE_STAGE',
preflightHandler,
{ payloadFormatVersion: PayloadFormatVersion.VERSION_1_0 },
);
By default, HTTP API uses payload version 2, and REST API Gateways work with version 1. We’ll need version 1 if we want the integration response that comes from the Lambda function to become the method response to the request.
3.2. Origin validation
Let’s store the allowed origins in an array. We can have static string values like http://localhost:3000
where we check for a full match.
const allowedOrigins = [
'http://localhost:3000',
'https://dev.example.com',
// ...other static values
/^https:\/\/qa-[0-9a-z]+\.example\.com$/
]
As for the dynamic origins we can have regular expressions. For example, the /^https:\/\/qa-[0-9a-z]+\.example\.com$/
will match the required https://qa-101abc.example.com
. We can create similar patterns for other domains and subdomains we want to allow.
The payload object has a headers
property, which contains all request headers. The origin
header has the information we want to validate.
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
const requestOrigins = event.headers.origin;
if (!requestOrigin) {
throw new Error('Invalid request');
}
// this function is the origin validator
const isAllowedOrigin = allowOrigin(requestOrigin, allowedOrigins);
return {
statusCode: 204,
headers: {
'Access-Control-Allow-Headers': 'Content-Type,Accept,Authorization',
'Access-Control-Allow-Origin': isAllowedOrigin ? requestOrigin : false,
'Access-Control-Allow-Credentials': false,
'Access-Control-Allow-Methods': 'OPTIONS,GET,POST,PATCH,PUT,DELETE',
'Access-Control-Max-Age': '300',
},
// payload version 1 requires the "body" property in the response
body: '',
};
}
We extract the origin
header’s value and pass it on to the validator function. The allowedOrigin
function returns a Boolean
depending on the validation result.
The origin validator code can look like this:
export function allowOrigin(requestOrigin: string, allowedOrigins: (string | RegExp)[]) {
const matchingOrigin = allowedOrigins.find((origin) => {
if (typeof origin === 'string') {
return origin === requestOrigin;
}
// if the value in the array is not a string, it should be a regular expression
return origin.test(requestOrigin);
});
return !!matchingOrigin;
}
The starting point is that we should return 'Access-Control-Allow-Origin': false
when we want to reject a request that comes from an origin. On the other hand, when we want to allow a request, we will want to return the origin in the header.
The allowOrigin
function does that. It loops over the array of allowed origins we have defined for the stage. If the current value is a string, it will check for a full match. If we define a regular expression pattern for a dynamic origin, the validator will check if they are a match.
In the case of a matching scenario, the function will return true
. The return value will be false
in any other case.
When the function returns true
, the method response (which is the same as the function’s response) will look like this:
{
// ... more headers
"Access-Control-Allow-Origin": "https://qa-101abc.example.com"
}
In case of a failed validation, the origin response header will be
// ... more headers
"Access-Control-Allow-Origin": false
That’s it! The HTTP API will now honor the preflight handler function’s response, and we can validate dynamic origins.
4. Summary
HTTP API automatically sends a response to the preflight OPTIONS
requests. It’s not the best fit for each scenario, though. When the backend has to serve origins with various or dynamic subdomains, we can create a custom preflight handler Lambda function.
We should use payload version 1 for the OPTIONS
integration, and the HTTP API will forward the Lambda function’s response to the browser. We can implement regular expression patterns to validate the dynamic patterns.
5. Further reading
Working with AWS Lambda proxy integrations for HTTP APIs - Difference between version 1 and 2 payloads
Configuring CORS for an HTTP API - How to configure CORS for HTTP APIs