Cloudflare Workers × Turnstile × JWT : AIアプリを安全にするハンズオン

この記事は[Cloudflare Advent Calendar 2024](https://qiita.com/advent-calendar/2024/cloudflare) **5日目**の記事です🎄

# 1. はじめに

[前回の記事](https://qiita.com/takatama/items/5efc76882bf674fa1c1b)では、**Cloudflare Workers**と**Workers AI**を使って、AI画像生成アプリを構築しました。今回は、そのアプリの**セキュリティを強化**していきます。

<img alt="Turnstileを使って人間であることを確認" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/20a77525-96e7-ceb6-f48d-2391d3b2867c.png" width="400">

## 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 開発環境の整備

[前回の記事](https://qiita.com/takatama/items/5efc76882bf674fa1c1b#21-%E4%BA%8B%E5%89%8D%E6%BA%96%E5%82%99)を参考にしながら、Cloudflareの設定と、ローカル開発環境の準備をしてください。

ソースコードは[GitHub](https://github.com/takatama/cf-workers-ai-image-handson-basic)で公開しています。以下のコマンドでcloneしてください。

```bash
git clone https://github.com/takatama/cf-workers-ai-image-handson-basic.git
```

主なファイル構成は次の通りです。`/public`ディレクトリー配下の静的コンテンツは[Static Assets](https://developers.cloudflare.com/workers/static-assets/
)で公開し、無料で配信しています（Workersの呼び出し回数にはカウントされません）。

```bash
/cf-workers-ai-image-handson-basic
├── public         # Static Assetsとして公開するフロントエンド
│   ├── index.html
│   └── script.js
├── src            # Workersで動作するバックエンド
│   └── index.js
├── .gitignore
└── wrangler.toml  # Workersの設定ファイル
```

開発用サーバーを起動して、動作を確認できます。

```bash
npx wrangler dev
```

何を描きたいか日本語で入力し、[英語に翻訳] ボタンを押すと、画像生成用の英語プロンプトが出力されます。さらに [画像を生成] ボタンを押して数秒待つと画像が生成されます。

次の章から段階的にセキュリティを強化していきます。

# 2. ボット対策：Cloudflare Turnstileの導入

この章では、Cloudflare Turnstileを利用したボット対策を実装することで、不正なアクセスを防ぐ方法を学べます。

## 2.1 Cloudflare Turnstileとは

Cloudflare Turnstileは、ユーザー体験を損なうことなくボットをブロックする無料のボット対策ソリューションです。

<img alt="turnstile-check" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/ac8d7ac8-9bca-40b4-4af2-69697ba4904b.png" width="200"><br />

<img alt="turnstile-verify" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/d5024c95-970f-6847-4a1e-71eac650d1d4.png" width="200"><br />

<img alt="turnstile-ok" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/91bb7d56-1ae9-ba31-3ed5-743b72cf4463.png" width="200"><br/>

従来のCAPTCHAのようにユーザーに負担をかけずに、人間とボットを区別できます。

## 2.2 Turnstileのセットアップ

### 2.2.1 Cloudflareダッシュボードでの作業

1. **Cloudflareアカウントにログイン**し、ダッシュボードを開きます
2. 左側のメニューから「**Turnstile**」を選択します
3. 「**Add Widget**」ボタンをクリックし、以下の情報を入力します
   - **Widget Name**：任意の名前（例：`AI Image Handson`）
   - **Hostname Management**：アプリのホスト名を追加
     - `ai-image-handson.<あなたのサブドメイン>.workers.dev`
     - `localhost`
   - **Widget Mode**：`Managed`を選択
   - **pre-clearance**：`No`を選択
4. 「**Create**」ボタンをクリックすると、**Site Key**と**Secret Key**が発行されます

:::note alert
Secret Keyは絶対に公開しないでください。
:::

### 2.2.2 環境変数の設定

取得したSecret Keyは環境変数`TURNSTILE_SECRET_KEY`として管理することにします。
- ローカル開発用の環境変数は、`.dev.vars`ファイルに記述します
- 本番用の環境変数は、Cloudflareダッシュボードで設定します

今回はローカル開発環境で動作確認を進め、最後にまとめてデプロイします。

**ローカル開発用の環境変数を設定する**

プロジェクトルートに`.dev.vars`ファイルを追加し、Secret Keyを設定します。

```bash:.dev.vars
TURNSTILE_SECRET_KEY = "あなたのSecret Key"
```
:::note info
`.dev.vars`ファイルはGitで管理しないように`.gitignore`に追加します。
Wranglerコマンドで生成した`.gitignore`には追記済です。
:::

## 2.3 フロントエンドでTurnstileと連携

Turnstileのロジックをフロントエンドに実装します。

`public/turnstile.js`を新規作成します。`SITE_KEY`はTurnstileのSite Keyです。忘れずに書き換えてください。

```javascript:public/turnstile.js
//あなたの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行分を追加します。

```html:public/index.html
<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>
```

ボタンは無効化しておきます。

```html:public/index.html
...
<button id="translate-btn" onclick="translateText()" disabled>英語に翻訳</button>
...
<button id="generate-btn" onclick="generateImage()" disabled>画像を生成</button>
```

## 2.5 バックエンドでTurnstileを検証

バックエンドでTurnstileから送信されるトークンを検証し、ボット対策を強化します。

`src/index.js`に認証エンドポイントを追加します。

```javascript: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`ファイルを新規作成します。

```javascript: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の動作を確認しよう

開発用サーバーを起動して、動作を確認します。

```bash
npx wrangler dev
```

**期待される結果**
1. ブラウザで `http://localhost:8787` にアクセスします
2. ページにTurnstileウィジェットが表示されます
3. ウィジェットで認証に成功すると、「画像を生成」や「英語に翻訳」ボタンが有効になります
4. 認証に失敗した場合、エラーメッセージが表示されます

Turnstileの認証が正常に動作した際の表示例です。

<img alt="Turnstileを使って人間であることを確認" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/20a77525-96e7-ceb6-f48d-2391d3b2867c.png" width="400">

# 3. セッション管理：JWTの導入

この章では、Cloudflare Turnstileでの認証後、**JWT（JSON Web Token）** を活用して認証済みセッションを管理する方法を学びます。有効なセッションにのみAPIへのアクセスを許可することで、安全性を向上します。

## 3.1 JWTとは：安全なセッション管理の基礎

JWTは**ユーザーの認証情報を安全に保持**するためのトークン形式です。以下の特徴があります：
- **軽量**: クライアントとサーバー間で情報をやり取りする際の負荷が少ない
- **署名付き**: トークンが改ざんされていないかどうかをサーバー側で確認可能
- **有効期限の設定**: セッションの寿命を制限できるため、セキュリティを向上させる

JWTを使用することで、APIへのアクセスが安全で効率的になります。

## 3.2 Turnstile認証後にJWTを使ったセッションを開始

前の章で、**Cloudflare Turnstile**を使ってユーザーが人間であることを証明する仕組みを実装しました。しかし、この仕組みをそのままAPIに流用するのは不適切です。以下の課題があります：

1. **Turnstile認証の負担**: APIリクエストごとにTurnstileを再認証するのは、ユーザー体験を損ねます
2. **認証情報の短期間の利用**: Turnstileのトークンには短い有効期限があり、認証の度にバックエンドがTurnstileと通信する必要があります

これを解決するために、Turnstile認証が成功した後、**JWTを生成**してセッションを開始します。JWTは以下の仕組みで動作します：
1. Turnstileトークンをバックエンドで検証し、人間であることが確認された場合にJWTを生成します
2. JWTをユーザーに返し、以後のAPIリクエストはJWTで認証を行います
3. JWTの有効期限はTurnstileの有効期限より長く設定します
   - Turnstileの有効期限が切れるとJWTを再発行します
   - もし、JWTの有効期限が切れていた場合、再度Turnstile認証を要求します

この仕組みにより、認証の頻度を減らし、セキュリティと効率性のバランスを保てます。

### 3.2.1 **JWT認証フロー（シーケンス図）**

Turnstile認証からJWTを利用したAPIアクセスまでの流れを図で示します。

```mermaid
sequenceDiagram
    participant User as ユーザー
    participant Frontend as フロントエンド
    participant Turnstile as Turnstile
    participant Backend as バックエンド
    participant API as 保護されたAPI

    User->>Frontend: 1. アプリにアクセス
    Frontend->>Turnstile: 2. ウィジェットを表示
    User->>Turnstile: 3. 人間であることを証明
    Turnstile-->>Frontend: 4. 成功トークン
    Frontend->>Backend: 5. 成功トークンを送信
    Backend->>Turnstile: 6. トークンの検証要求
    Turnstile-->>Backend: 7. 検証成功レスポンス
    Backend->>Backend: 8. JWTを生成
    Backend-->>Frontend: 9. JWTを返す（クッキーまたはレスポンス）
    User->>Frontend: 10. APIを利用（JWT付き）
    Frontend->>API: 11. JWT付きでAPIリクエスト
    API->>Backend: 12. JWTを検証
    Backend-->>API: 検証結果（成功/失敗）
    API-->>Frontend: リソースまたはエラーを返す
```

### 3.2.2 フローの説明

1. **ユーザーがアプリにアクセス**
(1) ユーザーがアプリにアクセスすると、(2) Cloudflare Turnstileウィジェットが表示されます。
2. **Turnstile認証**
(3) ユーザーがウィジェットを操作し、人間であることを証明します。(4) Turnstileは成功トークンをフロントエンドに返します。
3. **バックエンドでトークンを検証**
(5) フロントエンドは、Turnstile成功トークンをバックエンドに送信します。(6) バックエンドは、TurnstileのAPIにリクエストを送り、トークンが有効かどうかを確認します。
4. **JWTを生成**
(7) Turnstileトークンの検証が成功した場合、(8) バックエンドでJWTを生成します。(9) 生成されたJWTはクッキーやレスポンスを通じてフロントエンドに送信されます。
5. **JWT付きリクエスト**
(10) ユーザーがフロントエンドを操作し、APIを利用します。(11) フロントエンドは、リクエストにJWTを付加して保護されたAPIにアクセスします。
6. **JWT検証**
(12) APIは、JWTをバックエンドに送信して検証します。(13) 検証が成功すればリソースを提供し、失敗すれば401エラーを返します。

このフローにより、ユーザーは**Turnstile認証を1度行うだけで、以降のAPIリクエストがシームレスに動作**します。APIはJWTを通じて認証されたアクセスのみを許可し、セキュリティを確保します。また、JWTの有効期限を適切に設定することで、セッションの管理を効率化できます。

## 3.3 バックエンドでJWTの認証ロジックを実装

JWTはクッキーに保存します。安全に保存できるよう、注意深く実装していきます。

### 3.3.1 ライブラリーのインストール

JWTの生成や検証には軽量で依存関係の少ないライブラリー [jose](https://github.com/panva/jose) を使います。

```bash
npm install jose
```

### 3.3.2 セッション管理用のユーティリティ作成

`src/session.js`を新規作成します。

```javascript: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`の認証部分を修正します。

```javascript: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エンドポイントでセッションを検証します。

```javascript:src/index.js
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シークレットキーとして使います。

```bash
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
```

:::note alert
JWTシークレットキーは絶対に公開しないでください。
:::

ターミナルに表示JWTシークレットキーを、`.dev.vars`に環境変数`JWT_SECRET`として追加します。

```bash:.dev.vars
JWT_SECRET="あなたのJWTシークレットキー"
```

## 3.4 フロントエンドでのJWTセッション切れ対応

セッションが無効な場合、再度Turnstile認証を要求します。`src/index.html`のscriptを修正します。

まず`verifyResponse()`を追加します。

```javascript:src/index.html
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()`を使用します。

```javascript:src/index.html
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の有効性を確認し、セッション切れや再認証の動作が正しく実装されていることを確認します。

```bash
npx wrangler dev
```

ブラウザで `http://localhost:8787` にアクセスして、先ほどと同様に使えることを確認しましょう。

その後で、セッションをわざと無効化した時に、APIが**利用できなくなる**ことを確かめていきます。

セッションを無効化するために2つの方法をそれぞれ試します。
- セッション管理用のクッキーを削除する
- JWTの有効期限を短くする

### 3.5.1 セッション管理用のクッキーを削除する

セッション
**手順**

1. ブラウザの開発者ツールを開き、「Application」タブでStorage > Cookies > `http://localhost:8787` を確認します
2. `session`という名前のCookieを削除します
3. 再度「英語に翻訳」または「画像を生成」を実行してAPIを呼び出します

**期待される結果**

- セッションが無効なため、APIリクエストが失敗し、401エラーが返されます
- 401エラーに対して、自動的にTurnstileウィジェットが再認証します
- 再認証が成功するとクッキーが復活します。再度「英語に翻訳」または「画像を生成」を実行しても成功します

### 3.5.2 JWTの有効期限を短くする

**手順**

1. ブラウザの開発者ツールを開き、すでに払い出された`session`クッキーを消しておきます
2. `src/session.js`でJWTの有効期限を変更します
   ```javascript
   .setExpirationTime("30s") // 有効期限を6分から30秒に短縮
   ```
3. アプリを起動します。Turnstileの再認証が成功すると、短い有効期限のJWTが払い出されます
4. 30秒以上待ちます
5. 再度「英語に翻訳」または「画像を生成」を実行してAPIを呼び出します

**期待結果**

- JWTの有効期限が切れているので、401エラーが返されます
- 401エラーに対して、自動的にTurnstileウィジェットが再認証します
- 再認証してもJWTの有効期限は30秒のままなので、それ以上経つとAPI呼び出しが再び失敗します

### 3.5.3  動作確認が終わったら

`src/session.js`でJWTの有効期限を戻しておきましょう

```javascript
.setExpirationTime("6m") // 有効期限を6分に戻す
```

# 4. CSRF対策：リクエストの検証

この章では、OriginヘッダーやFetch Metadataを利用してCSRF攻撃を防ぐ方法を学べます。

## 4.1 CSRF攻撃とは

CSRF（Cross-Site Request Forgery）は、ユーザーが意図しないリクエストを認証済のウェブサイトに対して送信させられる攻撃手法です。攻撃者は、ユーザーがログインしている状態を悪用し、不正な操作（データの変更、購入、送金など）を実行させます。

次の記事では、CSRF攻撃が成立する条件とその対策を端的にまとめてくださっています。

https://blog.jxck.io/entries/2024-04-26/csrf.html

副作用のあるバックエンドを守る4つのポイントのうち、

- POST にする(副作用のある API を GET にしないの意)
- SameSite Lax/Strict を明示する

については対策済です。残った2つを実装していきます：

- Origin を確認する
- Fetch Metadata も確認する

## 4.2 OriginヘッダーとFetch Metadataを利用したリクエストの検証

`src/origin.js`を新規作成します。

```javascript: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メソッドを受信したときに検証します。

```javascript:src/index.js
// 追加
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を追加します。

```bash:.dev.vars
ALLOWED_ORIGIN = "http://localhost:8787"
```

:::note warn
`ALLOWED_ORIGIN`に設定するURLは最後にスラッシュ（/）を記載せず、`http://localhost:8787`にしてください。
:::

## 4.3 CSRF対策の効果をローカル環境で確認

```bash
npx wrangler dev
```

ブラウザで `http://localhost:8787` にアクセスして、先ほどと同様に使えることを確認しましょう。

その後で、許可しているOriginをわざと変更した時に、APIが**利用できなくなる**ことを確かめていきます。

### 4.3.1 不正なOriginからのアクセス確認

許可していないOriginヘッダーを持つリクエストを受け付けないことを確認します。

ブラウザー側の操作ではOriginヘッダーの値を変更できません。

そこで、環境変数で指定するALLOWED_ORIGINの値をわざと`http://localhost:8788`**以外**に変更して、ブラウザーからのアクセスを受け付けないことを確認します。

**手順**

1. `.dev.vars`の`ALLOWED_ORIGIN`をわざと異なったものに変更します（例:`http://localhost:8888`）
2. 開発用サーバーを再起動します（環境変数の変更を反映させるために再起動が必要です）
3. ブラウザーでアプリにアクセスします

**期待結果**

- 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の主な対策

1. HTTPOnlyクッキーの使用:
   - クッキーに`HttpOnly`属性を付け、JavaScriptによるクッキーの盗難を防ぎます
2. 入力値のエスケープ:
   - HTML、JavaScript、CSSなどに出力するデータを適切にエスケープします
   - 例：`<` → `&lt;`、`>` → `&gt;`
3. サニタイズ:
   - ユーザー入力から不要なコードや特殊文字を除去し無害化します
4. CSP（Content Security Policy）の導入:
   - 実行可能なスクリプトのソースを制限し、外部の悪意あるスクリプトを防止します

すでにHTTPOnlyクッキーは導入済です。入力値のエスケープは画面に情報を表示するときに使うため、今回のアプリの対象外です。

サニタイズとCSPについて紹介します。

## 5.3 ユーザー入力をサニタイズして安全性を確保

ユーザー入力をそのまま使用せずサニタイズ（無害化）することで、アプリケーションをスクリプトインジェクション攻撃から守る方法を学びます。

例えば、ユーザーが`<script>`タグを含む入力を送信すると、アプリケーションがそのスクリプトをそのまま実行してしまうリスクがあります。以下の処理を追加することで、悪意ある入力を安全な形式に変換できます。

`src/sanitize.js`を新規作成します。

```javascript: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`でユーザー入力をサニタイズします。

```javascript: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`に追加します。

:::note warn
Static Assetsを使うとWorkersを起動しないため、レスポンスヘッダーを付与することができません。
:::

今回のアプリの場合、HTMLには次のようなCSPを記述します。

```html:src/index.html
  <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の設定内容**

1. **`default-src 'self'`**  
   - すべてのリソース（スクリプト、スタイルシート、画像、フレームなど）をデフォルトで現在のオリジン（`self`）からのみ読み込む
2. **`script-src 'self' https://challenges.cloudflare.com`**  
   - JavaScriptスクリプトは現在のオリジン（`self`）および `https://challenges.cloudflare.com`（Turnstileウィジェットの配信元）からのみ読み込みを許可
3. **`frame-src https://challenges.cloudflare.com`**  
   - フレームやiframeは `https://challenges.cloudflare.com` からのみ許可
4. **`style-src 'self' https://cdn.jsdelivr.net`**  
   - CSSスタイルシートは現在のオリジン（`self`）および `https://cdn.jsdelivr.net`（new.CSSの配信元）からのみ許可
5. **`img-src 'self' blob:`**  
   - 画像は現在のオリジン（`self`）およびデータURI（`blob:`）からのみ読み込みを許可

この設定により、必要な外部リソースを適切に許可しつつ、それ以外のリソースの読み込みを制限することでセキュリティを強化できます。

:::note warn
動的に生成されるページで、インラインスクリプトを安全に実行したい場合に`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`を入力します。

:::note warn
`ALLOWED_ORIGIN`に設定するURLは最後にスラッシュ（/）を記載せず、`https://ai-image-handson.<あなたのサブドメイン名>.workers.dev`にしてください。
:::

なお、PlaintextでもOKな`ALLOWED_ORIGIN`は、wrangler.tomlファイルで定義しても大丈夫です。

```toml:wrangler.toml
[vars]
ALLOWED_ORIGIN = "https://ai-image-handson.<あなたのサブドメイン名>.workers.dev"
```

## 6.2 アプリのデプロイ手順

いよいよ世界に向けて公開する瞬間です！

```bash
npx wrangler deploy
```

## 6.3 公開後の動作確認

本番環境でセキュリティ強化版アプリが正しく動作することを最終確認しましょう。

ブラウザーで`https://ai-image-handson.<あなたのサブドメイン>.workers.dev`にアクセスして、動作を確認してください。

# 7. まとめ

セキュリティを強化したコードもGitHubで公開しています。リポジトリには、この記事で紹介したすべてのコードと、設定ファイルが含まれています。

https://github.com/takatama/cf-workers-ai-image-handson-secure

**今回学んだセキュリティ強化の技術**

- **Cloudflare Turnstile**を使ったボット対策の実装
- **JWT**を用いたセッション管理
- **リクエスト検証**によるCSRF対策
- **サニタイズとCSP設定**によるXSS対策

セキュリティはアプリケーション開発において非常に重要な要素です。今回のハンズオンを通じて、セキュリティを考慮した開発手法を学び、実践することができました。この記事で紹介したセキュリティ対策は、他のWebアプリケーションやAPIにも応用可能です。

最後まで読んでいただき、ありがとうございました！Cloudflare WorkersやWorkers AIの魅力が少しでも伝われば幸いです。