Amazon API Gatewayで国外からのアクセス制限をCDKで実装する

f:id:hamo2020:20220407114125j:plain

マネックス証券 システム開発推進部のHです。

はじめに

久々にCDKネタです。
今回はAmazon API Gatewayで国外からのアクセスを制限する(日本国内からのアクセスのみ許可する)ことをCDK+Javaで実装します。

やりたいこと

イメージ的にはこのような感じです。

  • 国内からのAPI呼び出しはOK。
  • 国外からのAPI呼び出しはNG。

f:id:hamo2020:20220408105933p:plain

どうやって実現するか考える

API GatewayにAWS WAFのWeb ACL(ウェブアクセスコントロールリスト)を追加し、そのルールの中で国外からのアクセスをブロックするようにします。

今回その実装をCDK(使用言語Java)で実装します。
API Gatewayの実装をCDKで行うことについては、過去にエンジニアブログで書いたのでそちらをご覧ください。

blog.tech-monex.com

実装してみる

Rest API

APIの実装にはRestApiを使用します。

RestApi api = RestApi.Builder.create(this, "Eblog202204Stack-createApi")
            .restApiName("Eblog202204Api")
            .description("2022/4月エンジニアブログ用APIです。")
            .deploy(false)
            .build();

APIのリソース&メソッド

APIとしてはモックで下記のような固定のレスポンスを返すようにします。(正常アクセスの場合)

  • レスポンスのイメージ
{
  "message": "OK",
  "statusCode": 200
}

リソースとメソッドの実装は以下の通りです。

Resource test = api.getRoot().addResource("test");
    test.addMethod("GET",
        MockIntegration.Builder.create()
            .integrationResponses(List.of(IntegrationResponse
                .builder()
                .statusCode("200")
                .responseTemplates(Map.of("application/json", 
                    "{ \"message\": \"OK\", \"statusCode\": 200 }"))
                .build()))
            .passthroughBehavior(PassthroughBehavior.NEVER)
            .requestTemplates(Map.of(
                    "application/json", "{ \"statusCode\": 200 }"))                
            .build(),
        MethodOptions
            .builder()
            .methodResponses(List.of(MethodResponse
                .builder()
                .statusCode("200")
                .build()))
            .build());

cdk deployにより以下の用に構築されます。 f:id:hamo2020:20220407161840p:plain

Web ACLの設定

Web ACLでは以下の基準を使用してリクエストを許可したりブロックしたりできます。(AWS WAF デベロッパーガイド)

  • リクエストの IP アドレスの送信元
  • リクエストの送信元の国
  • リクエストの一部に含まれる文字列一致または正規表現(regex)一致
  • リクエストの特定の部分のサイズ
  • 悪意のある SQL コードまたはスクリプトの検出

今回はリクエスト送信元の国を判断して、日本以外をブロックします。 構築すると以下のような画面になります。

f:id:hamo2020:20220407162549p:plain f:id:hamo2020:20220407162721p:plain f:id:hamo2020:20220407162838p:plain

また、ログをCloudWatchログに出力します。CloudWatchログ名は「aws-waf-logs-」で始まる必要があります。 f:id:hamo2020:20220407163219p:plain

実装としては以下の通りです。

  • Web ACLの作成
// WebACLを作成する。
CfnWebACL webAcl =  CfnWebACL.Builder.create(this, aclName)
    .name(aclName)
    .description("Eblog202204 WebACL")
    .defaultAction(DefaultActionProperty
        .builder()
        .allow(AllowActionProperty.builder()
            .build())
        .build())
    .visibilityConfig(VisibilityConfigProperty
        .builder()
        .sampledRequestsEnabled(true)
        .cloudWatchMetricsEnabled(true)
        .metricName(aclName)
        .build())
    .scope("REGIONAL")
    .rules(List.of(RuleProperty  // ↓ここの範囲で日本以外をブロックする設定をしている。
        .builder()
        .name(ruleName)
        .priority(0)
        .action(RuleActionProperty
            .builder()
            .block(BlockActionProperty
                .builder()
                .build())
            .build())
        .statement(StatementProperty
            .builder()
            .notStatement(NotStatementProperty
                .builder()
                .statement(StatementProperty
                    .builder()
                    .geoMatchStatement(GeoMatchStatementProperty
                        .builder()
                        .countryCodes(List.of("JP"))
                        .build())
                    .build())
                .build())
            .build())  // ↑ここまで
        .visibilityConfig(VisibilityConfigProperty.builder()
            .sampledRequestsEnabled(true)
            .cloudWatchMetricsEnabled(true)
            .metricName(ruleName)
            .build())
        .build()))
    .build();
  • WAFが出力するログのロググループ作成とWeb ACLへの紐づけをします。
// ロググループの作成
String logGroupName = "aws-waf-logs-" + webAcl.getName();
LogGroup.Builder.create(this, logGroupName)
    .logGroupName(logGroupName)
    .removalPolicy(RemovalPolicy.DESTROY)
    .retention(RetentionDays.FIVE_DAYS)
    .build();

// ロググループとWebACLの紐づけ
String logGroupArn = this.formatArn(ArnComponents.builder()
    .service("logs")
    .resource("log-group:" + logGroupName)
    .build());
CfnLoggingConfiguration.Builder.create(this, webAcl.getName() + "-waf-loggroup")
    .logDestinationConfigs(List.of(logGroupArn))
    .resourceArn(webAcl.getAttrArn())
    .build();
  • API Gatewayのデプロイ API GatewayとWeb ACLの紐づけについては、APIのステージとWeb ACLを紐づけます。
Deployment deploy = Deployment.Builder.create(this, "Deployment_" + stageName)
    .api(api)
    .description("CDKでデプロイしています。")
    .retainDeployments(true)
    .build();

Stage.Builder.create(this, api.getRestApiName() + "Stage")
    .stageName(stageName)
    .tracingEnabled(true)
    .methodOptions(
        Map.of("/*/*", MethodDeploymentOptions
            .builder()
            .loggingLevel(MethodLoggingLevel.INFO)
            .dataTraceEnabled(true)
            .metricsEnabled(true)
            .build()))
    .metricsEnabled(true)
    .deployment(deploy)
    .build();

// WebACLとAPIゲートウェイのステージを紐付ける。
String resourceArn = this.formatArn(ArnComponents.builder()
    .service("apigateway")
    .resource("/restapis/" + api.getRestApiId() + "/stages/" + stageName)
    .build());
CfnWebACLAssociation.Builder.create(this, api.getRestApiName() + "Association")
    .resourceArn(resourceArn)
    .webAclArn(webAcl.getAttrArn())
    .build();

紐づけ後の画面です。 f:id:hamo2020:20220407164630p:plain

確認してみる

ターミナルでの確認

国内(東京リージョン)と国外(オハイオリージョン)にEC2を立てて、それぞれcurlコマンドでAPI呼び出しを行います。

  • 国内
    正常なレスポンスが返ってきています。 f:id:hamo2020:20220408082909p:plain

  • 国外
    ブロックされて、エラーレスポンス(403エラー)が返ってきています。 f:id:hamo2020:20220408083108p:plain

Web ACL画面の確認

国内からのリクエストは許可されて、国外からのリクエストはブロックされていることが分かります。 f:id:hamo2020:20220408084113p:plain

CloudWatchログの確認

CloudWatchログにも出力しているので、こちらでも確認できます。

  • 国内
    actionが「ALLOW」となっていることが分かります。
{
    "timestamp": 1649372109094,
    "formatVersion": 1,
    "webaclId": "arn:aws:wafv2:ap-northeast-1:999999999999:regional/webacl/Eblog202204webAcl/07b59af2-5328-4507-9191-8c66dfe1c7ca",
    "terminatingRuleId": "Default_Action",
    "terminatingRuleType": "REGULAR",
    "action": "ALLOW",
    "terminatingRuleMatchDetails": [],
    "httpSourceName": "APIGW",
    "httpSourceId": "999999999999:xxxxxxxxxx:devmonex",
    "ruleGroupList": [],
    "rateBasedRuleList": [],
    "nonTerminatingMatchingRules": [],
    "requestHeadersInserted": null,
    "responseCodeSent": null,
    "httpRequest": {
        "clientIp": "123.456.789.012",
        "country": "JP",
        "headers": [
            {
                "name": "X-Forwarded-For",
                "value": "123.456.789.012, 345.678.901.234"
            },
            {
                "name": "X-Forwarded-Proto",
                "value": "https"
            },
            {
                "name": "X-Forwarded-Port",
                "value": "443"
            },
            {
                "name": "Host",
                "value": "xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com"
            },
            {
                "name": "X-Amzn-Trace-Id",
                "value": "Root=1-624f6ecd-0f9c869f513bdbc93384733d"
            },
            {
                "name": "User-Agent",
                "value": "curl/7.76.1"
            },
            {
                "name": "X-Amz-Cf-Id",
                "value": "B8ail1i66traCd2iwCajav11hvyN7ZvX8__IKv6othWgoMTUGI_HGg=="
            },
            {
                "name": "Via",
                "value": "2.0 1f93e59f609910f3906a07395eb1ee4a.cloudfront.net (CloudFront)"
            },
            {
                "name": "Accept",
                "value": "*/*"
            },
            {
                "name": "CloudFront-Is-Mobile-Viewer",
                "value": "false"
            },
            {
                "name": "CloudFront-Is-Tablet-Viewer",
                "value": "false"
            },
            {
                "name": "CloudFront-Is-SmartTV-Viewer",
                "value": "false"
            },
            {
                "name": "CloudFront-Is-Desktop-Viewer",
                "value": "true"
            },
            {
                "name": "CloudFront-Viewer-Country",
                "value": "JP"
            },
            {
                "name": "CloudFront-Forwarded-Proto",
                "value": "https"
            }
        ],
        "uri": "/devmonex/test",
        "args": "",
        "httpVersion": "HTTP/1.1",
        "httpMethod": "GET",
        "requestId": "QO3IEGlvNiMFuLQ="
    }
}
  • 国外
    actionが「BLOCK」となっていることが分かります。
{
    "timestamp": 1649372216994,
    "formatVersion": 1,
    "webaclId": "arn:aws:wafv2:ap-northeast-1:999999999999:regional/webacl/Eblog202204webAcl/07b59af2-5328-4507-9191-8c66dfe1c7ca",
    "terminatingRuleId": "Eblog202204rule",
    "terminatingRuleType": "REGULAR",
    "action": "BLOCK",
    "terminatingRuleMatchDetails": [],
    "httpSourceName": "APIGW",
    "httpSourceId": "999999999999:xxxxxxxxxx:devmonex",
    "ruleGroupList": [],
    "rateBasedRuleList": [],
    "nonTerminatingMatchingRules": [],
    "requestHeadersInserted": null,
    "responseCodeSent": null,
    "httpRequest": {
        "clientIp": "345.678.901.234",
        "country": "US",
        "headers": [
            {
                "name": "X-Forwarded-For",
                "value": "345.678.901.234, 123.456.789.012"
            },
            {
                "name": "X-Forwarded-Proto",
                "value": "https"
            },
            {
                "name": "X-Forwarded-Port",
                "value": "443"
            },
            {
                "name": "Host",
                "value": "xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com"
            },
            {
                "name": "X-Amzn-Trace-Id",
                "value": "Root=1-624f6c48-41966d271932fc7c4beef7d2"
            },
            {
                "name": "User-Agent",
                "value": "curl/7.79.1"
            },
            {
                "name": "X-Amz-Cf-Id",
                "value": "Hd0f0MyQy-_TjKndb879krh-tYmP48jMBFTrkZecEUz46m2R92JYFQ=="
            },
            {
                "name": "Via",
                "value": "2.0 47dbad48e25df8e5ccd2822e46c2aba6.cloudfront.net (CloudFront)"
            },
            {
                "name": "Accept",
                "value": "*/*"
            },
            {
                "name": "CloudFront-Is-Mobile-Viewer",
                "value": "false"
            },
            {
                "name": "CloudFront-Is-Tablet-Viewer",
                "value": "false"
            },
            {
                "name": "CloudFront-Is-SmartTV-Viewer",
                "value": "false"
            },
            {
                "name": "CloudFront-Is-Desktop-Viewer",
                "value": "true"
            },
            {
                "name": "CloudFront-Viewer-Country",
                "value": "US"
            },
            {
                "name": "CloudFront-Forwarded-Proto",
                "value": "https"
            }
        ],
        "uri": "/devmonex/test",
        "args": "",
        "httpVersion": "HTTP/1.1",
        "httpMethod": "GET",
        "requestId": "QO3Y7GAqNhMFbTw="
    }
}

おわりに

API Gatewayに関しては過去のエンジニアブログでも書きましたが、今回はさらにWAF(WebACL)についてCDKで実装しました。 CDKの特にJavaの実装や、WAFについてはナレッジが少なく、APIリファレンスもわかりにくいのですが、なんとか実装できました。

f:id:hamo2020:20200830084302p:plain