CORS with CloudFront and S3

The goal of this post is to hopefully save some of you out there hours of ripping your hair out trying to figure out why you get CORS errors on your CloudFront distributuion every once in while.

The intermittent CORS issues ended up coming down to my CloudFront distribution sometimes getting poisoned by a request that was being made made with no Origin header attached. As an example, while working on setting up CloudFront I would frequently invalidate the entire cache and then occationally load some of my resources from CloudFront directly in the browser instead of having a webpage load them via HTML tag or ajax javascript request. This resulted in CloudFront receiving and caching a response that did not include the appropriate CORS response headers since no Origin header on the request means S3 thinks the resource is being loaded directly. This would then break any cross origin access of the resources. I resolved this by forcing CloudFront to always send a specific Origin header to S3 which causes S3 to always believe it needs to attach the CORS headers to the response. This prevents blank Origin header based cache poisoning. The other option is to forward the Origin header through to S3 and cache based on that. If you go that route you’ll want to make sure you also forward the Access-Control-Request-Headers and Access-Control-Request-Method.

Here’s an example of how I configured the custom headers in CloudFormation to force sending a static Origin to S3:

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  DistCert:
    Type: String
    Description: The ARN of the certificate uploaded to ACM in us-east-1
  DistDomain:
    Type: String
    Description: The Domain for the CloudFront Distribution
Resources:
  AssetBucket:
    Type: AWS::S3::Bucket
    Properties:
      CorsConfiguration:
        CorsRules:
          -
            AllowedHeaders:
              - '*'
            AllowedMethods:
              - GET
              - HEAD
            AllowedOrigins:
              - '*'

  AssetBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
        PolicyDocument:
          Id: AssetBucketOriginPolicy
          Version: "2012-10-17"
          Statement:
            - Sid: PublicReadForGetBucketObjects
              Effect: Allow
              Principal:
                CanonicalUser: !GetAtt AssetBucketOriginOAI.S3CanonicalUserId
              Action: "s3:GetObject"
              Resource: !Sub "arn:aws:s3:::${AssetBucket}/*"
        Bucket: !Ref AssetBucket

  AssetBucketOriginOAI:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: "AssetBucketOriginOAI"

  MainDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Comment: The distribution that serves our assets
        Enabled: true
        PriceClass: PriceClass_All
        HttpVersion: http2
        DefaultRootObject: index.html
        Aliases:
          - !Ref DistDomain
        ViewerCertificate:
          AcmCertificateArn: !Ref DistCert
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2018
        Origins:
          - DomainName: !Sub "${AssetBucket}.s3.amazonaws.com"
            Id: AssetBucketOrigin
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${AssetBucketOriginOAI}"
            # The custom origin header means S3 believes every request comes from the same origin
            # which allows us to share the browser cached object between sites while disabling
            # origin header forwarding and still getting the proper CORS headers added to the
            # response.
            OriginCustomHeaders:
              -
                HeaderName: Origin        # Here's where we set a static Origin
                HeaderValue: CloudFront
        DefaultCacheBehavior:
          AllowedMethods:
            - GET
            - HEAD
            - OPTIONS
          Compress: true
          TargetOriginId: AssetBucketOrigin
          ForwardedValues:
            QueryString: false # Many real APIs need this to be true
            Cookies:
              Forward: none
            Headers:
              - Access-Control-Request-Headers
              - Access-Control-Request-Method
              # - Origin
          ViewerProtocolPolicy: redirect-to-https