こんにちは、広野です。
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 サポートを追加してくれればよいのですが。。。
本記事が皆様のお役に立てれば幸いです。