[AWS CDK] CloudFront のカスタムオリジンとして Amazon S3 のウェブサイトエンドポイントを指定した場合のアクセス制御について検証してみた

2024.01.14

2023/01/17 追記:本記事の検証について、キャッシュの考慮をしておらず正しい検証結果を得られていませんでした。再検証を次の記事で行っているので、合わせてご覧ください。
https://dev.classmethod.jp/articles/re-amazon-cloudfront-custom-origin-access-control-verification-with-amazon-s3-website-endpoint/

こんにちは、CX 事業本部製造ビジネステクノロジー部の若槻です。

Amazon CloudFront では、Distribution のカスタムオリジンとして S3 バケットのウェブサイトエンドポイントを指定することができます。

S3 バケットに配置されているコンテンツを CloudFront で配信したい場合は、OAI や OAC の認証による S3 オリジンを利用した構成を使うことが多いと思いますが、両者のアカウントが異なる場合などの理由によりカスタムオリジンを使うこともあります。

しかしその際に、S3 バケットへのアクセスをバケットポリシーの PrincipalCondition によりどの程度制限できるのでしょうか。特に CloudFront Distribution を前段に置くので S3 バケットへの直接アクセスは制限したいところです。

そこで今回、AWS CDK を使って環境構築をしつついくつかのバケットポリシーのパターンで検証をしてみました。

試してみた

Principal で匿名アクセスを許可した場合

まずは匿名アクセスを許可した場合はアクセス制限がかかっていないことを確認してみます。

実装全体の CDK コードは以下になります。AnyPrincipal クラスにより匿名アクセスの許可を設定しています。

lib/cdk-sample-stack.ts

import {
  aws_iam,
  aws_s3,
  aws_cloudfront,
  aws_cloudfront_origins,
  aws_s3_deployment,
  Stack,
  RemovalPolicy,
  Duration,
  CfnOutput,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class CdkSampleStack extends Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // S3 バケット
    const websiteBucket = new aws_s3.Bucket(this, 'WebsiteBucket', {
      websiteIndexDocument: 'index.html',
      removalPolicy: RemovalPolicy.DESTROY,
      // autoDeleteObjects: true, // ポリシー単純化のため一旦コメントアウト
    });

    // S3 バケット名を出力
    new CfnOutput(this, 'BucketName', {
      value: websiteBucket.bucketName,
    });

    // CloudFront ディストリビューション
    const distribution = new aws_cloudfront.Distribution(this, 'Distribution', {
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          ttl: Duration.minutes(5),
          httpStatus: 403,
          responseHttpStatus: 403,
          responsePagePath: '/error.html',
        },
        {
          ttl: Duration.minutes(5),
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/error.html',
        },
      ],
      defaultBehavior: {
        viewerProtocolPolicy:
          aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        // HttpOrigin により、S3 バケットのウェブサイトエンドポイントを指定
        origin: new aws_cloudfront_origins.HttpOrigin(
          websiteBucket.bucketWebsiteDomainName,
          {
            protocolPolicy: aws_cloudfront.OriginProtocolPolicy.HTTP_ONLY,
          }
        ),
      },
    });

    // CloudFront ディストリビューションのドメイン名を出力
    new CfnOutput(this, 'DistributionDomainName', {
      value: distribution.distributionDomainName,
    });

    // バケットポリシーの明示的な作成
    const websiteBucketPolicy = new aws_s3.BucketPolicy(
      this,
      'WebsiteBucketPolicy',
      {
        bucket: websiteBucket,
      }
    );

    // バケットポリシー
    websiteBucketPolicy.document.addStatements(
      new aws_iam.PolicyStatement({
        effect: aws_iam.Effect.ALLOW,
        principals: [new aws_iam.AnyPrincipal()], // "Principal":{"AWS":"*"}
        actions: ['s3:GetObject'],
        resources: [websiteBucket.arnForObjects('*')],
      })
    );

    // S3 バケットへのコンテンツのデプロイ
    new aws_s3_deployment.BucketDeployment(this, 'WebsiteDeploy', {
      distribution,
      destinationBucket: websiteBucket,
      distributionPaths: ['/*'],
      sources: [
        aws_s3_deployment.Source.data(
          '/index.html',
          '<html><body><h1>Hello, World!</h1></body></html>'
        ),
        aws_s3_deployment.Source.data(
          '/error.html',
          '<html><body><h1>Error!!!</h1></body></html>'
        ),
        aws_s3_deployment.Source.data('/favicon.ico', ''),
      ],
    });
  }
}

上記のデプロイにより作成されたバケットポリシーは以下になります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::cdksamplestack-websitebucket75c24d94-dzbqzhrl3hcw/*"
        }
    ]
}

S3 バケットの Web サイトエンドポイントへのアクセスは許可されています。

$ curl http://${BucketName}.s3-website-ap-northeast-1.amazonaws.com/
<html><body><h1>Hello, World!</h1></body></html>

CloudFront ディストリビューションへのアクセスは許可されています。

$ curl https://${DistributionDomainName}/
<html><body><h1>Hello, World!</h1></body></html>

期待通りの動作です。

Principal で特定サービスからのアクセスを許可する場合

Principal として cloudfront.amazonaws.com を指定し、CloudFront サービスからのアクセスのみ許可できるか確認してみます。

ServicePrincipal により指定のサービスの許可を設定しています。

lib/cdk-sample-stack.ts

    // バケットポリシー
    websiteBucketPolicy.document.addStatements(
      new aws_iam.PolicyStatement({
        effect: aws_iam.Effect.ALLOW,
        principals: [new aws_iam.ServicePrincipal('cloudfront.amazonaws.com')], // { "Service": "cloudfront.amazonaws.com" }
        actions: ['s3:GetObject'],
        resources: [websiteBucket.arnForObjects('*')],
      })
    );

上記のデプロイにより作成されたバケットポリシーは以下になります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::cdksamplestack-websitebucket75c24d94-dzbqzhrl3hcw/*"
        }
    ]
}

S3 バケットの Web サイトエンドポイントへのアクセスは拒否されます。

$ curl http://${BucketName}.s3-website-ap-northeast-1.amazonaws.com/
<html>
<head><title>403 Forbidden</title></head>
<body>
<h1>403 Forbidden</h1>
<ul>
<li>Code: AccessDenied</li>
<li>Message: Access Denied</li>
<li>RequestId: JMPX72YDQYWQW8KY</li>
<li>HostId: 7yqN4HnD7SBZzLN0ZYv7vaqQy6ooV82Oo2ylWsgGLO/MiO3xkjwY8obkAQlRzAq2Sl5ovHWsAAU=</li>
</ul>
<hr/>
</body>
</html>

CloudFront ディストリビューションへのアクセスは許可されています。

$ curl https://${DistributionDomainName}/
<html><body><h1>Hello, World!</h1></body></html>

ここまでは期待通りの動作です。

CloudFront ではないサービスを指定した場合

ここで、CloudFront でないサービスとして lambda.amazonaws.com を Principal に指定した場合はどうなるか確認してみます。

lib/cdk-sample-stack.ts

    // バケットポリシー
    websiteBucketPolicy.document.addStatements(
      new aws_iam.PolicyStatement({
        effect: aws_iam.Effect.ALLOW,
        principals: [new aws_iam.ServicePrincipal('lambda.amazonaws.com')], // { "Service": "lambda.amazonaws.com" }
        actions: ['s3:GetObject'],
        resources: [websiteBucket.arnForObjects('*')],
      })
    );

上記のデプロイにより作成されたバケットポリシーは以下になります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::cdksamplestack-websitebucket75c24d94-dzbqzhrl3hcw/*"
        }
    ]
}

S3 バケットの Web サイトエンドポイントへのアクセスは拒否されます。

$ curl http://${BucketName}.s3-website-ap-northeast-1.amazonaws.com/
<html>
<head><title>403 Forbidden</title></head>
<body>
<h1>403 Forbidden</h1>
<ul>
<li>Code: AccessDenied</li>
<li>Message: Access Denied</li>
<li>RequestId: 4AC798KQNT7KS707</li>
<li>HostId: Gmln6u8NMhf2QZQqzRtqD8SthubYQIG34JetlL8XiooOg4Zsp82pBeZoMLA+lKVmB7/59JiB92g=</li>
</ul>
<hr/>
</body>
</html>

CloudFront ディストリビューションからのアクセスは...許可されてしまいます。

$ curl https://${DistributionDomainName}/
<html><body><h1>Hello, World!</h1></body></html>

これは期待していない動作です。CloudFront ディストリビューションからのアクセスは拒否されて欲しかったのですが。

Principal で特定アカウントからのアクセスを許可する場合

プリンシパルとして自身の AWS アカウント ID を指定し、接続元のアカウント ID によるアクセス制御ができるか確認してみます。

AccountPrincipal により指定のアカウントの許可を設定しています。

lib/cdk-sample-stack.ts

    // バケットポリシー
    websiteBucketPolicy.document.addStatements(
      new aws_iam.PolicyStatement({
        effect: aws_iam.Effect.ALLOW,
        principals: [new aws_iam.AccountPrincipal(this.account)],
        actions: ['s3:GetObject'],
        resources: [websiteBucket.arnForObjects('*')],
      })
    );

上記のデプロイにより作成されたバケットポリシーは以下になります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::XXXXXXXXXXXX:root"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::cdksamplestack-websitebucket75c24d94-dzbqzhrl3hcw/*"
        }
    ]
}

S3 バケットの Web サイトエンドポイントへのアクセスは拒否されます。

$ curl http://${BucketName}.s3-website-ap-northeast-1.amazonaws.com/
<html>
<head><title>403 Forbidden</title></head>
<body>
<h1>403 Forbidden</h1>
<ul>
<li>Code: AccessDenied</li>
<li>Message: Access Denied</li>
<li>RequestId: 5T6JB0XZR7EJBFXT</li>
<li>HostId: Dk9H25d/E7OQbphlPxCrrvKbzgOE0A9+PbiU3m8xeLCqSQTfcFjmubj4MbjES4IEqG1OsO+JuXU=</li>
</ul>
<hr/>
</body>
</html>

CloudFront ディストリビューションへのアクセスは許可されています。

$ curl https://${DistributionDomainName}/
<html><body><h1>Hello, World!</h1></body></html>

ここまでは期待通りの動作です。

別のアカウントを許可した場合

では別のアカウントを許可した場合はどうなるか確認してみます。

lib/cdk-sample-stack.ts

    // バケットポリシー
    websiteBucketPolicy.document.addStatements(
      new aws_iam.PolicyStatement({
        effect: aws_iam.Effect.ALLOW,
        principals: [new aws_iam.AccountPrincipal('YYYYYYYYYYYY')], // 別のアカウント ID を指定
        actions: ['s3:GetObject'],
        resources: [websiteBucket.arnForObjects('*')],
      })
    );

上記のデプロイにより作成されたバケットポリシーは以下になります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::YYYYYYYYYYYY:root"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::cdksamplestack-websitebucket75c24d94-dzbqzhrl3hcw/*"
        }
    ]
}

S3 バケットの Web サイトエンドポイントへのアクセスは拒否されます。

$ curl http://${BucketName}.s3-website-ap-northeast-1.amazonaws.com/
<html>
<head><title>403 Forbidden</title></head>
<body>
<h1>403 Forbidden</h1>
<ul>
<li>Code: AccessDenied</li>
<li>Message: Access Denied</li>
<li>RequestId: AKJEN7XN9TW11EF4</li>
<li>HostId: gNWaiEAp+XRT+BqZ/1typqDK9byYaIGdfJi/zWZ/xpUHQ6oFh9VsB38PCGKLAAYWESRs52zJpjM=</li>
</ul>
<hr/>
</body>
</html>

しかし CloudFront Distribution へのアクセスは許可されています。

$ curl https://${DistributionDomainName}/                           
<html><body><h1>Hello, World!</h1></body></html>

これも期待していない動作です。サービスの場合と同様な挙動ですね。

Condition で特定アカウントからのアクセスを許可する場合

次に Condition により AWS アカウントからのアクセス制御をできるか確認してみます。

PrincipalWithConditions を使うと、Principal と合わせて Condition を指定することができます。

lib/cdk-sample-stack.ts

    // バケットポリシー
    websiteBucketPolicy.document.addStatements(
      new aws_iam.PolicyStatement({
        effect: aws_iam.Effect.ALLOW,
        principals: [
          new aws_iam.AnyPrincipal().withConditions({
            StringEquals: {
              'aws:SourceAccount': this.account,
            },
          }),
        ],
        actions: ['s3:GetObject'],
        resources: [websiteBucket.arnForObjects('*')],
      })
    );

上記のデプロイにより作成されたバケットポリシーは以下になります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::cdksamplestack-websitebucket75c24d94-dzbqzhrl3hcw/*",
            "Condition": {
                "StringEquals": {
                    "aws:SourceAccount": "XXXXXXXXXXXX"
                }
            }
        }
    ]
}

S3 バケットの Web サイトエンドポイントへのアクセスは拒否されます。

$ curl http://${BucketName}.s3-website-ap-northeast-1.amazonaws.com/
<html>
<head><title>403 Forbidden</title></head>
<body>
<h1>403 Forbidden</h1>
<ul>
<li>Code: AccessDenied</li>
<li>Message: Access Denied</li>
<li>RequestId: 3HJ43YGK25D22MJV</li>
<li>HostId: 8lhjBRSnqq16BzkjaMcZH8PncOovTsuJYRFWG1Tx4MLTm+m0uwZyWDCIIXv8aRY+is3nixTe8xg=</li>
</ul>
<hr/>
</body>
</html>

CloudFront ディストリビューションへのアクセスは許可されています。

$ curl https://${DistributionDomainName}/                           
<html><body><h1>Hello, World!</h1></body></html>

ここまでは期待している動作です。

別のアカウントを指定

では別のアカウント 123456789012 を許可した場合はどうなるか確認してみます。

lib/cdk-sample-stack.ts

    // バケットポリシー
    websiteBucketPolicy.document.addStatements(
      new aws_iam.PolicyStatement({
        effect: aws_iam.Effect.ALLOW,
        principals: [
          new aws_iam.AnyPrincipal().withConditions({
            StringEquals: {
              'aws:SourceAccount': '123456789012',
            },
          }),
        ],
        actions: ['s3:GetObject'],
        resources: [websiteBucket.arnForObjects('*')],
      })
    );

上記のデプロイにより作成されたバケットポリシーは以下になります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::cdksamplestack-websitebucket75c24d94-dzbqzhrl3hcw/*",
            "Condition": {
                "StringEquals": {
                    "aws:SourceAccount": "123456789012"
                }
            }
        }
    ]
}

S3 バケットの Web サイトエンドポイントへのアクセスは拒否されます。

$ curl http://${BucketName}.s3-website-ap-northeast-1.amazonaws.com/
<html>
<head><title>403 Forbidden</title></head>
<body>
<h1>403 Forbidden</h1>
<ul>
<li>Code: AccessDenied</li>
<li>Message: Access Denied</li>
<li>RequestId: GFHS8FJPB4YBA2BM</li>
<li>HostId: 5Ya3Gan+HqKhCnhHLxBIioI9YfzEWxMtzKERvJ6TyUbAmy8+kq4Zl0RJmIZnbLgXlLLBk0Q8rJY=</li>
</ul>
<hr/>
</body>
</html>

CloudFront ディストリビューションからのアクセスは許可されてしまいます。

$ curl https://${DistributionDomainName}/
<html><body><h1>Hello, World!</h1></body></html>

これは期待していない動作です。Condition での AWS アカウントの制限が効いていないようです。

おわりに

Amazon CloudFront のカスタムオリジンとして Amazon S3 のウェブサイトエンドポイントを指定した場合のアクセス制御について AWS CDK を使用して検証してみました。

結論として、バケットポリシーでウェブサイトエンドポイントへのアクセスを制御する場合は、少なくともウェブサイトエンドポイントへの直接アクセスは制限できるが、AWS アカウントやサービスからのアクセスは Principal や Condition により制限できないという結果となりました。CloudFront のカスタムオリジンによるアクセスは通常の AWS サービス間のアクセスとは異なる挙動となっているようです。

仕様が不明確な部分はありますが、「少なくともウェブサイトエンドポイントへの直接アクセスは制限できる」という点のみを期待して実装に採用するのであればありなのかなとは思いました。

以上