こんにちは、広野です。
AWS AppSync はレスポンスが速くてサブスクリプションを手軽に作れて便利なのですが、ネイティブに CORS 対応はしていません。CORS が必要になった場合には、現時点では Amazon CloudFront をかぶせて CORS ヘッダーをオーバーライドするのが一番スマートかな、と思います。
と、思ってたら簡単には行かなかったので、気付いたことを残しておきます。
実装したアーキテクチャ
冒頭で説明したように、AWS AppSync はネイティブに CORS をサポートしていません。レスポンスで返ってくる Access-Control-Allow-Origin ヘッダーは “*” 固定になっています。そのため、これを Amazon CloudFront distribution でレスポンスヘッダーを上書きします。
CORS は https:// から始まる宛先への通信をサポートしています。AWS AppSync への Query は https の POST メソッドを使用しているようで、Amazon CloudFront を介して CORS を有効化させられます。ところが Subscription (WebSocket) は wss:// から始まる宛先になるので、ブラウザは CORS をサポートしていません。一般的に、WebSocket で同様のセキュリティ対策をしようと思ったら Origin ヘッダーのチェックをするようです。ということなので、Subscription 通信については Amazon CloudFront を通さずに行きます。他にもそのようにした理由がありますが、細かすぎるので最後の方で補足します。
さて、Query の CORS 有効化に話を戻します。レスポンスヘッダーを上書きするだけじゃん?と思うのですが、今回の構成では AWS AppSync が Amazon Cognito 認証になっています。その場合、リクエストの Authorization ヘッダーに Amazon Cognito から受け取ったトークンを格納して送りますので、Amazon CloudFront が AWS AppSync にそれを転送する必要があります。
ここで気を付けないといけない点があり、Authorization ヘッダーをオリジン (ここでは AWS AppSync) に転送するには、特定の設定方法でしか実装できません。それが以下の AWS ドキュメントに書いてあります。
まるっとビューワーヘッダー全体指定で転送するか、キャッシュポリシーに Authorization を明記するか、の大まかに 2 択です。私は今回のケースでは Amazon CloudFront にキャッシュをさせたくなかった (通信をパススルーさせたい) ので、キャッシュポリシーをマネージドの CachingDisabled を選択することにしていました。そのため、まるっとビューワーヘッダー全体指定の転送を選択しています。ただし、それだけでは転送されず、レスポンスヘッダーのオーバーライドで使用予定だった Access-Control-Allow-Headers に Authorization を追加すると転送されるようになりました。
今回の一番の目的である CORS 有効化ですが、この設定自体は簡単です。具体的には後述の設定の章を見て欲しいですが、レスポンスヘッダーポリシーに CORS 用設定があるので、そこに CORS 用のヘッダー情報を入れるだけです。また、オーバーライドする設定を有効にします。ここよりも、他のヘッダー設定の方が苦労しました。
React アプリ側は、以下のコードで AWS AppSync を呼び出すクライアントの設定をしています。※値は変えてます
Amplify.configure(
{
Auth: {
Cognito: {
userPoolId: import.meta.env.VITE_USERPOOLID,
userPoolClientId: import.meta.env.VITE_USERPOOLWEBCLIENTID,
identityPoolId: import.meta.env.VITE_IDPOOLID
}
},
API: {
GraphQL: {
// AppSync の標準のエンドポイント
endpoint: 'https://example1234567890000.appsync-api.ap-northeast-1.amazonaws.com/graphql',
region: 'ap-northeast-1'
defaultAuthMode: 'userPool',
// 独自ドメインの CloudFront に置き換えたエンドポイント
customEndpoint: 'https://xxx.hironoenterprise.com/graphql',
customEndpointRegion: 'ap-northeast-1'
}
}
}
);
CORS 有効化のため AWS AppSync に手作りカスタムドメインを追加構築したようなものなので、カスタムドメインの設定を追加したところ Query 通信にはカスタムドメイン (Amazon CloudFront のエンドポイント) に、Subscription 通信は AWS AppSync のエンドポイントにアクセスするようになりました。
さて、、、ここまでで CORS 有効化設定そのものは整いましたが、周辺のアーキテクチャについても次章で説明します。
追加のアーキテクチャ
もう 1 回同じ図を再掲します。
AWS AppSync への Query 通信を CORS 有効化できただけでも 1 つの進歩なのですが、まだ少々懸念が残っています。
- AWS AppSync に Amazon CloudFront をかぶせただけでは、AWS AppSync にダイレクトにアクセスできる経路が残っている。
つまり、Amazon S3 で言う OAC (Origin Access Control) のようなことをした方が良い。 - WebSocket 通信については、CORS 同等の対策ができていない。アクセス元アプリを、ここでは hironoenterprise.com に限定するような設定が必要。Origin ヘッダーをチェックさせたい。
これらについて、完璧ではないですが今時点できることを実装してみます。
AWS AppSync エンドポイントへのダイレクトアクセス拒否
これについては、AWS ブログで方法が紹介されています。原始的と言っては失礼ですが、今できることを他の AWS サービスを駆使して組み上げた感じです。
私の図をベースに説明しますと。
- Amazon CloudFront から AWS AppSync にリクエストを転送するときに、「私は許可された CloudFront ですよー」と証明するためのキーをカスタムヘッダーに追加します。
- リクエストを受け取った AWS AppSync は、リクエストを AWS WAF にチェックしてもらいます。カスタムヘッダーの値が、あらかじめ口裏合わせしておいたキーと同じであれば、アクセスを許可します。
- (今回の私の例には含めていませんが) Amazon CloudFront と AWS WAF に持たせる口裏合わせのキーは AWS Secrets Manager で定期的に自動ローテーションします。
なんちゃって OAC AppSync 版、って感じですが、十分な機能ですね。私は今回はバッドプラクティスですが、キーはベタに書きました。そこは参考にしないでください。
Subscription (WebSocket) 通信の Origin チェック
WebSocket は特殊な通信で、今回、AWS WAF のログやブラウザの開発者コンソールを見て、通信の仕組みがよくわからないことがわかりました。目的の Origin チェックは簡単に実装できるのですが、前述の AppSync 通信ダイレクトアクセス拒否が意味を失います。長くなりますが、説明します。
まず、Origin のチェックは AWS WAF Web ACL で、Origin ヘッダーがここでは https://hironoenterprise.com であることをチェックすればよいです。ただし、それは以下のように条件分けする必要があります。
- Query 通信はカスタムヘッダーをチェックする。
- Subscription 通信は Origin ヘッダーをチェックする。
では、AWS WAF がどの情報で Query か Subscription かを識別するかです。
まず、私は host ヘッダーの利用を考えました。host は通信の宛先の FQDN で、今回のケースですと AWS AppSync のエンドポイントになります。実は AWS AppSync は Query と Subscription でエンドポイントが異なります。
Query 用のエンドポイント (GraphQL エンドポイント)
例:https://example1234567890000.appsync-api.us-east-1.amazonaws.com/graphql
SubScription 用のエンドポイント (リアルタイムエンドポイント)
例:wss://example1234567890000.appsync-realtime-api.us-east-1.amazonaws.com/graphql
FQDN が異なることが確認できると思います。そのため、AWS WAF に残る host ヘッダーが変わり、それぞれの通信を識別できるだろうと考えました。
結論から言いますが、実際には host 情報がまさかの同じ!だったので、識別できませんでした。
Query 通信も SubScription 通信ともに、AWS WAF のログを見る限り、どちらも GraphQL エンドポイントの FQDN が host ヘッダーに残っていました。それ以外の情報では通信を絶対というレベルで識別できるものは見つけられませんでした。
ログを見ていて、SubScription の通信の動きでわかったことは。
- まず、wss:// から始まるリアルタイムエンドポイントに GET の通信をしに行く。
これが AWS WAF には残らない!(WAF を通過しない???) - その後、GraphQL エンドポイントに POST の通信をしに行く。これは AWS WAF に残る。でもエンドポイントが Query と同じなので Subscription の通信なのかがわからない。
- WebSocket のセッションが張れた後は、AWS WAF に通信の記録は残らない。
Subscription 用にリアルタイムエンドポイントなるものが用意されてはいるものの、目に見えるのは GraphQL エンドポイントの情報のみなので、いかんともしがたいです。
しかしながら、2 の POST の通信がブロックされると WebSocket セッション確立が失敗するのと、Origin ヘッダーはあったので Origin チェックは意味を成します。
Origin チェックを Subscription 通信限定で行うことができず、AWS WAF を通る全通信を対象にするしかないため、ダイレクトアクセス拒否の設定は意味なくなります。ただし、Subscription 通信を全くしない構成であれば機能します。
さらに補足
他の方法として、CloudFront Functions や Lambda@Edge でヘッダーを書き換える方法も検討しましたが、セキュリティの根幹となるヘッダーを書き換えることはできない仕様だったのであきらめました。(そりゃそうだ、それができたら Amazon CloudFront 使って不正アクセスできるようになりますわw)
AWS WAF のログは必ず残しましょう。通信がなぜブロックされたのか、また許可された正常な通信はどのようなヘッダー情報を持っているのか確認できるので。
そもそも Subscription (WebSocket) 通信を Amazon CloudFront 経由に統合しなかったのか?と思われる方もいらっしゃるかもしれません。試しましたが、私はできませんでした。おそらく前述した、WebSocket 通信がトリッキーなことが原因だと思います。wss:// から始まる通信、GET から始まり途中から POST に変わる、そしてエンドポイントも変わる、host は GraphQL エンドポイント、、、などなど、通信仕様が理解できず、Amazon CloudFront にヘッダー処理をうまく組み込めませんでした。AWS AppSync はネイティブにカスタムドメインはサポートしており、それを設定すると AWS 側で管理する Amazon CloudFront distribution が立ち上がります。当然その構成では機能するはずなので、勝手な想像ですが Lambda@Edge 等も活用して通信が正常に通るように作り込んでいるのだと思います。ただし CORS はできませんが。
最後になってしまいましたが気になる AWS AppSync のレスポンスは、Amazon CloudFront を介しても体感的には変わらなかったです。さすがエッジロケーション。どうでもいいですが、使用されたエッジロケーションの所在地を意味するようなヘッダーがあって、xx県xx市からのアクセスだとあそこに誘導されるんだー、って一人で感動してました。
思った以上にアーキテクチャ説明が長くなってしまいました。次章で設定情報を紹介します。
具体的な設定 (AWS CloudFormation テンプレート)
すみません、AWS CloudFormation テンプレートで失礼します。インラインで補足をコメントします。
AWS AppSync の設定については説明のテーマではないので割愛します。
AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates S3 Buckets, AppSync API with a Web acl, a CloudFront distribution with CORS and relevant IAM roles.
# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
SubName:
Type: String
Description: System sub name that is used for all deployed resources. (e.g. prod or dev)
Default: dev
MaxLength: 10
MinLength: 1
DomainName:
Type: String
Description: Domain name for URL. xxxxx.xxx (e.g. hironoenterprise.com)
Default: hironoenterprise.com
MaxLength: 40
MinLength: 5
AllowedPattern: "[^\\s@]+\\.[^\\s@]+"
CertificateId:
Type: String
Description: ACM certificate ID. CloudFront only supports ACM certificates in us-east-1 region.
Default: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
MaxLength: 36
MinLength: 36
CloudFrontOriginVerificationKey:
Type: String
Description: The random string key that AppSync verifies it is sent from the exact CloudFront. !Bad Practice! Use Secrets Manager instead.
Default: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
MaxLength: 256
MinLength: 8
LogRetentionDays:
Type: Number
Description: The retention period (days) for AWS WAF logs. Enter an integer between 35 to 540.
Default: 365
MaxValue: 540
MinValue: 35
Resources:
# ------------------------------------------------------------#
# S3
# ------------------------------------------------------------#
S3BucketWafLogs:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub aws-waf-logs-hironoenterprise-${SubName}
LifecycleConfiguration:
Rules:
- Id: AutoDelete
Status: Enabled
ExpirationInDays: !Ref LogRetentionDays
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
Tags:
- Key: Cost
Value: !Sub hironoenterprise-${SubName}
# ------------------------------------------------------------#
# AppSync 大部分を省略、Cognito の設定は外部テンプレートを参照しているのでこのままでは動きません
# ------------------------------------------------------------#
AppSyncApi:
Type: AWS::AppSync::GraphQLApi
Description: AppSync API for hironoenterprise
Properties:
Name: !Sub hironoenterprise-${SubName}
AuthenticationType: AMAZON_COGNITO_USER_POOLS
AdditionalAuthenticationProviders:
- AuthenticationType: AWS_IAM
UserPoolConfig:
UserPoolId:
Fn::ImportValue:
!Sub CognitoUserPoolID-hironoenterprise-${SubName}
AwsRegion: !Sub ${AWS::Region}
DefaultAction: "ALLOW"
IntrospectionConfig: DISABLED
LogConfig:
CloudWatchLogsRoleArn: !GetAtt AppSyncCloudWatchLogsPushRole.Arn
ExcludeVerboseContent: true
FieldLogLevel: ALL
Visibility: GLOBAL
XrayEnabled: true
Tags:
- Key: Cost
Value: !Sub hironoenterprise-${SubName}
DependsOn:
- AppSyncCloudWatchLogsPushRole
# ------------------------------------------------------------#
# AppSync CloudWatch Invocation Role (IAM)
# ------------------------------------------------------------#
AppSyncCloudWatchLogsPushRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub hironoenterprise-AppSyncCloudWatchLogsPushRole-${SubName}
Description: This role allows AppSync to push logs to CloudWatch Logs.
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- appsync.amazonaws.com
Action:
- sts:AssumeRole
Path: /
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSAppSyncPushToCloudWatchLogs
# ------------------------------------------------------------#
# WAF Web Acl for AppSync
# ------------------------------------------------------------#
WebAclAppSync:
Type: AWS::WAFv2::WebACL
Properties:
Name: !Sub hironoenterprise-${SubName}-appsync
Description: !Sub WAFv2 WebACL for AppSync hironoenterprise-${SubName}
Scope: REGIONAL
DefaultAction:
Block: {}
Rules:
# Query 通信はカスタムヘッダーが正しく入っているかチェックする、ただし Origin チェックを併用すると意味はない
- Name: !Sub AllowAppSyncGraphqlEndpoint-hironoenterprise-${SubName}
Priority: 1
Action:
Allow: {}
Statement:
AndStatement:
Statements:
- ByteMatchStatement:
FieldToMatch:
SingleHeader:
Name: host
PositionalConstraint: EXACTLY
# GraphQL エンドポイント宛ての通信であることをチェック
SearchString: !GetAtt AppSyncApi.GraphQLDns
TextTransformations:
- Priority: 0
Type: NONE
# x-origin-verify というカスタムヘッダーをチェックする
- ByteMatchStatement:
FieldToMatch:
SingleHeader:
Name: x-origin-verify
PositionalConstraint: EXACTLY
SearchString: !Ref CloudFrontOriginVerificationKey
TextTransformations:
- Priority: 0
Type: NONE
VisibilityConfig:
CloudWatchMetricsEnabled: true
MetricName: !Sub AllowAppSyncGraphqlEndpoint-hironoenterprise-${SubName}
SampledRequestsEnabled: true
# Subscription 通信はという条件にしたかったが識別できず、全体に対してチェックしている
- Name: !Sub AllowAppSyncRealtimeEndpoint-hironoenterprise-${SubName}
Priority: 2
Action:
Allow: {}
Statement:
AndStatement:
Statements:
- ByteMatchStatement:
FieldToMatch:
SingleHeader:
# React アプリから呼び出したことを条件にしている、無くてもよい
Name: x-amz-user-agent
PositionalConstraint: STARTS_WITH
SearchString: aws-amplify
TextTransformations:
- Priority: 0
Type: NONE
- ByteMatchStatement:
FieldToMatch:
SingleHeader:
# origin ヘッダーをチェックする、ここでは hironoenterprise.com であること
Name: origin
PositionalConstraint: EXACTLY
SearchString: !Sub https://${DomainName}
TextTransformations:
- Priority: 0
Type: NONE
VisibilityConfig:
CloudWatchMetricsEnabled: true
MetricName: !Sub AllowAppSyncRealtimeEndpoint-hironoenterprise-${SubName}
SampledRequestsEnabled: true
VisibilityConfig:
CloudWatchMetricsEnabled: true
MetricName: !Sub hironoenterprise-${SubName}-appsync
SampledRequestsEnabled: true
Tags:
- Key: Cost
Value: !Sub hironoenterprise-${SubName}
DependsOn:
- AppSyncApi
WebACLAssociationAppSync:
Type: AWS::WAFv2::WebACLAssociation
Properties:
ResourceArn: !GetAtt AppSyncApi.Arn
WebACLArn: !GetAtt WebAclAppSync.Arn
DependsOn:
- WebAclAppSync
WebAclLoggingConfigurationAppSync:
Type: AWS::WAFv2::LoggingConfiguration
Properties:
ResourceArn: !GetAtt WebAclAppSync.Arn
LogDestinationConfigs:
- !GetAtt S3BucketWafLogs.Arn
DependsOn:
- S3BucketWafLogs
- WebAclAppSync
# ------------------------------------------------------------#
# CloudFront ログの設定は外部テンプレートを参照しているのでこのままでは動かない
# ------------------------------------------------------------#
CloudFrontDistributionAppSync:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
Comment: !Sub CloudFront distribution for hironoenterprise-${SubName}-appsync
Aliases:
- !Sub hironoenterprise-${SubName}-appsync.${DomainName}
HttpVersion: http2
IPV6Enabled: true
PriceClass: PriceClass_200
Logging:
Bucket:
Fn::ImportValue:
!Sub hironoenterprise-S3BucketDomainName-Logs-${SubName}
IncludeCookies: false
Prefix: cloudfrontAccesslogAppsync/
DefaultCacheBehavior:
TargetOriginId: !Sub AppSyncOrigin-hironoenterprise-${SubName}-https
ViewerProtocolPolicy: redirect-to-https
# 以下の AllowdMethods と CachedMethods の設定は書き方に制約があるのでこの通りに書く
AllowedMethods:
- GET
- HEAD
- OPTIONS
- PUT
- PATCH
- POST
- DELETE
CachedMethods:
- HEAD
- GET
# キャッシュポリシーはAWSマネージドのキャッシュを全くしないポリシーを指定
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
OriginRequestPolicyId: !Ref CloudFrontOriginRequestPolicy
ResponseHeadersPolicyId: !Ref CloudFrontResponseHeadersPolicy
Compress: false
SmoothStreaming: false
Origins:
- Id: !Sub AppSyncOrigin-hironoenterprise-${SubName}-https
# オリジンには AppSync の GraphQL エンドポイントを指定、FQDN 形式でないとエラーになる
DomainName: !GetAtt AppSyncApi.GraphQLDns
CustomOriginConfig:
HTTPPort: 80
HTTPSPort: 443
OriginProtocolPolicy: https-only
OriginSSLProtocols:
- TLSv1.2
# ここで、オリジンに送るカスタムヘッダーを追加している
OriginCustomHeaders:
- HeaderName: x-origin-verify
HeaderValue: !Ref CloudFrontOriginVerificationKey
ConnectionAttempts: 3
ConnectionTimeout: 10
ViewerCertificate:
AcmCertificateArn: !Sub "arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CertificateId}"
MinimumProtocolVersion: TLSv1.2_2021
SslSupportMethod: sni-only
Tags:
- Key: Cost
Value: !Sub hironoenterprise-${SubName}
DependsOn:
- CloudFrontOriginRequestPolicy
- CloudFrontResponseHeadersPolicy
CloudFrontOriginRequestPolicy:
Type: AWS::CloudFront::OriginRequestPolicy
Properties:
OriginRequestPolicyConfig:
Name: !Sub OriginRequestPolicy-hironoenterprise-${SubName}-appsync
Comment: !Sub CloudFront Origin Request Policy for hironoenterprise-${SubName}-appsync
CookiesConfig:
CookieBehavior: none
# オリジンに転送するヘッダーは host 以外全てにした
HeadersConfig:
HeaderBehavior: allExcept
Headers:
- Host
QueryStringsConfig:
QueryStringBehavior: all
CloudFrontResponseHeadersPolicy:
Type: AWS::CloudFront::ResponseHeadersPolicy
Properties:
ResponseHeadersPolicyConfig:
Name: !Sub ResponseHeadersPolicy-hironoenterprise-${SubName}-appsync
Comment: !Sub CloudFront Response Headers Policy for hironoenterprise-${SubName}-appsync
# CORS の設定は
CorsConfig:
AccessControlAllowCredentials: false
# ここに Authorization を追加しないと動かなかった
AccessControlAllowHeaders:
Items:
- Origin
- Content-Type
- Authorization
- x-amz-user-agent
# Query は POST メソッド。プリフライトチェックが行われるので OPTIONS を必ず入れる。
AccessControlAllowMethods:
Items:
- POST
- OPTIONS
# アクセスを許可するアプリの URL を入れる。ここでは hironoenterprise.com
AccessControlAllowOrigins:
Items:
- !Sub https://${DomainName}
# 必ず CORS ヘッダーをオーバーライドすること。
OriginOverride: true
# セキュリティヘッダーはおまけ。一般的な設定。
SecurityHeadersConfig:
ContentSecurityPolicy:
ContentSecurityPolicy: !Sub "default-src 'self' *.${DomainName} ${DomainName}"
Override: true
ContentTypeOptions:
Override: true
FrameOptions:
FrameOption: DENY
Override: true
ReferrerPolicy:
Override: true
ReferrerPolicy: strict-origin-when-cross-origin
StrictTransportSecurity:
AccessControlMaxAgeSec: 31536000
IncludeSubdomains: true
Override: true
Preload: true
XSSProtection:
ModeBlock: true
Override: true
Protection: true
CustomHeadersConfig:
Items:
- Header: Cache-Control
Value: no-store
Override: true
# ------------------------------------------------------------#
# Route 53 ※独自ドメインを使用するときは CloudFront にエイリアスレコードを登録する
# ------------------------------------------------------------#
Route53RecordA:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneName: !Sub ${DomainName}.
Name: !Sub hironoenterprise-${SubName}-appsync.${DomainName}.
Type: A
AliasTarget:
HostedZoneId: Z2FDTNDATAQYW2
DNSName: !GetAtt CloudFrontDistributionAppSync.DomainName
DependsOn:
- CloudFrontDistributionAppSync
Route53RecordAAAA:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneName: !Sub ${DomainName}.
Name: !Sub hironoenterprise-${SubName}-appsync.${DomainName}.
Type: AAAA
AliasTarget:
HostedZoneId: Z2FDTNDATAQYW2
DNSName: !GetAtt CloudFrontDistributionAppSync.DomainName
DependsOn:
- CloudFrontDistributionAppSync
まとめ
いかがでしたでしょうか。
いろいろ調べて非常に疲れました。HTTP ヘッダーの勉強になりました。同じ問題で困っている方、もし解決できたようでしたら、世の中に公開してくれると嬉しいです。または、AWS さんが AWS AppSync の CORS サポートを追加してくれればよいのですが。。。
本記事が皆様のお役に立てれば幸いです。