Dynamically handling origins in HTTP APIs
Tags: aws, security, serverless
Published: 2022-10-06 05:51:40

The last post discussed how we add a custom preflight handler for <code>ANY</code> and <code>{proxy+}</code> 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 <a href="https://dev.to/aws-builders/fixing-up-cors-errors-in-http-api-5fh4" class="link" target="_blank" rel="noopener noreferrer">custom preflight handler</a> she created for her company's application in <a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html" class="link" target="_blank" rel="noopener noreferrer">HTTP API Gateway</a>. But as the application (and the team) grew, she had to add more and more subdomains to the <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" class="link" target="_blank" rel="noopener noreferrer">CORS</a> 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 <a href="https://aws.amazon.com/cloudformation/" class="link" target="_blank" rel="noopener noreferrer">CloudFormation</a> 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 <a href="https://securityintelligence.com/posts/cors-how-to-use-and-secure-a-cors-policy-with-origin/" class="link" target="_blank" rel="noopener noreferrer">misconfiguration</a> 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 <a href="https://aws.amazon.com/lambda/" class="link" target="_blank" rel="noopener noreferrer">Lambda</a> 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.

<a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html" class="link" target="_blank" rel="noopener noreferrer">REST API Gateways</a> allow <a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-mock-integration.html" class="link" target="_blank" rel="noopener noreferrer">mock integrations</a> 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/630wr78g8mjk9bqobtwz.png)

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 <a href="https://aws.amazon.com/cdk/" class="link" target="_blank" rel="noopener noreferrer">CDK</a> when we create the Lambda integration instance:

```ts
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.

```ts
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 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions" class="link" target="_blank" rel="noopener noreferrer">regular expressions</a>. 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.

```ts
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:

```ts
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:

```jsonc
{
  // ... more headers
  "Access-Control-Allow-Origin": "https://qa-101abc.example.com"
}
```

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

```jsonc
  // ... 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

<a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html" class="link" target="_blank" rel="noopener noreferrer">Working with AWS Lambda proxy integrations for HTTP APIs</a> - Difference between version 1 and 2 payloads

<a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-cors.html" class="link" target="_blank" rel="noopener noreferrer">Configuring CORS for an HTTP API</a> - How to configure CORS for HTTP APIs