CloudFront + Lambda 関数 URL 構成でPOST/PUT リクエストを行うために Lambda@Edge でSigv4署名する

CloudFront + Lambda 関数 URL 構成でPOST/PUT リクエストを行うために Lambda@Edge でSigv4署名する

Lambda 関数 URL の OAC はPOST/PUTに対応していないのを知っていましたか?
Clock Icon2024.07.09 16:52

こんにちは。リテールアプリ共創部のきんじょーです。

少し前に、CloudFront が Lambda 関数 URL をオリジンとした OAC のサポートが開始されました。

https://aws.amazon.com/jp/about-aws/whats-new/2024/04/amazon-cloudfront-oac-lambda-function-url-origins/

このアップデート以前は、Lambda 関数 URL を IAM 認証で保護した場合、CloudFront から Lambda 関数 URL を実行するために、Lambda@Edge を使ってリクエストに Sigv4 の署名をする必要がありました。
OAC 対応により、マネージドで署名を付与してくれるようになり、CloudFront を前段に置いた Lambda 関数 URL の実装が容易になりました。

しかし、この OAC は 全ての HTTP メソッドには対応しておらず、POST/PUT リクエストを行う場合は、依然 Sigv4 で署名が必要です。

1_sign_required

自前で Sigv4 の署名をする Lambda@Edge を実装する機会があったので、備忘のために残しておきます。

全量のコードは以下のリポジトリに格納してあります。
お手元で試したい方はクローンしてデプロイしてみて下さい。

https://github.com/joe-king-sh/lambda-function-urls-sigv4-signer-sample

やってみる

AWS 構成

生成 AI を利用した API を公開する際、タイムアウトのクォータの問題で API Gateway を使わずにLambda 関数 URL を利用するケースがあります。

今回はそのエンドポイントで Slack アプリの Webhook を受ける構成をとっていたため、POST リクエストを捌く必要がありました。

2_architecture

※この記事では Sigv4 の署名を趣旨としているため、WAF や DynamoDB、Bedrock といったリソースの説明・実装は割愛します。

Lambda@Edge の実行タイミング

Lambda@Edge は CloudFront 4 つのイベントをトリガーに実行できます。

  1. Viewer リクエスト
  2. Origin リクエスト
  3. Origin レスポンス
  4. Viewer レスポンス

今回は CloudFront からオリジンにアクセスする際に、リクエストに署名を付与するため、Origin リクエストをトリガーに Lambda@Edge を実行します。

3_execution_trigger

署名処理の実装

以下のコードでオリジンリクエストをインターセプトして、署名をヘッダーに付与します。

x-forwarded-for ヘッダーを署名に含めてしまうと、 Lambda 関数にリクエストが届く際に CloudFront によって書き換えられてしまう可能性があり、署名するヘッダーから除外が必要でした。
また、署名時は base64 エンコードされた body をデコードする必要があり、この 2 点で大きくハマりました。

import {
  CloudFrontRequestEvent,
  CloudFrontRequestHandler,
  CloudFrontResponseCallback,
} from "aws-lambda";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { parseUrl } from "@aws-sdk/url-parser";
import { SignatureV4 } from "@aws-sdk/signature-v4";
import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { Sha256 } from "@aws-crypto/sha256-js";

export const handler: CloudFrontRequestHandler = async (
  event: CloudFrontRequestEvent,
  _context
) => {
  const request = event.Records[0].cf.request;
  const url = `https://${request.headers.host[0].value}${request.uri}`;

  const body = request.body?.data || "";
  // 署名時はbase64エンコードされたbodyをデコードする
  const decodedBody = Buffer.from(body, "base64").toString("utf-8");

  const parsedUrl = parseUrl(url);

  const httpRequest = new HttpRequest({
    headers: {
      host: parsedUrl.hostname || "",
      ...Object.fromEntries(
        Object.entries(request.headers)
          .filter(([k, v]) => k.toLowerCase() !== "x-forwarded-for") // x-forwarded-forはLambdaに到達するまでにCloudFrontに書き換えられる可能性があり、署名には含めない
          .map(([k, v]) => [k.toLowerCase(), v[0].value])
      ),
    },
    hostname: parsedUrl.hostname || "",
    method: request.method,
    path: parsedUrl.path,
    body: decodedBody,
  });

  const signer = new SignatureV4({
    credentials: defaultProvider(),
    region: "ap-northeast-1",
    service: "lambda",
    sha256: Sha256,
  });

  const signedRequest = await signer.sign(httpRequest);

  // 署名されたヘッダーをCloudFrontリクエストに追加
  for (const key of [
    "authorization",
    "x-amz-date",
    "x-amz-security-token",
    "x-amz-content-sha256",
  ]) {
    request.headers[key] = [{ key: key, value: signedRequest.headers[key] }];
  }

  return request;
};

Lambda@Edge の CDK 実装

Lambda@Edge に使用する Lambda は CloudFront のコントロールプレーンが配置されているバージニア(us-east-1)リージョンにデプロイする必要があります。

CloudFront やその他リソースは東京リージョン(ap-northeast1)にデプロイしたい場合、そのままではクロスリージョンで Lambda@Edge に設定する Lambda の ARN を取得できません。

東京リージョンにデプロイするスタックから参照できるように、SSM パラメーターストアに Lambda@Edge で使用する Lambda の ARN を格納しておきます。

import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";

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

    const lambdaEdgeFunction = new NodejsFunction(this, "LambdaEdgeFunction", {
      runtime: lambda.Runtime.NODEJS_20_X,
      entry: "./lib/lambda-edge/origin-request-sigv4-signer.ts",
      handler: "handler",
      memorySize: 1769,
      timeout: cdk.Duration.seconds(5),
      role: new iam.Role(this, "LambdaEdgeFunctionRole", {
        assumedBy: new iam.CompositePrincipal(
          new iam.ServicePrincipal("lambda.amazonaws.com"),
          new iam.ServicePrincipal("edgelambda.amazonaws.com")
        ),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName(
            "service-role/AWSLambdaBasicExecutionRole"
          ),
          iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambda_FullAccess"),
        ],
      }),
    });

    new ssm.StringParameter(this, `${id}-Origin-Request-Sigv4-Signer-Fn-Id`, {
      description: "The Lambda@Edge ARN for CloudFront",
      parameterName: "/LambdaFunctionUrlsSigv4SignerSample/LambdaEdgeArn",
      stringValue: lambdaEdgeFunction.currentVersion.functionArn,
      tier: ssm.ParameterTier.STANDARD,
    });
  }
}

Lambda と CloudFront の CDK 実装

CloudFront の後段の Lambda はインラインで「Hello from Lambda!」の 200 応答を返す仮実装です。自前で署名するため、CloudFront には OAI の設定は不要です。
先ほど格納した Lambda@Edge の ARN はカスタムリソースで SSM パラメーターから値を取り出しています。

import * as lambdaPython from "@aws-cdk/aws-lambda-python-alpha";
import * as cdk from "aws-cdk-lib";
import {
  AllowedMethods,
  CachePolicy,
  Distribution,
  HttpVersion,
  LambdaEdgeEventType,
  OriginRequestPolicy,
  PriceClass,
  ResponseHeadersPolicy,
  ViewerProtocolPolicy,
} from "aws-cdk-lib/aws-cloudfront";
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
import {
  AwsCustomResource,
  AwsCustomResourcePolicy,
  PhysicalResourceId,
} from "aws-cdk-lib/custom-resources";
import { Construct } from "constructs";

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

    /**
     * Lambda関数
     */
    const lambdaFunction = new cdk.aws_lambda.Function(this, "Lambda", {
      code: cdk.aws_lambda.Code.fromInline(`
def handler(_event, _context):
    return {
        'statusCode': 200,
        'body': 'Hello from Lambda!'
    }
`),
      handler: "index.handler",
      runtime: cdk.aws_lambda.Runtime.PYTHON_3_12,
      architecture: cdk.aws_lambda.Architecture.ARM_64,
      memorySize: 1769,
      timeout: cdk.Duration.minutes(15),
    });

    const lambdaFunctionUrl = lambdaFunction.addFunctionUrl({
      authType: cdk.aws_lambda.FunctionUrlAuthType.AWS_IAM,
      cors: {
        allowedMethods: [cdk.aws_lambda.HttpMethod.ALL],
        allowedOrigins: ["*"],
      },
    });

    new cdk.CfnOutput(this, "LambdaFunctionUrl", {
      value: lambdaFunctionUrl.url,
    });

    /**
     * CloudFront
     */
    const cloudFrontDistribution = new Distribution(this, "Default", {
      defaultBehavior: {
        origin: new cdk.aws_cloudfront_origins.FunctionUrlOrigin(
          lambdaFunctionUrl
        ),
        allowedMethods: AllowedMethods.ALLOW_ALL,
        cachePolicy: CachePolicy.CACHING_DISABLED,
        originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        responseHeadersPolicy: ResponseHeadersPolicy.SECURITY_HEADERS,
        edgeLambdas: [
          {
            eventType: LambdaEdgeEventType.ORIGIN_REQUEST,
            functionVersion: cdk.aws_lambda.Version.fromVersionArn(
              this,
              "OriginRequestSigv4SignerFn",
              this.getLambdaEdgeArn(
                "/LambdaFunctionUrlsSigv4SignerSample/LambdaEdgeArn"
              )
            ),
            includeBody: true,
          },
        ],
      },
      httpVersion: HttpVersion.HTTP2_AND_3,
      priceClass: PriceClass.PRICE_CLASS_200,
    });

    lambdaFunction.addPermission("AllowCloudFrontServicePrincipal", {
      principal: new cdk.aws_iam.ServicePrincipal("cloudfront.amazonaws.com"),
      action: "lambda:InvokeFunction",
      sourceArn: `arn:aws:cloudfront::${
        cdk.Stack.of(this).account
      }:distribution/${cloudFrontDistribution.distributionId}`,
    });

    new cdk.CfnOutput(this, "CloudFrontDistributionUrl", {
      value: `https://${cloudFrontDistribution.distributionDomainName}`,
    });
  }

  getLambdaEdgeArn(lambdaArnParamKey: string): string {
    const lambdaEdgeArnParameter = new AwsCustomResource(
      this,
      "LambdaEdgeCustomResource",
      {
        policy: AwsCustomResourcePolicy.fromStatements([
          new PolicyStatement({
            effect: Effect.ALLOW,
            actions: ["ssm:GetParameter*"],
            resources: [
              this.formatArn({
                service: "ssm",
                region: "us-east-1",
                resource: "*",
              }),
            ],
          }),
        ]),
        onUpdate: {
          service: "SSM",
          action: "getParameter",
          parameters: { Name: lambdaArnParamKey },
          physicalResourceId: PhysicalResourceId.of(
            `PhysicalResourceId-${Date.now()}`
          ),
          region: "us-east-1",
        },
      }
    );
    return lambdaEdgeArnParameter.getResponseField("Parameter.Value");
  }
}

動作検証

CloudFront 経由で実行した場合

IAM 認証をかけた Lambda 関数 URL に対して、CloudFront 経由でリクエストが可能になりました!

# BodyなしでPOSTリクエスト
% curl -X POST https://dc1isj65gc48f.cloudfront.net

-> Hello from Lambda!

# BodyありでPOSTリクエスト
% curl -X POST https://dc1isj65gc48f.cloudfront.net \
     -H "Content-Type: application/json" \
     -d '{"test":"test"}'

-> Hello from Lambda!

Lambda 関数 URL を直接実行した場合

CloudFront を経由しないと Lambda@Edge で署名されないため、IAM 認証でエラーが返ります。

% curl -X POST https://z3dtedgarwwyjccwd3oqhtnove0gevdy.lambda-url.ap-northeast-1.on.aws

-> {"Message":"Forbidden"}

CloudFront + Lambda 関数 URL でも 自前で署名すれば POST/PUT リクエストが可能です

一度実装してしまえば何てことはないですが、SigV4 の署名検証エラーのデバッグにハマり、かなりの時間を費やしてしまいました。

CloudFront + Lambda 関数 URL で POST/PUT リクエストを受け付ける必要がある場合、このブログを思い出していただけると幸いです。

以上。リテールアプリ共創部のきんじょーでした。

参考

参考にさせていただきました。

https://dev.classmethod.jp/articles/aws-cdk-cloudfront-oac-lambda-function-url/

この記事をシェアする

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.