DEV Community

Arpad Toth for AWS Community Builders

Posted on • Originally published at arpadt.com

Dynamically handling origins in HTTP APIs

The last post discussed how we add a custom preflight handler for 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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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: '*'
Enter fullscreen mode Exit fullscreen mode

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.

Setting payload version

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 },
);
Enter fullscreen mode Exit fullscreen mode

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$/
]
Enter fullscreen mode Exit fullscreen mode

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: '',
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

In case of a failed validation, the origin response header will be

  // ... more headers
  "Access-Control-Allow-Origin": false
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)