こんにちは、広野です。
S3に置いたファイルを会社等、特定のネットワークからのみアクセスさせたい、という要望は多いと思います。やり方はいろいろありますが、ここでは「許可されたソースIPアドレスからのみ Amazon S3 へのHTTPSアクセスを受け付ける」という機能をサーバーレスで実現する方法を1つ紹介したいと思います。
AWS ではもうコモディティ化したアーキテクチャだと思いますが、すぐにプロビジョニングできるように AWS CloudFormation テンプレートを付けます。若干の前提条件はありますが、要件が合えばパラメータだけ変えてそのままお使い頂けますし、合わない場合はカスタマイズ頂いてかまいません。
やりたいこと
- インターネット上の許可されたソースIPアドレスからのアクセスのみ Amazon S3 に配置したオブジェクトへのアクセスを受け付ける。
- 許可されたソースIPアドレスはCIDRで指定、ホワイトリストに登録する。本記事では、指定可能な条件は1つのみ。
- ユーザがアクセスするURLは独自ドメインである。
実現方法
アーキテクチャ
- ソースIPアドレスのチェックは AWS WAF で行います。
- AWS WAF は Amazon CloudFront にアタッチします。
- Amazon CloudFront 経由のアクセスのみ Amazon S3 へのアクセスを許可するよう、OAI (オリジンアクセスアイデンティティ) の設定を仕込みます。
- ユーザのアクセス先URLが独自ドメインになるよう、Amazon CloudFront ドメインのURLに Amazon Route 53 で エイリアスレコードを追加します。
- アクセスするプロトコルはHTTPSになるので、独自ドメインのSSL証明書をバージニア北部リージョンの AWS Certificate Manager で発行し、Amazon CloudFront に割り当てます。
解説
Amazon Route 53 パブリックホストゾーン
いわゆる権威DNSサーバの機能だと思ってよいと思います。ドメイン指定事業者から取得した独自ドメインの権威DNSサーバを Amazon Route 53 に登録することで機能し始めます。その状態で Amazon Route 53 に登録した DNS レコードはパブリックに伝播します。
Amazon Route 53 エイリアスレコード
Amazon Route 53 に DNS レコードを登録するとき、エイリアスレコードという種類のレコードを登録することができます。エイリアスレコードは AWS 独自のレコードの種類で、エイリアスレコードを選択できるときは極力使用した方が良いです。
AWS リソースによっては IP アドレスが動的に変わることがあり、その都度 DNS レコードの変更を余儀なくされるようではサービスとして成り立ちません。エイリアスレコードでは DNS レコードを IP アドレスではなく、AWSがリソースに対して自動作成したFQDNで登録できるようにしたものです。それによって、前述の課題を解決しています。似たようなレコードで CNAME レコードがありますが、それよりもレスポンスが良いようです。
本記事のアーキテクチャでは、ユーザに公開する URL を Amazon CloudFront の FQDN に関連付けるために使用します。
AWS Certificate Manager (ACM)
AWS が SSL 証明書等を作成、管理してくれるサービスです。料金が安い、すぐに発行、自動更新あり、当然 AWS リソースへの関連付けも容易にできるという素晴らしいサービスです。利用しない手はありません。
本記事のアーキテクチャではユーザのアクセス先 URL を独自ドメインにするため、かつアクセスを暗号化するため HTTPS プロトコルを使用するために SSL 証明書が必要になります。ユーザのアクセスを直接受けるサービスは Amazon CloudFront になるため、ACM で管理している SSL 証明書を Amazon CloudFront に関連付けます。Amazon CloudFront に ACM の証明書を関連付けるときは、証明書がバージニア北部リージョン (us-east-1) で管理されている必要があります。
Amazon CloudFront
Amazon CloudFront は、CDN サービスです。CDN とは Contents Delivery Network の略で、世界中の多くの箇所にコンテンツのキャッシュを配置し、ユーザが近い位置のキャッシュにアクセスできるようにすることでレスポンスを速くするサービスです。そのため、ユーザは直接コンテンツそのものにアクセスするのではなく、Amazon CloudFront のキャッシュ配置先 (AWS ではエッジロケーションと言う) に自動的に「誘導される」動きをします。
Amazon CloudFront のアクセス先 URL はデフォルトでは cloudfront.net ドメインで自動生成されますが、独自ドメインを割り当てることができます。HTTPS アクセスをさせる際には、前述の ACM で管理している SSL 証明書が必要になります。独自ドメインの URL を有効にするよう、Route 53 エイリアスレコードを割り当てています。以下 AWS 公式ドキュメントには CNAME レコードを使用するよう記載されていますが、現在はエイリアスレコードも使用できます。また、IPv6にも対応できるようエイリアスレコードを2種類登録しています。
Amazon CloudFront へのアクセスプロトコルはセキュリティを考慮し HTTPS のみに限定しています。HTTPアクセスも許可できますが、現在のトレンドとしても HTTP でパブリックなインターネット内を通信することは推奨されていませんので。
AWS WAF
AWS WAF は Amazon CloudFront や Application Load Balancer など、特定の AWS リソースに対して アプリケーションファイアウォールの機能を持たせる (アタッチする) ことができるサービスです。ただアタッチするだけでは機能せず、ポリシーやルールを適切に設定することで機能し始め、逆に誤った設定をすると意味が無かったり、意図せぬアクセス障害を引き起こしたりすることがあります。
本記事のアーキテクチャでは、許可されたソース IP アドレスからのみ Amazon S3 へのアクセスを許可するために使用します。AWS WAF を直接 Amazon S3 にアタッチすることはできないので、Amazon CloudFront にアタッチします。
本記事のアーキテクチャとは異なり、Amazon S3 のバケットポリシーによってアクセス可能なソース IP アドレスを制御することはできるのですが、独自ドメインを使用するには Amazon CloudFront を使用することが設定しやすいこと、そうであればアクセス制御機能をフロント側に配置する方が効率的であること、Amazon S3 だけでアクセス制御を実装すると機能の制約で今後の拡張性が限られてしまうこと (AWS WAF の方が柔軟) などの理由から、AWS WAF でソース IP アドレス制御をする設計にしています。
AWS WAF には、IP セットと呼ばれる IP アドレス特有のパターンマッチングをかける設定が用意されており、それを活用します。
Amazon S3 と OAI
Amazon S3 は激安なオブジェクトストレージです。ファイルサーバのように CIFS や NFS でアクセスするのではなく、HTTP(S) でアクセスします。デフォルトではパブリックにファイルは公開されておらず、パブリックに、もしくは限定的に公開したい場合は適切に設定をする必要があります。アクセス制御に関する設定は最も大きい範囲でバケット(と呼ばれるフォルダのようなもの)単位で設定可能です。
本記事のアーキテクチャでは、OAI (Origin Access Identity) という機能を利用して、Amazon S3 バケットへのアクセスを以下のように Amazon CloudFront 経由のみに限定許可するよう制御しています。
- Amazon S3 バケットへのアクセスを、Amazon CloudFront 経由のみに許可する。該当する Amazon CloudFront リソースに ID を持たせ、その ID からのアクセスであれば S3 バケットのファイル読み取りを許可するバケットポリシーを Amazon S3 バケットに設定する。
- ソース IP アドレスによるアクセス許可判断を Amazon CloudFront にアタッチされた AWS WAF で行うため、上述のバケットポリシーにより、Amazon S3 バケットへのパブリックからの直接アクセスは許可しない。そうしないと ソース IP アドレス制御が機能しないバックドアができてしまうため。
実装 (AWS CloudFormation テンプレート)
前提事項
- 独自ドメインをドメイン指定事業者から取得済みである。
- 取得した独自ドメインを、Amazon Route 53 のパブリックホストゾーンに登録済みである。
つまり、AWS側でドメインのレコード管理ができる状態になっている。 - Amazon CloudFront のURLで使用するサブドメイン名が決定済みである。xxxxxx.example.com の xxxxx の部分。
独自ドメイン名、サブドメイン名ともに、AWS CloudFormation テンプレートのパラメータで指定する。 - バージニア北部リージョンの AWS Certificate Manager で、Amazon CloudFront のURLで使用する独自ドメインのFQDNが含まれるSSL証明書が発行済みである。
サブドメインが *(ワイルドカード)で Zone Apex (サブドメインがない example.com のこと) も含まれる万能証明書が準備されていると便利。
このSSL証明書の Certificate ID を AWS CloudFormation テンプレートのパラメータとして指定する。 - 使用したい Amazon S3 バケット名が決定済みである。
このバケット名を AWS CloudFormation テンプレートのパラメータとして指定する。 - アクセスを許可したいソースIPアドレス範囲が CIDR 形式で決定済みである。xx.xx.xx.xx/xx の形式。
それを AWS CloudFormation テンプレートのパラメータとして指定する。
AWS CloudFormation テンプレート
前提事項参照の上、以下テンプレートでスタックを作成します。
AWSTemplateFormatVersion: 2010-09-09 Description: CloudFormation template that creates a S3 bucket, a CloudFront distribution and DNS records in Route 53 for the IP-based restricted access. # ------------------------------------------------------------# # Input Parameters # ------------------------------------------------------------# Parameters: BucketName: Type: String Description: S3 Bucket name for URL. Default: bucket_name MaxLength: 20 MinLength: 1 DomainName: Type: String Description: Domain name for URL. xxxxx.xxx (e.g. example.com) Default: example.com MaxLength: 40 MinLength: 5 AllowedPattern: "[^\\s@]+\\.[^\\s@]+" SubDomainName: Type: String Description: Sub domain name for URL. xxxxx.example.com Default: subdomain MaxLength: 20 MinLength: 1 SourceIpRange: Type: String Description: Source IP Address (CIDR) able to access this site. (e.g. xxx.xxx.xxx.xxx/xx) Default: 0.0.0.0/0 MaxLength: 32 MinLength: 10 CertificateId: Type: String Description: ACM certificate ID. CloudFront supports ACM certificates only in us-east-1 region. Default: xxxxxxxx-xxxx-xxxxxxxxx-xxxxxxxxxxxx MaxLength: 36 MinLength: 36 Resources: # ------------------------------------------------------------# # S3 # ------------------------------------------------------------# S3Bucket: Type: AWS::S3::Bucket Properties: BucketName: !Ref BucketName PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true Tags: - Key: Cost Value: Example S3BucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref S3Bucket PolicyDocument: Version: "2012-10-17" Statement: - Action: - "s3:GetObject" Effect: Allow Resource: !Sub "arn:aws:s3:::${S3Bucket}/*" Principal: AWS: !Sub "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}" DependsOn: - S3Bucket - CloudFrontOriginAccessIdentity # ------------------------------------------------------------# # CloudFront # ------------------------------------------------------------# CloudFrontDistribution: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Enabled: true Comment: CloudFront distribution for the IP-based restricted access Aliases: - !Sub ${SubDomainName}.${DomainName} HttpVersion: http2 IPV6Enabled: true PriceClass: PriceClass_200 DefaultCacheBehavior: TargetOriginId: !Sub S3Origin-${SubDomainName} ViewerProtocolPolicy: redirect-to-https AllowedMethods: - GET - HEAD - OPTIONS CachedMethods: - GET - HEAD CachePolicyId: !Ref CloudFrontCachePolicy OriginRequestPolicyId: !Ref CloudFrontOriginRequestPolicy Compress: true SmoothStreaming: false Origins: - Id: !Sub S3Origin-${SubDomainName} DomainName: !GetAtt S3Bucket.DomainName S3OriginConfig: OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}" ConnectionAttempts: 3 ConnectionTimeout: 10 ViewerCertificate: AcmCertificateArn: !Sub "arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CertificateId}" MinimumProtocolVersion: TLSv1.2_2019 SslSupportMethod: sni-only WebACLId: !GetAtt WAFv2WebAcl.Arn Tags: - Key: Cost Value: Example DependsOn: - S3Bucket - CloudFrontOriginAccessIdentity - CloudFrontOriginRequestPolicy - CloudFrontCachePolicy - WAFv2WebAcl CloudFrontOriginAccessIdentity: Type: AWS::CloudFront::CloudFrontOriginAccessIdentity Properties: CloudFrontOriginAccessIdentityConfig: Comment: CloudFront OAI for the IP-based restricted access CloudFrontCachePolicy: Type: AWS::CloudFront::CachePolicy Properties: CachePolicyConfig: Name: !Sub CachePolicy-${SubDomainName} Comment: CloudFront Cache Policy for the IP-based restricted internal access DefaultTTL: 3600 MaxTTL: 86400 MinTTL: 60 ParametersInCacheKeyAndForwardedToOrigin: CookiesConfig: CookieBehavior: none EnableAcceptEncodingBrotli: true EnableAcceptEncodingGzip: true HeadersConfig: HeaderBehavior: whitelist Headers: - Access-Control-Request-Headers - Access-Control-Request-Method - Origin QueryStringsConfig: QueryStringBehavior: none CloudFrontOriginRequestPolicy: Type: AWS::CloudFront::OriginRequestPolicy Properties: OriginRequestPolicyConfig: Name: !Sub OriginRequestPolicy-${SubDomainName} Comment: CloudFront Origin Request Policy for the IP-based restricted access CookiesConfig: CookieBehavior: none HeadersConfig: HeaderBehavior: whitelist Headers: - Access-Control-Request-Headers - Access-Control-Request-Method - Origin QueryStringsConfig: QueryStringBehavior: none # ------------------------------------------------------------# # WAF v2 # ------------------------------------------------------------# WAFv2WebAcl: Type: AWS::WAFv2::WebACL Properties: Name: !Sub WebACL-${SubDomainName} Description: WAF v2 WebACL for the IP-based restricted access DefaultAction: Block: {} Scope: CLOUDFRONT Rules: - Name: !Sub CustomRule-IPWhiteList-${SubDomainName} Action: Allow: {} Priority: 0 Statement: IPSetReferenceStatement: Arn: !GetAtt WAFv2IPWhiteList.Arn VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Sub CustomRule-IPWhiteList-${SubDomainName} SampledRequestsEnabled: true VisibilityConfig: CloudWatchMetricsEnabled: true MetricName: !Sub WebACL-${SubDomainName} SampledRequestsEnabled: true Tags: - Key: Cost Value: Example DependsOn: WAFv2IPWhiteList WAFv2IPWhiteList: Type: AWS::WAFv2::IPSet Properties: Name: !Sub IPWhiteList-${SubDomainName} Description: WAF v2 IP white list for the IP-based restricted access IPAddressVersion: IPV4 Addresses: - !Ref SourceIpRange Scope: CLOUDFRONT Tags: - Key: Cost Value: Example # ------------------------------------------------------------# # Route 53 # ------------------------------------------------------------# Route53RecordA: Type: AWS::Route53::RecordSet Properties: HostedZoneName: !Sub ${DomainName}. Name: !Sub ${SubDomainName}.${DomainName}. Type: A AliasTarget: HostedZoneId: Z2FDTNDATAQYW2 DNSName: !GetAtt CloudFrontDistribution.DomainName DependsOn: CloudFrontDistribution Route53RecordAAAA: Type: AWS::Route53::RecordSet Properties: HostedZoneName: !Sub ${DomainName}. Name: !Sub ${SubDomainName}.${DomainName}. Type: AAAA AliasTarget: HostedZoneId: Z2FDTNDATAQYW2 DNSName: !GetAtt CloudFrontDistribution.DomainName DependsOn: CloudFrontDistribution # ------------------------------------------------------------# # Output Parameters # ------------------------------------------------------------# Outputs: #S3 BucketName: Value: !Ref S3Bucket #CloudFront SiteURL: Value: !Sub https://${SubDomainName}.${DomainName} CloudFrontOriginalDomain: Value: !GetAtt CloudFrontDistribution.DomainName
ソースIPアドレス範囲を複数持ちたい場合は、WAFv2IPWhiteListの部分をカスタマイズすれば設定可能です。
まとめ
いかがでしたでしょうか?
詳細な解説まではできておらず恐縮ですが、各サービスの機能や設定についてはAWS公式ドキュメントをお読み頂けたらと思います。
この記事がみなさまのお役に立てれば幸いです。