[CDK] AppSyncにCORSレスポンスヘッダーを設定する方法を考える

2022.05.09

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、吉川です。

早速ですが、AWS AppSyncのCORS周りが気になり、リクエストを投げてレスポンスを確認してみました。

curl -H "Origin: https://example.com" \
     -H "Access-Control-Request-Method: POST" \
     -X OPTIONS -v \
        https://xxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql

出力の一部抜粋が以下です。

< HTTP/2 200 
< content-length: 0
< date: Sat, 07 May 2022 15:17:33 GMT
< x-amzn-requestid: xxxxxxxxxxxxxxxxx
< access-control-allow-origin: *
< access-control-expose-headers: x-amzn-RequestId,x-amzn-ErrorType,x-amz-user-agent,x-amzn-ErrorMessage,Date,x-amz-schema-version
< access-control-max-age: 172800
< x-cache: Miss from cloudfront
< via: 1.1 xxxxxxxxxxxxxxxx.cloudfront.net (CloudFront)
< x-amz-cf-pop: KIX50-P3
< x-amz-cf-id: xxxxxxxxxxxxxxxxxxxxxxxxx

このように access-control-allow-origin: * を返しているようでした。

一応、ブラウザからリクエストする場合も確認してみます。

同じでした。

より厳しいCORS設定をしたい場合はどうすれば良いのでしょうか?今の所、その方法はAppSync内では提供されていないように見えます。

そこで、AppSyncの前段にCloudFrontを置いてCORSヘッダーを設定する方法を試してみました。

今回はmermaidで図を書いてみました。

flowchart LR

client --> cloudfront

subgraph aws
    cloudfront -- httpOrigin --> appsync -- resolver --> lambda
end

Lambda関数をリゾルバとするAppSyncを用意し、その前段にCloudFrontを置くようにしていきます。この構成をCDKで実現していきます。

ただ、やや懸念の事項もあり気になる点として記載しています。また、構成がやや複雑化するトレードオフもあるため、本構成の採用を検討される場合はご留意ください。

環境

  • node 16.13.0
  • aws-cdk-lib 2.20.0
  • @aws-cdk/aws-appsync-alpha 2.20.0-alpha.0
  • constructs 10.0.115
  • typescript 3.9.7

コード

フロントエンドリソースの用意

動作確認用にシンプルなフロントエンドWebアプリケーションを用意します。ページを開くとfetch APIでリクエストするだけの内容です(動きはChrome DevToolsで確認する想定)。

認証と認可 - AWS AppSync

AppSyncをAPI認証モードで建てるので、x-api-keyヘッダと値(マネジメントコンソールで確認)を忘れずにセットします。

public/index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Example</title>
  </head>
  <body>
    <h1>Example</h1>
  </body>
  <script>
    fetch('https://xxxxxxxxxxx.cloudfront.net/graphql', {
      method: 'POST',
      headers: {
        'x-api-key': 'xxxxxxxxxxxxxxxxx',
        'Content-Type': 'application/graphql',
      },
      mode: 'cors',
      body: JSON.stringify({
        query: `
          query MyQuery {
            user {
              id
              name
            }
          }
        `,
      }),
    })
  </script>
</html>

フロントエンドリソース分のCDKコードは以下です。

lib/appsync-stack.ts

    /**
     * Frontend
     */
    // フロントエンド用S3バケットとCloudFrontを作成して紐付ける
    const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    })
    const originAccessIdentity = new cloudfront.OriginAccessIdentity(
      this,
      'websiteOai'
    )
    const webSiteBucketPolicyStatement = new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      effect: iam.Effect.ALLOW,
      principals: [
        new iam.CanonicalUserPrincipal(
          originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [`${websiteBucket.bucketArn}/*`],
    })
    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement)
    const websiteDistribution = new cloudfront.Distribution(
      this,
      'websiteDistribution',
      {
        defaultRootObject: 'index.html',
        defaultBehavior: {
          origin: new cloudfrontOrigins.S3Origin(websiteBucket, {
            originAccessIdentity,
          }),
          viewerProtocolPolicy:
            cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
      }
    )
    // フロントエンドリソースもCDKでデプロイする
    new s3Deployment.BucketDeployment(this, 'WebsiteDeploy', {
      sources: [s3Deployment.Source.asset('public')],
      destinationBucket: websiteBucket,
      distribution: websiteDistribution,
      distributionPaths: ['/*'],
    })

なお、こちらの記述は次のブログをかなり参考にさせていただきました。

CloudFront DistributionのCDK Constructの新しいクラスを使って静的サイトホスティング(Amazon S3)の配信を構築してみた | DevelopersIO

レスポンスヘッダーポリシー(CORS設定)

AppSyncの前段に置くCloudFront Distributionのレスポンスヘッダーポリシーを用意します。

lib/appsync-stack.ts

    // レスポンスヘッダーポリシー
    // ここでCORS系ヘッダを追加する
    const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
      this,
      'ResponseHeadersPolicy',
      {
        corsBehavior: {
          accessControlAllowCredentials: false,
          accessControlAllowOrigins: [`https://${websiteDistribution.domainName}`],
          accessControlAllowHeaders: ['*'],
          accessControlAllowMethods: ['POST', 'OPTIONS'],
          originOverride: true,
        },
      }
    )

accessControlAllowOrigins: [websiteDistribution.domainName] でフロントエンドのURLを許可するようにしています。

オリジンリクエストポリシー

CloudFrontからAppSyncにクライアントからのリクエストヘッダーを渡す必要があるためオリジンリクエストポリシーも用意します。検証の当初は未設定だったので、x-api-key ヘッダーでAPIキーを渡しているにも関わらず以下のエラーが発生しました。

{
  "errors" : [ {
    "errorType" : "UnauthorizedException",
    "message" : "You are not authorized to make this call."
  } ]
}

これを避けるために以下のオリジンリクエストポリシーを用意しました。

lib/appsync-stack.ts

    // オリジンリクエストポリシー
    // リクエストヘッダのx-api-keyとContent-TypeをAppSyncまで渡すようにする
    const originRequestPolicy = new cloudfront.OriginRequestPolicy(
      this,
      'originRequestPolicy',
      {
        headerBehavior: cloudfront.OriginRequestHeaderBehavior.allowList(
          'x-api-key',
          'Content-Type'
        ),
      }
    )

後述のコードで登場しますが、キャッシュポリシーについては cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED と設定しています。

正直、このキャッシュポリシーとオリジンリクエストポリシーの組み合わせがベストかどうかはやや自信がないところではあります。キャッシュポリシーとオリジンリクエストポリシーについては下記のブログなども併せて参考いただければと思います。

[アップデート] Amazon CloudFront でキャッシュキーとオリジンリクエストポリシーによる管理が可能となりました | DevelopersIO

AppSync用CloudFront Distributionを作成

AppSyncの前段に置くCloudFront Distributionを作成します。

lib/appsync-stack.ts

    // レスポンスヘッダーポリシー
    // ここでCORS系ヘッダを追加する
    const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
      this,
      'ResponseHeadersPolicy',
      {
        corsBehavior: {
          accessControlAllowCredentials: false,
          accessControlAllowOrigins: [`https://${websiteDistribution.domainName}`],
          accessControlAllowHeaders: ['*'],
          accessControlAllowMethods: ['POST'],
          originOverride: true,
        },
      }
    )

lib/appsync-stack.ts

    new cloudfront.Distribution(this, 'appsyncApiDistribution', {
      defaultBehavior: {
        origin: new cloudfrontOrigins.HttpOrigin(
          // URLでなくDomain形式で渡さなければならないため、CFnのparseDomainName関数を使う
          cdk.Fn.parseDomainName(api.graphqlUrl)
        ),
        responseHeadersPolicy: {
          responseHeadersPolicyId:
            responseHeadersPolicy.responseHeadersPolicyId,
        },
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
        compress: false,
        cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
        originRequestPolicy: originRequestPolicy,
      },
    })

ややハマった点として、CloudFrontのHTTP OriginとしてAppSync APIのドメインを設定するのですが、CDKの GraphqlApi クラスには .graphqlUrl というURLのプロパティはあるものの、ドメインを返すプロパティやメソッドはないようです。そして、 new cloudfrontOrigins.HttpOrigin() にはドメインを渡す必要があり、 .graphqlUrl をそのまま渡すと次のようなエラーとなります。

Resource handler returned message: "Invalid request provided: The parameter origin name cannot contain a colon. (Service: CloudFront, Status
Code: 400, Request ID: XXXXXXXXXXXX)" (RequestToken: xxxxxxxxxxxxx, HandlerErrorCode: Invalid

コロン : が含まれてはいけないというエラーが出るので、つまり https:// の部分をカットする必要があります。ただ、CDK・CFnの仕組み上、

cloudfrontOrigins.HttpOrigin(api.graphqlUrl.replace('https://', ''))

のような方法では意図通り動作しません。ではどうするのかというと、CFnの組み込み関数である parseDomainName() を使うと良いようでした(ちなみに、これはSlackコミュニティのcdk.devで質問して教えてもらいました)。

Lambda関数コードとGraphQLスキーマファイル

AppSyncのResolverとするLambda関数の中身は以下になります。

lambda-handler/find-user-handler.ts

import { AppSyncResolverEvent } from 'aws-lambda'

export const handler = async (event: AppSyncResolverEvent<{}, {}>) => {
  console.log(JSON.stringify({ event }))

  return {
    id: 'USER_ID',
    name: 'John Doe',
  }
}

GraphQLスキーマファイルは以下です。

[gql title="schema.graphql"] type User { id: String! name: String! } type Query { user: User! } [/gql]

CDKコード全体

CDKコードの全体を以下に示します。

lib/appsync-stack.ts

import { Construct } from 'constructs'
import * as cdk from 'aws-cdk-lib'
import * as appsync from '@aws-cdk/aws-appsync-alpha'
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs'
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'
import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins'
import * as s3 from 'aws-cdk-lib/aws-s3'
import * as s3Deployment from 'aws-cdk-lib/aws-s3-deployment'
import * as iam from 'aws-cdk-lib/aws-iam'

export class AppSyncStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    /**
     * Frontend
     */
    // フロントエンド用S3バケットとCloudFrontを作成して紐付ける
    const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    })
    const originAccessIdentity = new cloudfront.OriginAccessIdentity(
      this,
      'websiteOai'
    )
    const webSiteBucketPolicyStatement = new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      effect: iam.Effect.ALLOW,
      principals: [
        new iam.CanonicalUserPrincipal(
          originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [`${websiteBucket.bucketArn}/*`],
    })
    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement)
    const websiteDistribution = new cloudfront.Distribution(
      this,
      'websiteDistribution',
      {
        defaultRootObject: 'index.html',
        defaultBehavior: {
          origin: new cloudfrontOrigins.S3Origin(websiteBucket, {
            originAccessIdentity,
          }),
          viewerProtocolPolicy:
            cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        },
      }
    )
    // フロントエンドリソースもCDKでデプロイする
    new s3Deployment.BucketDeployment(this, 'WebsiteDeploy', {
      sources: [s3Deployment.Source.asset('public')],
      destinationBucket: websiteBucket,
      distribution: websiteDistribution,
      distributionPaths: ['/*'],
    })

    /**
     * Backend
     */
    // AppSync API
    const api = new appsync.GraphqlApi(this, 'myAppsyncApi', {
      name: 'myAppsyncApi',
      schema: appsync.Schema.fromAsset('./schema.graphql'),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.API_KEY,
        },
      },
    })

    // レスポンスヘッダーポリシー
    // ここでCORS系ヘッダを追加する
    const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
      this,
      'ResponseHeadersPolicy',
      {
        corsBehavior: {
          accessControlAllowCredentials: false,
          accessControlAllowOrigins: [websiteDistribution.domainName],
          accessControlAllowHeaders: ['*'],
          accessControlAllowMethods: ['POST'],
          originOverride: true,
        },
      }
    )
    // オリジンリクエストポリシー
    // リクエストヘッダのx-api-keyとContent-TypeをAppSyncまで渡すようにする
    const originRequestPolicy = new cloudfront.OriginRequestPolicy(
      this,
      'originRequestPolicy',
      {
        headerBehavior: cloudfront.OriginRequestHeaderBehavior.allowList(
          'x-api-key',
          'Content-Type'
        ),
      }
    )
    new cloudfront.Distribution(this, 'appsyncApiDistribution', {
      defaultBehavior: {
        origin: new cloudfrontOrigins.HttpOrigin(
          // URLでなくDomain形式で渡さなければならないため、CFnのparseDomainName関数を使う
          cdk.Fn.parseDomainName(api.graphqlUrl)
        ),
        responseHeadersPolicy: {
          responseHeadersPolicyId:
            responseHeadersPolicy.responseHeadersPolicyId,
        },
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
        compress: false,
        cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
        originRequestPolicy: originRequestPolicy,
      },
    })

    // Lambda関数
    const userFn = new lambdaNodejs.NodejsFunction(this, 'userFn', {
      entry: 'lambda-handler/find-user-handler.ts',
    })

    // Lambda関数をDataSourceとしてAppSyncAPIと紐付ける
    const userDs = api.addLambdaDataSource('userDs', userFn)

    // schema.graphqlで定義した中のどの操作とマッピングするかを指定
    userDs.createResolver({
      typeName: 'Query',
      fieldName: 'user',
    })
  }
}

動作確認

リクエストが成功することを確認

まずはcurlコマンドで叩いてみます。

curl -H "Origin: https://{フロントエンド用CloudFrontのサブドメイン}.cloudfront.net" \
     -H "Access-Control-Request-Method: POST" \
     -X OPTIONS --v \
        https://{AppSync用CloudFrontのサブドメイン}.cloudfront.net/graphql

出力の一部抜粋が以下です。

< HTTP/2 200 
< content-length: 0
< date: Sat, 07 May 2022 15:26:38 GMT
< x-amzn-requestid: xxxxxxxxxxxxxxxxxxxxxxxx
< via: 1.1 xxxxxxxxxxxx.cloudfront.net (CloudFront), 1.1 xxxxxxxxxxxxxxx.cloudfront.net (CloudFront)
< x-amz-cf-pop: KIX56-C2
< access-control-allow-origin: https://xxxxxxxxxxxx.cloudfront.net
< access-control-allow-methods: POST
< vary: Access-Control-Request-Method
< vary: Origin
< vary: Access-Control-Request-Headers
< access-control-allow-headers: *
< x-cache: Miss from cloudfront
< x-amz-cf-pop: KIX50-P3
< x-amz-cf-id: xxxxxxxxxxxxx

access-control-allow-originaccess-control-allow-methods が意図通りに返っています。

続いて、Chromeブラウザでフロントエンド用CloudFront DistributionのURLを開き、DevToolsを確認しながら動作確認します。Networkタブを開くと以下のようにリクエストが成功していることが確認できました。

レスポンスボディも下のように意図通りの内容が返ってきていました。

{"data":{"user":{"id":"USER_ID","name":"John Doe"}}}

Access-Control-Allow-Originの値を変えてリクエストを失敗させてみる

次は、 Access-Control-Allow-Origin を許可したいURLとは違う値にした場合に失敗することを確認します。CDKコードの accessControlAllowOrigins の値を 'https://example.com' に変更します。

    // レスポンスヘッダーポリシー
    // ここでCORS系ヘッダを追加する
    const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
      this,
      'ResponseHeadersPolicy',
      {
        corsBehavior: {
          accessControlAllowCredentials: false,
          accessControlAllowOrigins: ['https://example.com'],
          accessControlAllowHeaders: ['*'],
          accessControlAllowMethods: ['POST'],
          originOverride: true,
        },
      }
    )

curlコマンドで叩いてみます。

curl -H "Origin: https://{フロントエンド用CloudFrontのサブドメイン}.cloudfront.net" \
     -H "Access-Control-Request-Method: POST" \
     -X OPTIONS --v \
        https://{AppSync用CloudFrontのサブドメイン}.cloudfront.net/graphql

出力の一部抜粋が以下です。

< HTTP/2 200 
< content-length: 0
< date: Sat, 07 May 2022 15:32:51 GMT
< x-amzn-requestid: xxxxxxxxxxxxxx
< via: 1.1 xxxxxxxxxxxxxx.cloudfront.net (CloudFront), 1.1 xxxxxxxxxxxxxxxx.cloudfront.net (CloudFront)
< x-amz-cf-pop: KIX56-C2
< access-control-allow-origin: https://example.com
< access-control-allow-methods: POST
< vary: Access-Control-Request-Method
< vary: Origin
< vary: Access-Control-Request-Headers
< access-control-allow-headers: *
< x-cache: Miss from cloudfront
< x-amz-cf-pop: KIX50-P3
< x-amz-cf-id: xxxxxxxxxxxxxxxxxxxxxxxxxxx

access-control-allow-originhttps://example.com になっています。

Chromeブラウザで確認するとエラーになることがわかります。

また、DevToolsのConsoleタブにもエラーが出力されます。

Access to fetch at 'https://xxxxxxxxxx.cloudfront.net/graphql' from origin 'https://xxxxxxxxxxx.cloudfront.net' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

これでリクエスト元ページのドメインが違う場合は弾かれることを確認できました。

気になる点

オリジンのAppSync APIのURLが知られていれば直接叩けてしまう?

気になる点のひとつに、上記を実施してもオリジンのAppSync APIのURL https://xxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql を知っている人は直接叩くことができてしまう問題があります。

これに対してはまず、AppSync APIのURLは推測困難と思われることから、割り切って更なる対処はしないという選択肢が考えられます。

「もしURLを知られたとしてもブロックしたい」という場合は、実際に試してはいないのですが、AppSyncとAWS WAFを紐付けて、下記のCloudFrontのIPレンジをIP制限のホワイトリストに設定するという方法が採れるかもしれないと思っています。

CloudFront エッジサーバーの場所と IP アドレス範囲 - Amazon CloudFront

AWS WAFのIP制限については下記ブログなどが参考になりそうです。

AWS WAFV2でIPアドレス制限してみた | DevelopersIO

Subscriptionには影響はない?

リアルタイムデータ - AWS AppSync

CloudFrontを前段に置くことでAppSyncのSubscriptionに影響が発生するかどうかについては、申し訳ないのですがまだ手元で試せていないため何とも断言が難しいです。後日検証ブログを投稿したいと考えています。

本記事の構成を検討しているが、要件的にSubscriptionが必要そう……という場合はこの点しっかり事前リサーチをした方が良さそうです。

その他補足

AppSyncのカスタムレスポンスヘッダーサポートは使えない?

AWS AppSync がカスタムレスポンスヘッダーのサポートを追加

最近、上のようなアップデートがあり、 $util.http.addResponseHeader() が使えるようになりました。ただ、以下の制約があるようです。

$util.http の HTTP ヘルパー - AWS AppSync

以下の制限が適用されます。 ヘッダー名は、既存のヘッダー名または制限付きのいずれとも一致できませんAWSまたはAWS AppSync ヘッダー。 ヘッダー名を制限付きプレフィックスで始めることはできません。x-amzn-またはx-amz-。 カスタムレスポンスヘッダーのサイズは 4 KB を超えることはできません。これには、ヘッダーの名前と値が含まれます。 各レスポンスヘッダーは、GraphQL オペレーションごとに 1 回定義する必要があります。ただし、同じ名前のカスタムヘッダーを複数回定義すると、最新の定義が応答に表示されます。すべてのヘッダーは、名前に関係なく、ヘッダーサイズの制限にカウントされます。

上記より、既存のヘッダー(つまり Access-Control-Allow-Origin )を上書きすることはできないように見えます。実際に試してみました。

lib/appsync-stack.ts

    const apiDistribution = new cloudfront.Distribution(
      this,
      'appsyncApiDistribution',
      {
        defaultBehavior: {
          origin: new cloudfrontOrigins.HttpOrigin(
            // URLでなくDomain形式で渡さなければならないため、CFnのparseDomainName関数を使う
            cdk.Fn.parseDomainName(api.graphqlUrl)
          ),
          responseHeadersPolicy: {
            responseHeadersPolicyId:
              responseHeadersPolicy.responseHeadersPolicyId,
          },
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
          allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
          compress: false,
          cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
          originRequestPolicy: originRequestPolicy,
        },
      }
    )

    // Lambda関数
    const userFn = new lambdaNodejs.NodejsFunction(this, 'userFn', {
      entry: 'lambda-handler/find-user-handler.ts',
    })

    // Lambda関数をDataSourceとしてAppSyncAPIと紐付ける
    const userDs = api.addLambdaDataSource('userDs', userFn)

    // schema.graphqlで定義した中のどの操作とマッピングするかを指定
    userDs.createResolver({
      typeName: 'Query',
      fieldName: 'user',
      responseMappingTemplate: appsync.MappingTemplate.fromString(`
        $util.http.addResponseHeader("x-example-header", "example-value")
        $util.http.addResponseHeader("Access-Control-Allow-Origin", "https://${apiDistribution.domainName}")
        $util.http.addResponseHeader("Access-Control-Allow-Origin-2", "https://${apiDistribution.domainName}")
        $util.toJson($context.result)
      `),
    })
  • x-example-header
  • Access-Control-Allow-Origin
  • Access-Control-Allow-Origin-2

の3つのヘッダーを加えてみました。そして確認した結果が以下です。

OPTIONSリクエスト:

POSTリクエスト:

POSTリクエストにおいてx-example-headerとAccess-Control-Allow-Origin-2は追加できました。しかし、Access-Control-Allow-Originの値は * のままでありやはり上書きできないように見えます。

また、OPTIONSつまりPreflightリクエストでは追加ヘッダーが含まれない結果となりました。ResponseMappingTemplateはResolverごとに設定するので、OPTIONSリクエストはどのResolverにも該当しないためと思われます。この点はどのようなリクエストに対しても設定できるような項目があればクリアできる可能性がありますが、自分が確認した限りでは見つけられませんでした(もしできるという情報があれば提供頂ければ嬉しいです)。

以上の2点から、現時点では $util.http.addResponseHeader() でCORSを設定することは難しいと考えています。

まとめ

ここまでAppSyncの前段にCloudFrontを置いてCORSレスポンスヘッダーを設定する方法を紹介しましたが、この構成が常に推奨かというと否であり、ケースバイケースの意思決定が必要と思います。

構成がやや複雑になるという点でトレードオフは発生するので、要件を鑑みつつ総合的に判断していきましょう。

以上、少しでも参考になれば幸いです。

参考