Fixing CORS error from API Gateway with Serverless?

How do you use Serverless to create an AWS API Gateway that can be accessed via a browser?

I tried following these serverless docs that describe how to allow CORS on an API Gateway but they don’t seem to work at all.

I wrote a serverless.yml to create a small search service with a /query endpoint like:

org: myorg
service: myservice
variablesResolutionMode: 20210219

# NOTE: Remember to define your environment variables for the custom fields below.
custom:
  name: ${sls:stage}-${self:service}
  region: ${opt:region, "us-east-1"}
  vpcId: ${env:VPC_ID}
  subnetId1: ${env:SUBNET_ID1}
  subnetId2: ${env:SUBNET_ID2}
  javaVersion: provided.al2

provider:
  name: aws
  profile: ${env:PROFILE}
  region: ${self:custom.region}
  versionFunctions: false
  apiGateway:
    shouldStartNameWithService: true
  tracing:
    lambda: false
  timeout: 15
  environment:
    stage: prod
    DISABLE_SIGNAL_HANDLERS: true
  iam:
    role:
      statements: ${file(roleStatements.yml)}
  vpc:
    securityGroupIds:
      - Ref: EfsSecurityGroup
    subnetIds:
      - ${self:custom.subnetId1}
      - ${self:custom.subnetId2}

package:
  individually: true

functions:
  query:
    name: ${self:custom.name}-query
    runtime: ${self:custom.javaVersion}
    handler: native.handler
    provisionedConcurrency: 1
    memorySize: 256
    events:
      - http:
          path: /query
          method: post
          cors: true
      - http:
          path: /query
          method: options
          cors: true
    dependsOn:
      - EfsMountTarget1
      - EfsMountTarget2
      - EfsAccessPoint
    fileSystemConfig:
      localMountPath: /mnt/data
      arn:
        Fn::GetAtt: [EfsAccessPoint, Arn]
    package:
      artifact: target/function.zip
    environment:
      DOMAIN_ORIGIN: "${env:LUCENE_SERVERLESS_QUERY_DOMAIN_ORIGIN}"
      QUARKUS_LAMBDA_HANDLER: query
      QUARKUS_PROFILE: prod

  index:
    name: ${self:custom.name}-index
    runtime: ${self:custom.javaVersion}
    handler: native.handler
    reservedConcurrency: 1
    memorySize: 256
    timeout: 180
    dependsOn:
      - EfsMountTarget1
      - EfsMountTarget2
      - EfsAccessPoint
    fileSystemConfig:
      localMountPath: /mnt/data
      arn:
        Fn::GetAtt: [EfsAccessPoint, Arn]
    package:
      artifact: target/function.zip
    environment:
      QUARKUS_LAMBDA_HANDLER: index
      QUARKUS_PROFILE: prod
    events:
      - sqs:
          arn:
            Fn::GetAtt: [WriteQueue, Arn]
          batchSize: 5000
          maximumBatchingWindow: 5

  enqueue-index:
    name: ${self:custom.name}-enqueue-index
    runtime: ${self:custom.javaVersion}
    handler: native.handler
    memorySize: 256
    package:
      artifact: target/function.zip
    vpc:
      securityGroupIds: []
      subnetIds: []
    events:
      - http: POST /index
    environment:
      QUARKUS_LAMBDA_HANDLER: enqueue-index
      QUARKUS_PROFILE: prod
      QUEUE_URL:
        Ref: WriteQueue

  delete-index:
    name: ${self:custom.name}-delete-index
    runtime: ${self:custom.javaVersion}
    handler: native.handler
    memorySize: 256
    dependsOn:
      - EfsMountTarget1
      - EfsMountTarget2
      - EfsAccessPoint
    fileSystemConfig:
      localMountPath: /mnt/data
      arn:
        Fn::GetAtt: [EfsAccessPoint, Arn]
    package:
      artifact: target/function.zip
    environment:
      QUARKUS_LAMBDA_HANDLER: deleteIndex
      QUARKUS_PROFILE: prod

resources:
  Resources:
    WriteQueue:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: ${self:custom.name}-write-queue
        VisibilityTimeout: 900
        RedrivePolicy:
          deadLetterTargetArn:
            Fn::GetAtt: [WriteDLQ, Arn]
          maxReceiveCount: 5

    WriteDLQ:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: ${self:custom.name}-write-dlq
        MessageRetentionPeriod: 1209600 # 14 days in seconds

    FileSystem:
      Type: AWS::EFS::FileSystem
      Properties:
        BackupPolicy:
          Status: DISABLED
        FileSystemTags:
          - Key: Name
            Value: ${self:custom.name}-fs
        PerformanceMode: generalPurpose
        ThroughputMode: elastic # faster scale up/down
        Encrypted: true
        FileSystemPolicy:
          Version: "2012-10-17"
          Statement:
            - Effect: "Allow"
              Action:
                - "elasticfilesystem:ClientMount"
              Principal:
                AWS: "*"

    EfsSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        VpcId: ${self:custom.vpcId}
        GroupDescription: "mnt target sg"
        SecurityGroupIngress:
          - IpProtocol: -1
            CidrIp: "0.0.0.0/0"
          - IpProtocol: -1
            CidrIpv6: "::/0"
        SecurityGroupEgress:
          - IpProtocol: -1
            CidrIp: "0.0.0.0/0"
          - IpProtocol: -1
            CidrIpv6: "::/0"

    EfsMountTarget1:
      Type: AWS::EFS::MountTarget
      Properties:
        FileSystemId: !Ref FileSystem
        SubnetId: ${self:custom.subnetId1}
        SecurityGroups:
          - Ref: EfsSecurityGroup

    EfsMountTarget2:
      Type: AWS::EFS::MountTarget
      Properties:
        FileSystemId: !Ref FileSystem
        SubnetId: ${self:custom.subnetId2}
        SecurityGroups:
          - Ref: EfsSecurityGroup

    EfsAccessPoint:
      Type: "AWS::EFS::AccessPoint"
      Properties:
        FileSystemId: !Ref FileSystem
        PosixUser:
          Uid: "1000"
          Gid: "1000"
        RootDirectory:
          CreationInfo:
            OwnerGid: "1000"
            OwnerUid: "1000"
            Permissions: "0777"
          Path: "/mnt/data"
      DependsOn:
        - EfsMountTarget1
        - EfsMountTarget2

Running sls deploy runs without error and I can access /query via curl just fine with a call like:

curl -H "Content-Type: application/json" -X POST -d '{"indexName":"myindex","query": "some query term"}' https://myappid.execute-api.us-east-1.amazonaws.com/dev/query

However, if I make the equivalent call from Javascript in the browser like:

function search(text) {
    $.ajax({
        url: 'https://myappid.execute-api.us-east-1.amazonaws.com/dev/query?'+new Date().getTime(),
        type: 'POST',
        dataType: 'json',
        data: {
            'indexName': 'myindex',
            'query': text
        },
        success: function(data) {
            console.log('success');
            console.log(data);
        },
        error: function(xhr, status, error) {
            console.log('error');
        }
    });
}

search('some search term');

I get the infamous CORS error:

Access to XMLHttpRequest at 'https://myappid.execute-api.us-east-1.amazonaws.com/dev/query?1724248946289' from origin 'http://www.example.com:8111' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Why am I getting this error? I have cors: true for both the post and options methods for /query.

If I manually check the setup in AWS Console, it configures it as a proxy integration, so I don’t see any CORS header mappings under the Integration Response tabs.

Attempted Solution 1: Switch from the default proxy-handler to non-proxy.

I tried switch from a proxy to a direct lambda handler, but then the API Gateway is unable to pass the request to the lambda, and my lambda complains of either no input or malformed input.

I’ve tried adding these request template mappings for the application/json content type:

{"body": $input.json('$')}

$input.body

{"body": "$util.escapeJavaScript($input.body)"}

{"body": "$input.body"}

{
  "indexName": "$input.json('$.indexName')",
  "query": "$input.json('$.query')"
}

{
  "indexName": $input.json('$.indexName'),
  "query": $input.json('$.query')
}

$input.json('$')

{"content": $input.json('$')}

but they either send over a corrupt request or no request.

How do I get the API Gateway to work with CORS?

Edit: I tweaked the way my Java lambda response handler added headers, and generalized them a little to:

APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent();

Map<String, String> headers = new HashMap<>();
headers.put("Access-Control-Allow-Origin", "*");
headers.put("Access-Control-Allow-Credentials", "true");
headers.put("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS, HEAD");
headers.put("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With");

try {
    return response.withStatusCode(200).withHeaders(headers).withBody(queryResponseWriter.writeValueAsString(queryResponse));
} catch (JsonProcessingException e) {
    LOG.error(e);
    return response.withStatusCode(500).withHeaders(headers).withBody("Internal error");
}

Now if I make the ajax request, I can confirm it makes it to the lambda, becaues a CloudWatch log entry is generated, but I think it’s first querying OPTIONS, not POST, because my handler is logging blank content. Plus, the browser error has now changed to:

www.example.com/:1 Access to XMLHttpRequest at 'https://myappid.execute-api.us-east-1.amazonaws.com/dev/query?1724255655036' from origin 'http://www.example.com:8111' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

The “Response to preflight request doesn’t pass access control check” is new. I’m guessing my lambda isn’t handling the OPTIONS request, which I believe also needs to respond with the appropriate CORS headers, and that’s also failing.

Can the OPTIONS handler return the exact same CORS headers as GET and POST?