💨

Next.jsをCloudFront+Lambdaでサーバーレス化しカスタムヘッダーでFunctionURLsへの直アクセスを制限してみた

2023/12/14に公開1

はじめに

みなさま、はじめまして。

こちらの記事を読んでいてCDKでCloudFront+Lambda(DockerImage)を組んで遊んでみたいと思い記事にしてみました。(SSR Streamingはしてないです、すみません)
またカスタムヘッダーでアクセス制限すること自体セキュリティ的にどうかという話もあるかもしれないですが、あくまで一つのパターンとして捉えていただければ幸いです。
https://aws.amazon.com/jp/blogs/news/implementing-ssr-streaming-on-nextjs-with-aws-lambda-response-streaming/

構成

タイトル通りですがアクセス制限はCloudFrontからカスタムヘッダーを送信しNext.jsのミドルウェアで行います。

Next.jsのセットアップ

まずNext.jsのセットアップをします。

mkdir nextjs-lambda
npx create-next-app frontend
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … No
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes

Dockerfile作成

コンテナイメージのLambdaを構築するためfrontendディレクトリ直下にDockerfileを作成します。

Dockerfile
Dockerfile
FROM node:20.9.0-alpine3.17 AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY ./package*.json ./
RUN npm ci

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS prod
# Install Lambda Web Adapter
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.0 /lambda-adapter /opt/extensions/lambda-adapter
ENV PORT=3000
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

Standaloneの設定

Lambdaはデプロイパッケージファイルサイズが250MBのため、next.confing.jsを修正しStandaloneモードにしてビルド成果物のファイルサイズを削減します。

next.confing.js
/** @type {import('next').NextConfig} */
const nextConfig = {
    output: 'standalone' // 追加
}

module.exports = nextConfig

ミドルウェアの実装

frontend直下にCloudFront経由でアクセスされているか確認するミドルウェアを実装します。
ローカル検証中にカスタムヘッダーの検証で落ちると面倒なのでNODE_ENVがproductionの時のみ検証を行います。
またNext.jsのミドルウェアはサーバー上で動作しますのでLambdaで設定した環境変数と比較するようにしています。

middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const customHeader = request.headers.get('cf-secret-value');

  if (process.env.NODE_ENV === 'production') {
    if (customHeader === process.env.CF_SECRET_VALUE) {
      // ヘッダーが期待通りの場合、リクエストを通常通り処理
      return NextResponse.next();
    } else {
      // ヘッダーが期待通りでない場合、エラーまたはカスタム応答を返す
      return new Response('Unauthorized', {
        status: 401,
        statusText: 'Unauthorized',
      });
    }
  }
  return NextResponse.next();
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

CDKのセットアップ

次にCDKのセットアップを行います。

mkdir cdk && cd $_
cdk init --language typescript

パラメーターストアへの登録

カスタムヘッダーで送信する値がわかればCloudFront経由以外からでもアクセスできてしまうため、パラメーターストアで登録しそれをCDK上で取得したいと思います。
なのでコンソールからパラメーターストアへの登録を行います。

Lambdaの構築

lib直下のcdk-stack.tsにコンテナイメージのLambdaの定義をしていきます。
DockerImageCode.fromImageAssetでfrontendのDockerfileを元にビルド及びECRにPushをしてくれます。

cdk-stack.ts
import { Construct } from 'constructs';
import { Duration, Stack, StackProps } from "aws-cdk-lib";
import { DockerImageCode, DockerImageFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
import { Repository } from "aws-cdk-lib/aws-ecr";

export class CdkStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    
    // パラメーターストアからカスタムヘッダーの値を取得
    const cfSecretValue = StringParameter.fromStringParameterAttributes(this, 'CFSecretValue', {
      parameterName: 'cf-secret-value',
    }).stringValue;
    
    const nextjsRenderer = new DockerImageFunction(this, 'NextjsRenderer', {
      code: DockerImageCode.fromImageAsset('../frontend'),
      memorySize: 512,
      timeout: Duration.seconds(30),
      environment: {
        CF_SECRET_VALUE: cfSecretValue,
      },
    });
    nextjsRenderer.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE,
    });
  }
}

次に一旦CDKデプロイを行います。
この後作るCloudFrontのオリジンにFunctionUrlを指定しないといけないのですが、私が試した限りではhttps://〜しか取得できませんできずデプロイ時にバリデーションエラーを起こしてしました。(何か回避策知っている方がいれば教えていただければ助かります)

cdk deploy

デプロイが成功したら下記画像の箇所をコピーしておいてください。

CloudFrontの構築

次にCloudFrontを定義していきます。
FunctionUrlはhttps://及び末尾の/は削除してください。

cdk-stack.ts
    nextjsRenderer.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE,
    });

    // Distributionの構築
    new Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: new HttpOrigin('ここにFunctionUrlを記載', {
          customHeaders: {
            'cf-secret-value': cfSecretValue
          }
        }),
        allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
        cachePolicy: CachePolicy.CACHING_DISABLED,
        originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
    });
  }
}

最後にデプロイを行います。

cdk deploy

検証

まずCloudFrontのコンソールのディストリビューションドメイン名をコピーしURLにアクセスしてみましょう。

無事成功したようです。

次にLambdaのコンソールからURLにアクセスしてみましょう。


こちらもUnauthorizedと表示されているので成功です。

まとめ

今回はNext.jsをCloudFront+Lambdaでサーバーレス化しカスタムヘッダーでFunctionURLsへの直アクセスを制限してみました。
私自身AmplifyConsoleだとWAF等がつけづらくECSやAppRunnerだと少しコストが気になることもあったので、なかなか面白い選択肢だと思いました。
機会があればSSR StreamingやCloudFrontのキャッシュ周りも試してみたいと思います。

参考

https://aws.amazon.com/jp/blogs/news/implementing-ssr-streaming-on-nextjs-with-aws-lambda-response-streaming/

O-KUN Tech Blog

Discussion

rikutorikuto

Next.js使うタイミングでまた聞きかせてください。