Next.jsをCloudFront+Lambdaでサーバーレス化しカスタムヘッダーでFunctionURLsへの直アクセスを制限してみた
はじめに
みなさま、はじめまして。
こちらの記事を読んでいてCDKでCloudFront+Lambda(DockerImage)を組んで遊んでみたいと思い記事にしてみました。(SSR 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
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 /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS prod
# Install Lambda Web Adapter
COPY /lambda-adapter /opt/extensions/lambda-adapter
ENV PORT=3000
WORKDIR /app
ENV NODE_ENV production
COPY /app/next.config.js ./
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
Standaloneの設定
Lambdaはデプロイパッケージファイルサイズが250MBのため、next.confing.jsを修正しStandaloneモードにしてビルド成果物のファイルサイズを削減します。
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone' // 追加
}
module.exports = nextConfig
ミドルウェアの実装
frontend直下にCloudFront経由でアクセスされているか確認するミドルウェアを実装します。
ローカル検証中にカスタムヘッダーの検証で落ちると面倒なのでNODE_ENVがproductionの時のみ検証を行います。
またNext.jsのミドルウェアはサーバー上で動作しますのでLambdaで設定した環境変数と比較するようにしています。
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をしてくれます。
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://及び末尾の/は削除してください。
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のキャッシュ周りも試してみたいと思います。
参考
Discussion
Next.js使うタイミングでまた聞きかせてください。