CloudFront の AWS WAF SDK 自動統合を有効にして、HTML への script タグ挿入と応答の変化を確認してみた

CloudFront の AWS WAF SDK 自動統合を有効にして、HTML への script タグ挿入と応答の変化を確認してみた

アプリケーションや CI/CD にタグを組み込むことなく、web ACL の設定だけで WAF SDK のシグナルを Bot Control などの判定に利用できます。一方で、ETag と Range 要求の扱いにも影響します。
2026.11.01

はじめに

2026-10-30 に、AWS WAF が CloudFront 向けの SDK 自動統合を発表しました。

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/aws-waf-automatic-sdk-integration-amazon-cloudfront/

CloudFront スコープの web ACL で SDK 自動統合を有効にすると、CloudFront が配信する HTML に AWS WAF JavaScript SDK の script タグが挿入され、Bot Control(targeted)などの rule group が、リクエスト元の判定にクライアント側のシグナルを使えるようになります。

この記事では、固定応答と Lambda ターゲットを持つ ALB をオリジンにした CloudFront で SDK 自動統合を有効にし、配信される HTML、応答ヘッダー、WAF ログがどう変わるかを curl とヘッドレス Chromium で確かめました。

SDK 自動統合の設定

要件と既定値

対象は、Bot Control(inspection level が targeted)、ATP、ACFP のいずれかを含む CloudFront スコープの web ACL です。SDK 自動統合の利用に追加料金はかかりません。

発表後に対象の rule group を含む CloudFront スコープの web ACL をコンソールで新規作成すると、SDK 自動統合は有効になります。API で新規作成した場合や、既存の web ACL に対象の rule group を追加した場合は無効のままです。発表前に作った web ACL の設定も変わりません。

https://docs.aws.amazon.com/waf/latest/developerguide/waf-challenge-javascript-sdk-injection.html

API での有効化

API では、web ACL の AssociationConfig にある ChallengeJavascriptSdkInjection の CLOUDFRONT に空のオブジェクトを指定します。検証では次の内容を artifacts/association-config-on.json として保存しました。

{
  "ChallengeJavascriptSdkInjection": {
    "CLOUDFRONT": {}
  }
}

update-web-acl は、web ACL の可変設定(ルール、既定アクション、AssociationConfig など)を呼び出し時に渡した内容で置き換えます。そのため、get-web-acl で現在の設定を取得し、AssociationConfig にこの設定を加えたうえで、設定全体を渡します。

検証では次のコマンドで有効にしました。

aws wafv2 update-web-acl --region us-east-1 \
  --name waf-sdk-verify --scope CLOUDFRONT --id <WEB_ACL_ID> --lock-token <LOCK_TOKEN> \
  --default-action Allow=\{\} \
  --rules file://artifacts/web-acl-rules.json \
  --visibility-config SampledRequestsEnabled=true\,CloudWatchMetricsEnabled=true\,MetricName=waf-sdk-verify \
  --association-config file://artifacts/association-config-on.json

--rules に渡した artifacts/web-acl-rules.json の内容は、次の「検証構成」の節に載せています。

検証構成

項目 値
検証日・リージョン 2026-10-31、us-east-1(web ACL、ALB、Lambda、ログすべて)
経路 curl・ヘッドレス Chromium → CloudFront(HTTPS)→ ALB(HTTP 80)
CloudFront キャッシュポリシーは Managed の CachingDisabled、Compress は false
オリジン ALB。/ と /json は固定応答(200)。/no-transform、/etag、/range は Lambda ターゲット
web ACL CloudFront スコープ。ルールは AWSManagedRulesBotControlRuleSet(inspection level は TARGETED)の 1 つだけで、Count に上書き。ログは CloudWatch Logs
AWS CLI aws-cli/2.39.3。create-web-acl help に ChallengeJavascriptSdkInjection の記載あり
curl 8.18.0
ブラウザー Playwright 1.55.0 の Chromium(ユーザーエージェントは HeadlessChrome/140.0.7339.16)を Docker で実行

--rules に渡した web ACL のルール定義です。

web ACL のルール(artifacts/web-acl-rules.json)
[
  {
    "Name": "BotControlTargeted",
    "Priority": 0,
    "Statement": {
      "ManagedRuleGroupStatement": {
        "VendorName": "AWS",
        "Name": "AWSManagedRulesBotControlRuleSet",
        "ManagedRuleGroupConfigs": [
          { "AWSManagedRulesBotControlRuleSet": { "InspectionLevel": "TARGETED" } }
        ]
      }
    },
    "OverrideAction": { "Count": {} },
    "VisibilityConfig": {
      "SampledRequestsEnabled": true,
      "CloudWatchMetricsEnabled": true,
      "MetricName": "BotControlTargeted"
    }
  }
]

ALB、Lambda、CloudFront は次の CloudFormation テンプレートで作成しました。ALB の固定応答では応答ヘッダーを指定できないため、応答ヘッダーと 206 応答を検証する 3 つのパスには Lambda ターゲットを使いました。

CloudFormation スタック定義(artifacts/stack.yaml)
AWSTemplateFormatVersion: "2010-09-09"
Description: waf-sdk-verify - fixed-response ALB + CloudFront (verification only, delete after use)
Parameters:
  VpcId: { Type: AWS::EC2::VPC::Id }
  SubnetIds: { Type: List<AWS::EC2::Subnet::Id> }
  WebAclArn: { Type: String }
  CloudFrontPrefixListId: { Type: String }
Resources:
  AlbSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: waf-sdk-verify ALB (HTTP 80 from CloudFront origin-facing only)
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - { IpProtocol: tcp, FromPort: 80, ToPort: 80, SourcePrefixListId: !Ref CloudFrontPrefixListId }
  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: application
      Scheme: internet-facing
      Subnets: !Ref SubnetIds
      SecurityGroups: [!Ref AlbSg]
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - { Effect: Allow, Principal: { Service: lambda.amazonaws.com }, Action: "sts:AssumeRole" }
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
  Fn:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.13
      Handler: index.handler
      Role: !GetAtt LambdaRole.Arn
      Code:
        ZipFile: |
          HTML = "<!DOCTYPE html>\n<html>\n<head>\n<title>WAF SDK verify</title>\n</head>\n<body>\n<h1>lambda response</h1>\n</body>\n</html>\n"
          TEXT = "0123456789abcdefghijklmnopqrstuvwxyz\n"

          def resp(status, desc, ctype, body, extra=None):
              h = {"Content-Type": ctype}
              h.update(extra or {})
              return {"statusCode": status, "statusDescription": desc, "headers": h,
                      "body": body, "isBase64Encoded": False}

          def handler(event, context):
              path = event.get("path")
              headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
              if path == "/no-transform":
                  return resp(200, "200 OK", "text/html", HTML, {"Cache-Control": "no-transform"})
              if path == "/etag":
                  return resp(200, "200 OK", "text/html", HTML, {"ETag": '"strong-etag-1"'})
              if path == "/range":
                  rng = headers.get("range", "")
                  if rng.startswith("bytes="):
                      s, e = rng[6:].split("-")
                      s = int(s)
                      e = int(e) if e else len(TEXT) - 1
                      return resp(206, "206 Partial Content", "text/plain", TEXT[s:e + 1],
                                  {"Accept-Ranges": "bytes", "Content-Range": "bytes %d-%d/%d" % (s, e, len(TEXT))})
                  return resp(200, "200 OK", "text/plain", TEXT, {"Accept-Ranges": "bytes"})
              return resp(404, "404 Not Found", "text/plain", "not found\n")
  FnTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    DependsOn: FnPermission
    Properties:
      TargetType: lambda
      Targets:
        - Id: !GetAtt Fn.Arn
  FnPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt Fn.Arn
      Principal: elasticloadbalancing.amazonaws.com
  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref Alb
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: fixed-response
          FixedResponseConfig:
            StatusCode: "200"
            ContentType: text/html
            MessageBody: "<!DOCTYPE html>\n<html>\n<head>\n<title>WAF SDK verify</title>\n</head>\n<body>\n<h1>fixed response</h1>\n</body>\n</html>\n"
  RuleJson:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      ListenerArn: !Ref Listener
      Priority: 10
      Conditions: [{ Field: path-pattern, Values: [/json] }]
      Actions:
        - Type: fixed-response
          FixedResponseConfig: { StatusCode: "200", ContentType: application/json, MessageBody: '{"ok":true}' }
  RuleNoTransform:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      ListenerArn: !Ref Listener
      Priority: 20
      Conditions: [{ Field: path-pattern, Values: [/no-transform] }]
      Actions: [{ Type: forward, TargetGroupArn: !Ref FnTargetGroup }]
  RuleRange:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      ListenerArn: !Ref Listener
      Priority: 30
      Conditions: [{ Field: path-pattern, Values: [/range] }]
      Actions: [{ Type: forward, TargetGroupArn: !Ref FnTargetGroup }]
  RuleEtag:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      ListenerArn: !Ref Listener
      Priority: 40
      Conditions: [{ Field: path-pattern, Values: [/etag] }]
      Actions: [{ Type: forward, TargetGroupArn: !Ref FnTargetGroup }]
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: waf-sdk-verify (delete after use)
        HttpVersion: http2
        WebACLId: !Ref WebAclArn
        Origins:
          - Id: alb
            DomainName: !GetAtt Alb.DNSName
            CustomOriginConfig: { OriginProtocolPolicy: http-only, HTTPPort: 80 }
        DefaultCacheBehavior:
          TargetOriginId: alb
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods: [GET, HEAD]
          Compress: false
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
          OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac
Outputs:
  DomainName: { Value: !GetAtt Distribution.DomainName }
  DistributionId: { Value: !Ref Distribution }
  AlbDns: { Value: !GetAtt Alb.DNSName }

各パスは、URL の末尾を変えて次の curl で取得しました。-i オプションで応答ヘッダーも表示しています。

curl -sS -i -H "Accept-Encoding: identity" https://dxxxxxxxxxxxxx.cloudfront.net/

配信 HTML の変化

script タグの挿入

SDK 自動統合の有効化前後で、ルートパスを curl で取得しました。有効化後の本文は次のとおりです。script タグの src に含まれる ID はマスクしています。

<!DOCTYPE html>
<html>
<head><script type="text/javascript" src="https://<ID1>.edge.sdk.awswaf.com/<ID1>/<ID2>/challenge.js" defer></script>
<title>WAF SDK verify</title>
</head>
<body>
<h1>fixed response</h1>
</body>
</html>

<head> の直後に改行を挟まず script タグが入り、それ以外の内容は無効時と同じです。本文の長さは 115 バイトから 247 バイトになりました。

応答ヘッダーとパス別の挙動

オリジンの応答ごとに、無効のときと有効のときを比べました。

オリジンの応答 無効 有効
text/html(固定応答) 200、content-length 115 200、content-length 247、script タグあり
text/html、強い ETag "strong-etag-1" 200、content-length 116、ETag "strong-etag-1" 200、content-length 248、ETag W/"strong-etag-1-waf"、script タグあり
text/html、Cache-Control: no-transform 200、content-length 116、script タグなし 200、content-length 116、script タグなし
application/json 200、content-length 11 200、content-length 11
text/plain、Range: bytes=0-9 を付けた要求(オリジンは Range を受け取ると 206 で応答、全体 37 バイト) 206、content-length 10、content-range: bytes 0-9/37 200、content-length 37、content-range なし

script タグが挿入された HTML では Content-Length が増え、オリジンの強い ETag は弱い検証子に置き換わりました。Cache-Control: no-transform を返す HTML と、Range を付けずに取得した JSON には変化がありませんでした。

HTML 以外にも影響するのが Range 要求です。Developer Guide によると、SDK 自動統合が有効な間、CloudFront は WAF が許可したリクエストから Range と If-Range ヘッダーを削除し、オリジンへ転送します。そのため、表の text/plain では 206 の部分応答が 200 の全体応答に変わりました。主に注意が必要なのは、動画や音声のプレーヤー、ダウンロードマネージャーなど、部分取得を行うクライアントへの影響です。

WAF ログの記録

挿入の有無

SDK の挿入自体は WAF のメトリクスやログに記録されません。検証でも、有効時と無効時の WAF ログに挿入の有無を示す差はありませんでした。

トークンの記録

SDK 自動統合を有効にした状態で、ヘッドレス Chromium を使って同じ URL を 5 秒間隔で 2 回開きました。初回の読み込みで challenge.js が実行され、aws-waf-token Cookie が設定されました。2 回目はこの Cookie を付けてリクエストしました。無効時には SDK への通信も Cookie の設定もありませんでした。

このときの WAF ログのトークン関連ラベルは次のとおりです。接頭辞 awswaf:managed: は省略しています。

リクエスト 自動統合 トークン関連のラベル
curl 有効 token:absent、captcha:absent
Chromium 1 回目 有効 token:absent、captcha:absent
Chromium 2 回目(同じセッション) 有効 token:accepted、token:id:<TOKEN_ID>、token:fingerprint:<FINGERPRINT>、captcha:rejected、captcha:rejected:not_solved
Chromium 2 回目(新しいセッションで同じ 2 回の手順を実施) 無効に戻した後 token:absent、captcha:absent

token:absent はトークンが無いか読み取れない状態、token:accepted は有効なトークンを受け取った状態です。captcha:rejected:not_solved は CAPTCHA が未解答であることを示し、トークンの有効性とは別に付与されます。このため、token:accepted と同時に記録されることがあります。token:id はクライアントセッションの識別子、token:fingerprint はブラウザーのシグナルから算出したフィンガープリントです。フィンガープリントは単一のクライアントに固有ではありません。

https://docs.aws.amazon.com/waf/latest/developerguide/waf-tokens-labeling.html

まとめ

SDK 自動統合を有効にすると、CloudFront は挿入条件を満たす HTML 応答の <head> 直後に SDK の script タグを挿入します。これにより、保護したいページごとのタグの保守や、アプリケーションのコード、ビルド、デプロイの変更が不要になります。

アプリケーションや CI/CD に script タグを組み込みにくい場合でも、Bot Control(inspection level が targeted)、ATP、ACFP は SDK のシグナルを利用できます。User-Agent を偽装するなど、サーバー側の情報だけでは見分けにくいボットの判定に有効です。

SDK 自動統合は web ACL 全体に適用され、パス単位では指定できません。Range 要求や ETag の扱いが変わるため、既存の配信への影響を評価したうえでの導入をおすすめします。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事