3

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?

lxg
  • 12,375
  • 12
  • 51
  • 73
  • Please try this one as test: https://aws.amazon.com/blogs/networking-and-content-delivery/amazon-s3-amazon-cloudfront-a-match-made-in-the-cloud/ – Nikhil Jul 10 '20 at 10:53
  • Role-based access from within AWS for the S3 bucket is not a good practice. It is best you had some other restrictions on the static site itself. – Nikhil Jul 10 '20 at 10:55
  • 1
    Have you considered using [Origin Access Identity](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html#private-content-granting-permissions-to-oai) to enable access to your bucket only from CloudFront? – Marcin Jul 10 '20 at 11:10
  • @Nikhil We are talking about **S3 website** buckets here, so the link is irrelevant in this screnario. Also the role-based approach is explicitely recommended by AWS themselves, see the link in my question. – lxg Jul 10 '20 at 11:11
  • 1
    @Marcin OAIs don’t work with **S3 website** buckets. – lxg Jul 10 '20 at 11:11
  • Yes I know. But if you want to keep your bucket private, you use OAI with non-website bucket. – Marcin Jul 10 '20 at 11:12
  • There’s a reason why I specifically need a website bucket. ;) I know how OAIs work, I have other setups where they do a great job. In fact, I started by copying a template from a stack with a non-website bucket and OAI, before I learned that this doesn’t work. Cloudfront treats website buckets as custom origins, and therefore can’t use OAI. – lxg Jul 10 '20 at 11:15
  • @lxg Oh. Got it. I used an S3 website way back before I knew about the Cloudfront option. – Nikhil Jul 10 '20 at 11:18
  • I haven't looked at the docs or tried this, so this isn't an answer, but you could potentially craft a bucket policy that has an IP condition. AWS publishes a list of CIDRs by service, but it will change as new edge locations are added to CloudFront (they also have an SNS notification when it changes). But tbh, I think you need to rethink your architecture. – Parsifal Jul 10 '20 at 12:38
  • @Parsifal True, some solutions on SO suggest IP whitelisting. But as the sources for those addresses are non-authoritative, and as IP addresses may change, this doesn’t seem like a sustainable solution to me. – lxg Jul 10 '20 at 13:58
  • Add a condition in S3 policy to limit the access to CloudFront useragent only would be the simplest approach. – jellycsc Jul 10 '20 at 14:08
  • 1. How would that be different from the approach I already have described in my question? 2. If I’d use a known header string, the whole concept would be pointless. – lxg Jul 10 '20 at 14:39
  • Questions: [1] what is the expected URL? There is no `Alias` so the URL is `https://{bucket name}.s3-website.{etc etc}` ? [2] Do you need to add the [`ViewerCertificate`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudfront-distribution-distributionconfig.html#cfn-cloudfront-distribution-distributionconfig-viewercertificate) to use the `certificateArn` parameter? – Al-un Jul 13 '20 at 03:04
  • @Al-un I removed the custom domain settings from the configuration in order to keep it as simple as possible. You are right, I should have removed the corresponding parameters as well. But this is not related to the problem. – lxg Jul 13 '20 at 06:52

3 Answers3

0

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"
}
seanWLawrence
  • 145
  • 1
  • 4
  • Thank you very much for sharing! I’m a bit surprised to see you using a website bucket with an OAI, because officially, this shouldn’t be possible. But maybe there’s some hidden feature which is only supported by the SDK. But then again you’re using a Lambda, which is a bit beneath the point, because I think you’re actually using the that one to do the access, rather than through Cloudfront. I’ll have to analyze your setup sometime in the next days and let you know if it solves the problem. – lxg Dec 19 '20 at 11:24
  • It’s not actually a website bucket - that was just the id/name I gave it for reference. Sorry for the confusion! You can see I have it blocking public access, which is not what a website bucket would do. – seanWLawrence Dec 21 '20 at 17:43
-1

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

Pierre
  • 1
  • 1
  • Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). –  Oct 25 '21 at 11:32
  • Thanks for your post. But, like already discussed with others, this is about **S3 website** buckets. So your answer misses the point. Please read the comments section on the original question. – lxg Oct 25 '21 at 18:01
-1

It is an old question, but I hope someone in the future could solve the issue with these cdk code:

const cfOriginAccessIdentity = new cf.OriginAccessIdentity(this, 'OAI', {
        comment: "only from cloudfront to s3",
    });

    const bucket = new s3.Bucket(this, PARAMS.cf.bucketId, {
        publicReadAccess: false,
        bucketName: PARAMS.cf.bucketName,
        websiteIndexDocument: 'index.html',
        websiteErrorDocument: '404.html',
        removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    bucket.grantRead(cfOriginAccessIdentity);

    const sampleHtmlFile = new s3deploy.BucketDeployment(this, PARAMS.cf.fileId, {
        destinationBucket: bucket,
        sources: [s3deploy.Source.asset('./public')]
    });

    const cloudfront = new cf.CloudFrontWebDistribution(this, PARAMS.cf.id, {
        originConfigs: [{
            s3OriginSource: {
                originPath: "",
                s3BucketSource: bucket,
                originAccessIdentity: cfOriginAccessIdentity,
            },
            behaviors: [{
                isDefaultBehavior: true,
                viewerProtocolPolicy: cf.ViewerProtocolPolicy.ALLOW_ALL,
            }],
        }],
        priceClass: cf.PriceClass.PRICE_CLASS_ALL,
        httpVersion: cf.HttpVersion.HTTP2_AND_3
    });

For the full version of the code, refer to this repo: https://github.com/vanvuvuong/cdk-sample/blob/master/lib/statistic-web.ts

  • Are you sure OAI works with S3 in website mode? This would be a significant change in Cloudfront/S3. – lxg Feb 10 '23 at 11:22
  • Yep, I have tested it and it worked well – Trần Đình Đồng Feb 13 '23 at 01:13
  • But in your project, you only have an `index.html` file in the root folder. Does this also work with named subdirectories e.g. if you have `/this-is-my-page/index.html` in your bucket, would your setup display the `index.html` contents when navigating to `https://example.com/this-is-my-page/`? – lxg Feb 13 '23 at 06:40
  • Just replace the originPath: "" with the path you want – Trần Đình Đồng Feb 13 '23 at 08:37
  • No, that’s not what I mean. Static Site Deployments work in a way where they create lots of folders which contain only one `index.html` file, and when you navigate to the folder part, it gives you the content of the file. That’s the whole point of S3 website mode. – lxg Feb 13 '23 at 09:14
  • As it’s currently written, your answer is unclear. Please [edit] to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Feb 14 '23 at 04:22