How can I restrict access to an S3 website to Cloudfront?

Viewed 1149

I want to put a Cloudfront CDN in front of a S3 website bucket for a static website, and restrict read access of the bucket to the Cloudfront distribution. Pretty common, and documented by AWS and other sources. But for some reason I can’t get it to work.

And I’m not the first one to stumble upon this. (1, 2, 3). I’ve tried the solutions posted there, but again, no luck.

My setup, as a Cloudformation template, looks as follows:

AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  s3BucketName:
    Type: String
  domainName:
    Type: String
  certificateArn:
    Type: String
  bucketAuthHeader:
    Type: String

Resources:
  cloudfrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
         Enabled: true
         PriceClass: PriceClass_100
         Origins:
           - Id: !Sub "ID-${s3BucketName}"
             DomainName: !Sub "${s3BucketName}.s3-website.eu-central-1.amazonaws.com"
             CustomOriginConfig:
               OriginProtocolPolicy : http-only
             OriginCustomHeaders:
               - HeaderName: User-Agent
                 HeaderValue: !Ref bucketAuthHeader
         DefaultCacheBehavior:
           AllowedMethods:
             - GET
             - HEAD
             - OPTIONS
           CachedMethods:
             - GET
             - HEAD
             - OPTIONS
           DefaultTTL: 600
           ForwardedValues:
               QueryString: false
           TargetOriginId: !Sub "ID-${s3BucketName}"
           ViewerProtocolPolicy: redirect-to-https

  s3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref s3BucketName
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: _errors/404/index.html
    DeletionPolicy: Delete

  s3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref s3BucketName
      PolicyDocument:
        Version: 2012-10-17
        Id: "Cloudfront Bucket Access"
        Statement:
          - Sid: "Cloudfront Bucket Access via Referer"
            Effect: Allow
            Principal: "*"
            Action: "s3:GetObject"
            Resource:  !Sub "arn:aws:s3:::${s3BucketName}/*"
            Condition:
              StringEquals:
                aws:UserAgent:
                  - !Ref bucketAuthHeader

However, when applying this, I cannot access files via Cloudfront, I always get a 403. I also tried tweaking values in PublicAccessBlockConfiguration and AccessControl and tried uploading bucket content with aws s3 sync … --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers.

But I always end up with either public S3 content, or content being unavailable via Cloudfront as well.

Does anybody have an idea what else I could try?

2 Answers

I did it for a SPA like this using the AWS Cloud Development Kit (CDK):

import * as path from "path";
import * as s3 from "@aws-cdk/aws-s3";
import * as cloudfront from "@aws-cdk/aws-cloudfront";
import * as cloudfrontOrigins from "@aws-cdk/aws-cloudfront-origins";
import * as lambda from "@aws-cdk/aws-lambda"

// Private website bucket
const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
  encryption: s3.BucketEncryption.S3_MANAGED,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  publicReadAccess: false,
});

// Access identity that we can attach to the bucket to give it access
const websiteOriginAccessIdentity = new cloudfront.OriginAccessIdentity(
  this,
  "OriginAccessIdentity"
);

// Grant the access identity access to this bucket
websiteBucket.grantRead(websiteOriginAccessIdentity)

// Use this bucket and origin access identity to Cloudfront
const websiteBucketOrigin = new cloudfrontOrigins.S3Origin(
  props.websiteBucket,
  {
    originPath: "/",
    originAccessIdentity: websiteOriginAccessIdentity,
  }
);

// Add a Lambda@Edge to redirect all non-asset requests to `/index.html`. 
// Just like a rewrite rule with Amplify.
const websiteRedirectFunction = new lambda.Function(
  this,
  "RedirectFunction",
  {
    code: lambda.Code.fromAsset(path.resolve(__dirname, "../redirect"), {
      bundling: {
        command: [
          "bash",
          "-c",
          "npm install && npm run build && cp -rT /asset-input/dist/ /asset-output/",
        ],
        image: lambda.Runtime.NODEJS_12_X.bundlingDockerImage,
        user: "root",
      },
    }),
    handler: "index.redirect",
    runtime: lambda.Runtime.NODEJS_12_X,
  }
);

// Create Cloudfront distribution with the origin
const websiteCdn = new cloudfront.Distribution(this, "WebsiteCdn", {
  defaultBehavior: {
    origin: websiteBucketOrigin,
    edgeLambdas: [
      {
        eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
        functionVersion: websiteRedirectFunction.currentVersion,
      },
     ],
  },
});
// ../redirect/src/index.ts

import * as lambdaTypes from "aws-lambda";

export const redirect = (
  event: lambdaTypes.CloudFrontRequestEvent,
  _context: lambdaTypes.Context,
  callback: lambdaTypes.CloudFrontRequestCallback
): void => {
  const request = event.Records[0].cf.request;

  console.info({ request });

  // Redirects all files to index.html except for the specific file extensions for assets
  const isPageRequest = /^[^.]+$|\.(?!(css|gif|ico|jpg|js|png|txt|svg|woff|ttf|map|json)$)([^.]+$)/;

  console.log({ isPageRequest });

  if (isPageRequest) {
    request.uri = "/index.html";
  }

  console.log({ updatedRequest: request });

  callback(null, request);
};

This example assumes that you're deploying the website to the websiteBucket bucket that we're creating here. Left out for brevity since there are a few different ways to deploy your website to S3. For example, in a CodePipeline.

Note: this example uses TypeScript and assumes you have it set to build into ./dist/. Simple TSConfig below as an example:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"]
}

// package.json

"scripts": {
  "build": "tsc -b"
}

When using AWS CDK it's actually even simpler than @SeanWLawrence suggestion. When you create a CloudFront distribution with an S3 bucket that is not configured as a website as it's origin AWS will automatically create you an Object Access Identity and grant the identity read permissions to the bucket. This functionality mirrors what happens in the AWS console.

This behaviour is documented here - https://docs.aws.amazon.com/cdk/api/latest/docs/aws-cloudfront-readme.html#from-an-s3-bucket

Related