この記事はCloudflare Advent Calendar 2024 5日目の記事です🎄
1. はじめに
前回の記事では、Cloudflare WorkersとWorkers AIを使って、AI画像生成アプリを構築しました。今回は、そのアプリのセキュリティを強化していきます。
1.1 アプリのセキュリティリスクを把握しよう
前回のアプリは基本的な機能を備えていますが、以下のセキュリティリスクがあります。
- ボットによる不正利用:無制限にAPIを呼び出され、リソースが浪費される
- 誰でもAPIを利用可能:認証なしでアクセスできるため、不正アクセスのリスクがある
- CSRF(Cross Site Request Forgery)攻撃のリスク:悪意のあるサイトからのリクエストを受け入れる可能性がある
- XSS(Cross Site Scripting)攻撃のリスク:ユーザーの入力が適切に処理されていないため、スクリプトが実行される可能性がある
1.2 セキュリティリスクに対する戦略立案
アプリに潜むセキュリティリスクを把握し、それに対応するための具体的な対策を理解します。
まずはセキュリティリスクの原因を整理してみましょう。
リスク | 原因 | 影響 |
---|---|---|
ボット利用 | CAPTCHA等によるボット対策がない | リソース浪費 |
誰でも利用可能 | 認証がない | 不正アクセスや悪用 |
CSRF攻撃 | 外部サイトからのリクエストを許可している | 認証済み状態の悪用 |
XSS攻撃 | ユーザー入力を適切に処理していない | 悪意あるスクリプトの実行 |
これらのリスクを軽減するため、適切なセキュリティ対策を導入していきます。
リスク | 対策 | 実装内容 |
---|---|---|
ボット利用 | Cloudflare Turnstileで人間のみアクセス可能に | ユーザーのアクセス時にTurnstileで検証 |
誰でも利用可能 | JWT(JSON Web Token)によるセッション管理 | 人間の場合セッションを開始 |
CSRF攻撃 | OriginヘッダーとFetch Metadata検証 | リクエスト元が正当か確認 |
XSS攻撃 | 入力の無害化、CSP(Content Security Policy)で外部リソース読み込み制限 | 不正スクリプトの挿入を防止 |
この記事を読むことで、それぞれのセキュリティ技術がどう連携させてアプリ全体を保護するかについても理解を深められます。
1.3 開発環境の整備
前回の記事を参考にしながら、Cloudflareの設定と、ローカル開発環境の準備をしてください。
ソースコードはGitHubで公開しています。以下のコマンドでcloneしてください。
git clone https://github.com/takatama/cf-workers-ai-image-handson-basic.git
主なファイル構成は次の通りです。/public
ディレクトリー配下の静的コンテンツはStatic Assetsで公開し、無料で配信しています(Workersの呼び出し回数にはカウントされません)。
/cf-workers-ai-image-handson-basic
├── public # Static Assetsとして公開するフロントエンド
│ ├── index.html
│ └── script.js
├── src # Workersで動作するバックエンド
│ └── index.js
├── .gitignore
└── wrangler.toml # Workersの設定ファイル
開発用サーバーを起動して、動作を確認できます。
npx wrangler dev
何を描きたいか日本語で入力し、[英語に翻訳] ボタンを押すと、画像生成用の英語プロンプトが出力されます。さらに [画像を生成] ボタンを押して数秒待つと画像が生成されます。
次の章から段階的にセキュリティを強化していきます。
2. ボット対策:Cloudflare Turnstileの導入
この章では、Cloudflare Turnstileを利用したボット対策を実装することで、不正なアクセスを防ぐ方法を学べます。
2.1 Cloudflare Turnstileとは
Cloudflare Turnstileは、ユーザー体験を損なうことなくボットをブロックする無料のボット対策ソリューションです。
従来のCAPTCHAのようにユーザーに負担をかけずに、人間とボットを区別できます。
2.2 Turnstileのセットアップ
2.2.1 Cloudflareダッシュボードでの作業
- Cloudflareアカウントにログインし、ダッシュボードを開きます
- 左側のメニューから「Turnstile」を選択します
- 「Add Widget」ボタンをクリックし、以下の情報を入力します
-
Widget Name:任意の名前(例:
AI Image Handson
) -
Hostname Management:アプリのホスト名を追加
ai-image-handson.<あなたのサブドメイン>.workers.dev
localhost
-
Widget Mode:
Managed
を選択 -
pre-clearance:
No
を選択
-
Widget Name:任意の名前(例:
- 「Create」ボタンをクリックすると、Site KeyとSecret Keyが発行されます
Secret Keyは絶対に公開しないでください。
2.2.2 環境変数の設定
取得したSecret Keyは環境変数TURNSTILE_SECRET_KEY
として管理することにします。
- ローカル開発用の環境変数は、
.dev.vars
ファイルに記述します - 本番用の環境変数は、Cloudflareダッシュボードで設定します
今回はローカル開発環境で動作確認を進め、最後にまとめてデプロイします。
ローカル開発用の環境変数を設定する
プロジェクトルートに.dev.vars
ファイルを追加し、Secret Keyを設定します。
TURNSTILE_SECRET_KEY = "あなたのSecret Key"
.dev.vars
ファイルはGitで管理しないように.gitignore
に追加します。
Wranglerコマンドで生成した.gitignore
には追記済です。
2.3 フロントエンドでTurnstileと連携
Turnstileのロジックをフロントエンドに実装します。
public/turnstile.js
を新規作成します。SITE_KEY
はTurnstileのSite Keyです。忘れずに書き換えてください。
//あなたのSite Keyに書き換えてください
const SITE_KEY = 'あなたのSite Key';
function onLoadTurnstile() {
turnstile.render('#turnstile-widget', {
sitekey: SITE_KEY,
callback: onTurnstileSuccess,
'error-callback': onTurnstileError,
'expired-callback': onTurnstileExpired,
});
}
async function onTurnstileSuccess(token) {
const formData = new FormData();
formData.append('cf-turnstile-response', token);
const response = await fetch('/auth', {
method: 'POST',
body: formData,
});
if (response.ok) {
translateBtn.disabled = false;
generateBtn.disabled = false;
} else {
alert('認証に失敗しました。再度お試しください。');
}
}
function onTurnstileError() {
alert('Turnstileエラーが発生しました。再度お試しください。');
}
function onTurnstileExpired() {
translateBtn.disabled = true;
generateBtn.disabled = true;
}
2.4 フロントエンドにTurnstileウィジェットを追加
フロントエンドにCloudflare Turnstileウィジェットを組み込み、ユーザー認証の基盤を構築します。
public/index.html
にTurnstileウィジェットを追加し、public/turnstile.js
を読み込みます。合計で3行分を追加します。
<head>
...
<!-- 追加 -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onLoadTurnstile" defer></script>
<script src="script.js" defer></script>
<!-- 追加 -->
<script src="turnstile.js" defer></script>
</head>
<body>
<!-- 追加 -->
<div id="turnstile-widget"></div>
...
</body>
ボタンは無効化しておきます。
...
<button id="translate-btn" onclick="translateText()" disabled>英語に翻訳</button>
...
<button id="generate-btn" onclick="generateImage()" disabled>画像を生成</button>
2.5 バックエンドでTurnstileを検証
バックエンドでTurnstileから送信されるトークンを検証し、ボット対策を強化します。
src/index.js
に認証エンドポイントを追加します。
// 追加
import { verifyTurnstileToken } from './turnstile.js';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/") {
// 省略
}
// 追加
if (request.method === "POST" && url.pathname === "/auth") {
const formData = await request.formData();
const token = formData.get("cf-turnstile-response");
const ip = request.headers.get('CF-Connecting-IP');
const isValid = await verifyTurnstileToken(token, env.TURNSTILE_SECRET_KEY, ip);
if (isValid) {
const response = new Response("Authenticated", { status: 200 });
// JWTをセット(後述)
return response;
}
return new Response("Unauthorized", { status: 401 });
}
return env.ASSETS.fetch(request);
},
};
src/turnstile.js
ファイルを新規作成します。
export async function verifyTurnstileToken(token, secretKey, ip) {
const formData = new FormData();
formData.append('response', token);
formData.append('secret', secretKey);
formData.append('remoteip', ip);
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const data = await response.json();
console.log(data);
return data.success;
}
2.6 ローカルでTurnstileの動作を確認しよう
開発用サーバーを起動して、動作を確認します。
npx wrangler dev
期待される結果
- ブラウザで
http://localhost:8787
にアクセスします - ページにTurnstileウィジェットが表示されます
- ウィジェットで認証に成功すると、「画像を生成」や「英語に翻訳」ボタンが有効になります
- 認証に失敗した場合、エラーメッセージが表示されます
Turnstileの認証が正常に動作した際の表示例です。
3. セッション管理:JWTの導入
この章では、Cloudflare Turnstileでの認証後、JWT(JSON Web Token) を活用して認証済みセッションを管理する方法を学びます。有効なセッションにのみAPIへのアクセスを許可することで、安全性を向上します。
3.1 JWTとは:安全なセッション管理の基礎
JWTはユーザーの認証情報を安全に保持するためのトークン形式です。以下の特徴があります:
- 軽量: クライアントとサーバー間で情報をやり取りする際の負荷が少ない
- 署名付き: トークンが改ざんされていないかどうかをサーバー側で確認可能
- 有効期限の設定: セッションの寿命を制限できるため、セキュリティを向上させる
JWTを使用することで、APIへのアクセスが安全で効率的になります。
3.2 Turnstile認証後にJWTを使ったセッションを開始
前の章で、Cloudflare Turnstileを使ってユーザーが人間であることを証明する仕組みを実装しました。しかし、この仕組みをそのままAPIに流用するのは不適切です。以下の課題があります:
- Turnstile認証の負担: APIリクエストごとにTurnstileを再認証するのは、ユーザー体験を損ねます
- 認証情報の短期間の利用: Turnstileのトークンには短い有効期限があり、認証の度にバックエンドがTurnstileと通信する必要があります
これを解決するために、Turnstile認証が成功した後、JWTを生成してセッションを開始します。JWTは以下の仕組みで動作します:
- Turnstileトークンをバックエンドで検証し、人間であることが確認された場合にJWTを生成します
- JWTをユーザーに返し、以後のAPIリクエストはJWTで認証を行います
- JWTの有効期限はTurnstileの有効期限より長く設定します
- Turnstileの有効期限が切れるとJWTを再発行します
- もし、JWTの有効期限が切れていた場合、再度Turnstile認証を要求します
この仕組みにより、認証の頻度を減らし、セキュリティと効率性のバランスを保てます。
3.2.1 JWT認証フロー(シーケンス図)
Turnstile認証からJWTを利用したAPIアクセスまでの流れを図で示します。
3.2.2 フローの説明
-
ユーザーがアプリにアクセス
(1) ユーザーがアプリにアクセスすると、(2) Cloudflare Turnstileウィジェットが表示されます。 -
Turnstile認証
(3) ユーザーがウィジェットを操作し、人間であることを証明します。(4) Turnstileは成功トークンをフロントエンドに返します。 -
バックエンドでトークンを検証
(5) フロントエンドは、Turnstile成功トークンをバックエンドに送信します。(6) バックエンドは、TurnstileのAPIにリクエストを送り、トークンが有効かどうかを確認します。 -
JWTを生成
(7) Turnstileトークンの検証が成功した場合、(8) バックエンドでJWTを生成します。(9) 生成されたJWTはクッキーやレスポンスを通じてフロントエンドに送信されます。 -
JWT付きリクエスト
(10) ユーザーがフロントエンドを操作し、APIを利用します。(11) フロントエンドは、リクエストにJWTを付加して保護されたAPIにアクセスします。 -
JWT検証
(12) APIは、JWTをバックエンドに送信して検証します。(13) 検証が成功すればリソースを提供し、失敗すれば401エラーを返します。
このフローにより、ユーザーはTurnstile認証を1度行うだけで、以降のAPIリクエストがシームレスに動作します。APIはJWTを通じて認証されたアクセスのみを許可し、セキュリティを確保します。また、JWTの有効期限を適切に設定することで、セッションの管理を効率化できます。
3.3 バックエンドでJWTの認証ロジックを実装
JWTはクッキーに保存します。安全に保存できるよう、注意深く実装していきます。
3.3.1 ライブラリーのインストール
JWTの生成や検証には軽量で依存関係の少ないライブラリー jose を使います。
npm install jose
3.3.2 セッション管理用のユーティリティ作成
src/session.js
を新規作成します。
import { SignJWT, jwtVerify } from 'jose';
const COOKIE_NAME = 'session';
const COOKIE_OPTIONS = {
httpOnly: true, // クッキーをJavaScriptからアクセス不可にする
secure: true, // HTTPS通信のみで送信可能にする
sameSite: "Strict", // クロスサイトのリクエストでは送信しない
maxAge: 360, // Turnstileの有効期限(5分)より、少しだけ長くする(6分)
path: "/",
};
export async function createSessionCookie(value, sessionSecret) {
// JWTを生成し、有効期限を設定
const token = await new SignJWT(value)
.setProtectedHeader({ alg: "HS256" })
// Turnstileの有効期限(5分)より、少しだけ長くする(6分)
.setExpirationTime("6m")
.sign(sessionSecret);
// クッキーの属性を設定
const cookieAttributes = [
`Path=${COOKIE_OPTIONS.path}`,
`HttpOnly=${COOKIE_OPTIONS.httpOnly ? "true" : ""}`,
`Secure=${COOKIE_OPTIONS.secure ? "true" : ""}`,
`SameSite=${COOKIE_OPTIONS.sameSite}`,
`Max-Age=${COOKIE_OPTIONS.maxAge}`,
]
.filter(Boolean)
.join("; ");
return `${COOKIE_NAME}=${token}; ${cookieAttributes}`;
}
async function validateSessionCookie(request, sessionSecret) {
const cookie = request.headers.get("Cookie");
if (!cookie) return false;
const match = cookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
if (!match) return false;
const token = match[1];
try {
const { payload } = await jwtVerify(token, sessionSecret);
return payload.authenticated === true;
} catch (e) {
console.error('jwtVerify', e);
return false;
}
}
export async function verifySession(request, env) {
const sessionSecret = new TextEncoder().encode(env.JWT_SECRET);
return await validateSessionCookie(request, sessionSecret);
}
3.3.3 認証後にJWTを発行
src/index.js
の認証部分を修正します。
// 追加
import { createSessionCookie, verifySession } from './session.js';
export default {
async fetch(request, env, ctx) {
// 前略
if (request.method === "POST" && url.pathname === "/auth") {
// 前略
if (isValid) {
const response = new Response("Authenticated", { status: 200 });
// JWTをセット(後述)
const secret = new TextEncoder().encode(env.JWT_SECRET);
const cookie = await createSessionCookie({ authenticated: true }, secret);
response.headers.append('Set-Cookie', cookie);
return response;
} else {
return new Response("Unauthorized", { status: 401 });
}
}
// 後略
},
};
3.3.4 各APIでJWTを検証
src/index.js
の各APIエンドポイントでセッションを検証します。
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/") {
// 省略
}
if (request.method === "POST" && url.pathname === "/translate") {
const isValidSession = await verifySession(request, env);
if (!isValidSession) return new Response("Unauthorized", { status: 401 });
// 翻訳処理
} else if (request.method === "POST" && url.pathname === "/generate-image") {
const isValidSession = await verifySession(request, env);
if (!isValidSession) return new Response("Unauthorized", { status: 401 });
// 画像生成処理
}
// 後略
},
};
3.3.5 JWTシークレットキーの設定
JWTシークレットキーは、ある一定以上の長さ(32文字以上)で、ランダムな文字列が望まれます。
ターミナルでNode.jsを実行して、64文字のランダムな文字列を作成しJWTシークレットキーとして使います。
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWTシークレットキーは絶対に公開しないでください。
ターミナルに表示JWTシークレットキーを、.dev.vars
に環境変数JWT_SECRET
として追加します。
JWT_SECRET="あなたのJWTシークレットキー"
3.4 フロントエンドでのJWTセッション切れ対応
セッションが無効な場合、再度Turnstile認証を要求します。src/index.html
のscriptを修正します。
まずverifyResponse()
を追加します。
function verifyResponse(response) {
if (response.ok) return;
if (response.status === 401) {
onTurnstileExpired();
throw new Error('セッションが無効です。再度認証してください。');
}
throw new Error(`エラー ${response.status} ${response.statusText}`);
}
次にtranslateText()
とgenerateImage()
の内でverifyResponse()
を使用します。
async function translateText() {
...
// if (!response.ok) {
// throw new Error(`エラー ${response.status} ${response.statusText}`);
// }
verifyResponse(response);
...
}
async function generateImage() {
...
// if (!response.ok) {
// throw new Error(`エラー ${response.status} ${response.statusText}`);
// }
verifyResponse(response);
...
}
3.5 JWTの有効性をローカル環境で確認
ローカル環境でJWTの有効性を確認し、セッション切れや再認証の動作が正しく実装されていることを確認します。
npx wrangler dev
ブラウザで http://localhost:8787
にアクセスして、先ほどと同様に使えることを確認しましょう。
その後で、セッションをわざと無効化した時に、APIが利用できなくなることを確かめていきます。
セッションを無効化するために2つの方法をそれぞれ試します。
- セッション管理用のクッキーを削除する
- JWTの有効期限を短くする
3.5.1 セッション管理用のクッキーを削除する
セッション
手順
- ブラウザの開発者ツールを開き、「Application」タブでStorage > Cookies >
http://localhost:8787
を確認します -
session
という名前のCookieを削除します - 再度「英語に翻訳」または「画像を生成」を実行してAPIを呼び出します
期待される結果
- セッションが無効なため、APIリクエストが失敗し、401エラーが返されます
- 401エラーに対して、自動的にTurnstileウィジェットが再認証します
- 再認証が成功するとクッキーが復活します。再度「英語に翻訳」または「画像を生成」を実行しても成功します
3.5.2 JWTの有効期限を短くする
手順
- ブラウザの開発者ツールを開き、すでに払い出された
session
クッキーを消しておきます -
src/session.js
でJWTの有効期限を変更します.setExpirationTime("30s") // 有効期限を6分から30秒に短縮
- アプリを起動します。Turnstileの再認証が成功すると、短い有効期限のJWTが払い出されます
- 30秒以上待ちます
- 再度「英語に翻訳」または「画像を生成」を実行してAPIを呼び出します
期待結果
- JWTの有効期限が切れているので、401エラーが返されます
- 401エラーに対して、自動的にTurnstileウィジェットが再認証します
- 再認証してもJWTの有効期限は30秒のままなので、それ以上経つとAPI呼び出しが再び失敗します
3.5.3 動作確認が終わったら
src/session.js
でJWTの有効期限を戻しておきましょう
.setExpirationTime("6m") // 有効期限を6分に戻す
4. CSRF対策:リクエストの検証
この章では、OriginヘッダーやFetch Metadataを利用してCSRF攻撃を防ぐ方法を学べます。
4.1 CSRF攻撃とは
CSRF(Cross-Site Request Forgery)は、ユーザーが意図しないリクエストを認証済のウェブサイトに対して送信させられる攻撃手法です。攻撃者は、ユーザーがログインしている状態を悪用し、不正な操作(データの変更、購入、送金など)を実行させます。
次の記事では、CSRF攻撃が成立する条件とその対策を端的にまとめてくださっています。
副作用のあるバックエンドを守る4つのポイントのうち、
- POST にする(副作用のある API を GET にしないの意)
- SameSite Lax/Strict を明示する
については対策済です。残った2つを実装していきます:
- Origin を確認する
- Fetch Metadata も確認する
4.2 OriginヘッダーとFetch Metadataを利用したリクエストの検証
src/origin.js
を新規作成します。
export function verifyOrigin(request, allowedOrigin) {
const origin = request.headers.get('Origin');
const secFetchSite = request.headers.get('Sec-Fetch-Site');
if (origin !== allowedOrigin) {
return false;
}
if (secFetchSite && secFetchSite !== 'same-origin') {
return false;
}
return true;
}
src/index.js
でPOSTメソッドを受信したときに検証します。
// 追加
import { verifyOrigin } from './origin.js';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/") {
return new Response(html, {
headers: {
"content-type": "text/html;charset=UTF-8",
},
});
}
// 追加
const allowedOrigin = env.ALLOWED_ORIGIN;
if (request.method === "POST" && !verifyOrigin(request, allowedOrigin)) {
return new Response("Bad Request", { status: 400 });
}
// 省略
},
};
.dev.vars
に許可されたOriginを追加します。
ALLOWED_ORIGIN = "http://localhost:8787"
ALLOWED_ORIGIN
に設定するURLは最後にスラッシュ(/)を記載せず、http://localhost:8787
にしてください。
4.3 CSRF対策の効果をローカル環境で確認
npx wrangler dev
ブラウザで http://localhost:8787
にアクセスして、先ほどと同様に使えることを確認しましょう。
その後で、許可しているOriginをわざと変更した時に、APIが利用できなくなることを確かめていきます。
4.3.1 不正なOriginからのアクセス確認
許可していないOriginヘッダーを持つリクエストを受け付けないことを確認します。
ブラウザー側の操作ではOriginヘッダーの値を変更できません。
そこで、環境変数で指定するALLOWED_ORIGINの値をわざとhttp://localhost:8788
以外に変更して、ブラウザーからのアクセスを受け付けないことを確認します。
手順
-
.dev.vars
のALLOWED_ORIGIN
をわざと異なったものに変更します(例:http://localhost:8888
) - 開発用サーバーを再起動します(環境変数の変更を反映させるために再起動が必要です)
- ブラウザーでアプリにアクセスします
期待結果
- Turnstile認証の段階で、サーバー側で403エラーが返される
- 開発者ツールの「Network」タブでエラーが確認できる
4.3.2 動作確認が終わったら
.dev.vars
のALLOWED_ORIGIN
をhttp://localhost:8787
に戻しておきましょう。
5. XSS対策
CSRF対策にはXSS対策も重要です。この章では、XSS攻撃を防ぐためのサニタイズ処理とCSPの基本設定を学べます。
5.1 XSS攻撃とは
XSSとは、悪意のあるスクリプト(主にJavaScript)が、信頼されたウェブサイトで実行されるセキュリティ脆弱性です。攻撃者は、ウェブアプリケーションに不正なコードを挿入し、それを利用者のブラウザで実行させます。
XSS脆弱性があると、CSRF対策を回避されるリスクがあるため、両方の対策が重要です。
ただ、今回のアプリは、ユーザーが入力した情報をWeb画面として表示するユースケースがありません。具体的な対策は取らず、方針だけ紹介します。
5.2 XSSの主な対策
- HTTPOnlyクッキーの使用:
- クッキーに
HttpOnly
属性を付け、JavaScriptによるクッキーの盗難を防ぎます
- クッキーに
- 入力値のエスケープ:
- HTML、JavaScript、CSSなどに出力するデータを適切にエスケープします
- 例:
<
→<
、>
→>
- サニタイズ:
- ユーザー入力から不要なコードや特殊文字を除去し無害化します
- CSP(Content Security Policy)の導入:
- 実行可能なスクリプトのソースを制限し、外部の悪意あるスクリプトを防止します
すでにHTTPOnlyクッキーは導入済です。入力値のエスケープは画面に情報を表示するときに使うため、今回のアプリの対象外です。
サニタイズとCSPについて紹介します。
5.3 ユーザー入力をサニタイズして安全性を確保
ユーザー入力をそのまま使用せずサニタイズ(無害化)することで、アプリケーションをスクリプトインジェクション攻撃から守る方法を学びます。
例えば、ユーザーが<script>
タグを含む入力を送信すると、アプリケーションがそのスクリプトをそのまま実行してしまうリスクがあります。以下の処理を追加することで、悪意ある入力を安全な形式に変換できます。
src/sanitize.js
を新規作成します。
// 最大2048文字に制限
const MAX_LENGTH = 2048;
export function sanitize(input) {
const sanitized = input
// 不要な特殊文字を削除し、安全な文字列に変換
.replace(/[^\p{L}\p{N}\s,.!?'"-]/gu, "")
.slice(0, MAX_LENGTH);
return sanitized;
}
src/index.js
でユーザー入力をサニタイズします。
// 追加
import { sanitize } from './sanitize.js';
if (request.method === "POST" && url.pathname === "/translate") {
const formData = await request.formData();
// const prompt = formData.get("prompt");
const prompt = sanitize(formData.get("prompt"));
// 翻訳処理
}
// 画像生成処理など、入力を受け付けるAPIすべてに導入する
5.4 CSPで外部リソースを制限しXSS攻撃をブロック
CSP(Content Security Policy)は、Webページが読み込むリソースを制限するセキュリティ機能です。
導入の際には、次の手順が必要です。
5.4.1 HTML / CSS / JavaScriptの分離
HTMLに直接記述されたJavaScriptをインラインスクリプトといいます。インラインスクリプトを許可していると、攻撃者が悪意のあるスクリプトをHTML内に埋め込むことを防ぐことが難しくなり、XSS攻撃のリスクが高まります。
そこで、CSPでは、デフォルトでインラインスクリプトを禁止し、外部ファイルでHTML、CSS、JavaScriptを分離することが推奨されます。この方法により、コード管理が容易になり、セキュリティとパフォーマンスが向上します。
なお、インラインスクリプトは<script>
タグ内に直接記述されたJavaScriptコードだけでなく、HTML要素のonclick
やonload
などの属性で指定されたコードも含まれます。徹底的に分離する必要があります。
HTMLに直接記述されたCSSはインラインスタイルと呼びます。インラインスクリプトほど危険性はありませんが、攻撃者が視覚的な操作をする可能性もあるため、分離しておきます。
分離したindex.html
、script.js
、style.css
は/public
ディレクトリー配下に配置します。
5.4.2 CSPの追加
CSPをHTMLの<meta>
タグ、もしくは、レスポンスヘッダーContent-Security-Policy
に追加します。
Static Assetsを使うとWorkersを起動しないため、レスポンスヘッダーを付与することができません。
今回のアプリの場合、HTMLには次のようなCSPを記述します。
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' blob:"
/>
CSPの設定内容
-
default-src 'self'
- すべてのリソース(スクリプト、スタイルシート、画像、フレームなど)をデフォルトで現在のオリジン(
self
)からのみ読み込む
- すべてのリソース(スクリプト、スタイルシート、画像、フレームなど)をデフォルトで現在のオリジン(
-
script-src 'self' https://challenges.cloudflare.com
- JavaScriptスクリプトは現在のオリジン(
self
)およびhttps://challenges.cloudflare.com
(Turnstileウィジェットの配信元)からのみ読み込みを許可
- JavaScriptスクリプトは現在のオリジン(
-
frame-src https://challenges.cloudflare.com
- フレームやiframeは
https://challenges.cloudflare.com
からのみ許可
- フレームやiframeは
-
style-src 'self' https://cdn.jsdelivr.net
- CSSスタイルシートは現在のオリジン(
self
)およびhttps://cdn.jsdelivr.net
(new.CSSの配信元)からのみ許可
- CSSスタイルシートは現在のオリジン(
-
img-src 'self' blob:
- 画像は現在のオリジン(
self
)およびデータURI(blob:
)からのみ読み込みを許可
- 画像は現在のオリジン(
この設定により、必要な外部リソースを適切に許可しつつ、それ以外のリソースの読み込みを制限することでセキュリティを強化できます。
動的に生成されるページで、インラインスクリプトを安全に実行したい場合にnonce
(一時的な値)を使用します。
Turnstileもnonce
の使用を推奨しており、script-set
とframe-set
を指定する方法は代替策としています。
https://developers.cloudflare.com/turnstile/reference/content-security-policy/
6. デプロイ
お疲れさまでした!セキュリティを強化し、ローカルでの動作が確認できました。
6.1 本番環境用の環境変数を設定
Cloudflareダッシュボードにログインします。
左メニュー「Workers & Pages」でai-image-handson
プロジェクトを選択し、Settings > Variables and Secretsで環境変数を追加します。
秘密にしておくTURNSTILE_SECRET_KEY
とJWT_SECRET
は、必ずType: Secret
を選んでください。それぞれ.dev.vars
に定義されたものを入力します。
ALLOWED_ORIGIN
はType: Plaintext
でOKです。デプロイ後のURLを設定します。https://ai-image-handson.<あなたのサブドメイン名>.workers.dev
を入力します。
ALLOWED_ORIGIN
に設定するURLは最後にスラッシュ(/)を記載せず、https://ai-image-handson.<あなたのサブドメイン名>.workers.dev
にしてください。
なお、PlaintextでもOKなALLOWED_ORIGIN
は、wrangler.tomlファイルで定義しても大丈夫です。
[vars]
ALLOWED_ORIGIN = "https://ai-image-handson.<あなたのサブドメイン名>.workers.dev"
6.2 アプリのデプロイ手順
いよいよ世界に向けて公開する瞬間です!
npx wrangler deploy
6.3 公開後の動作確認
本番環境でセキュリティ強化版アプリが正しく動作することを最終確認しましょう。
ブラウザーでhttps://ai-image-handson.<あなたのサブドメイン>.workers.dev
にアクセスして、動作を確認してください。
7. まとめ
セキュリティを強化したコードもGitHubで公開しています。リポジトリには、この記事で紹介したすべてのコードと、設定ファイルが含まれています。
今回学んだセキュリティ強化の技術
- Cloudflare Turnstileを使ったボット対策の実装
- JWTを用いたセッション管理
- リクエスト検証によるCSRF対策
- サニタイズとCSP設定によるXSS対策
セキュリティはアプリケーション開発において非常に重要な要素です。今回のハンズオンを通じて、セキュリティを考慮した開発手法を学び、実践することができました。この記事で紹介したセキュリティ対策は、他のWebアプリケーションやAPIにも応用可能です。
最後まで読んでいただき、ありがとうございました!Cloudflare WorkersやWorkers AIの魅力が少しでも伝われば幸いです。