Honoで実践するSPAセキュリティ強化の基本

この記事は[Hono Advent Calendar 2024](https://qiita.com/advent-calendar/2024/hono) 11日目の記事です。

このアドベントカレンダーをきっかけに、初めてHonoでウェブアプリを作ってみました。セキュリティを強化しても、コードがコンパクトなままなのでとてもいい感じです。

この記事では、ウェブアプリのセキュリティを強化する過程をハンズオン形式でお届けします。

# セキュリティを強化する対象のアプリ

Single Page Application（SPA）の翻訳アプリです。サーバーレスの実行環境Cloudflare Workersと、Cloudflare上で生成AIを簡単に利用できるWorkers AIを使っています。

<img width="512" alt="日本語から英語に翻訳するウェブアプリ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/70aba04b-aed6-4cec-43ca-78945f1da227.png">

Cloudflareのアカウントがあれば無料で動かせます。クレジットカードなしでアカウントを作れますし、ほんと太っ腹ですね！

ソースコードはGitHubで公開しています。

https://github.com/takatama/cf-workers-hono-spa-translator

# セキュリティ強化の流れ

このアプリには、次のようなセキュリティリスクがあります。

| リスク | 原因 | 影響 |
|---|---|---|
| ボット利用 | CAPTCHA等によるボット対策がない | リソース浪費 |
| 誰でも利用可能 | 認証がない | 悪意のあるユーザーによるアクセスやリソース消費 |
| CSRF（Cross Site Request Forgery）攻撃 | 外部サイトからのリクエストを防御していない | リソース浪費 |
| XSS（Cross Site Scripting）攻撃 | ユーザー入力を適切に処理していない | 悪意あるスクリプトの実行 |

これらのリスクを軽減するため、次のように対策していきます。

| リスク | 対策 | 実装内容 |
|---|---|---|
| ボット利用 | [Cloudflare Turnstile](https://www.cloudflare.com/ja-jp/application-services/products/turnstile/)で人間のみアクセス可能に | ユーザーのアクセス時にTurnstileで検証 |
| 誰でも利用可能 | JWT（JSON Web Token）によるセッション管理 | 人間の場合セッションを開始 |
| CSRF攻撃 | OriginヘッダーとFetch Metadata検証 | リクエスト元が正当か確認 |
| XSS攻撃 | 入力の無害化、CSP（Content Security Policy）で外部リソース読み込み制限 | 不正スクリプトの挿入を防止 |

特に、JWTとCSRF対策は、Honoのミドルウェアで効率的に実装できます。

:::note warn
Honoの[Secure Headers Middleware](https://hono.dev/docs/middleware/builtin/secure-headers)も様々な攻撃を防ぐのに有効なのですが、今回は`index.html`をStatic Assetsで配信しているため適用できませんでした。ぜひやり方をコメントで教えてください🙇
:::

# 開発環境の準備

1. [Cloudflareのアカウントを作成](https://dash.cloudflare.com/sign-up/workers-and-pages)
   クレジットカードは不要です。
1. Node.jsをインストール
   [Volta](https://volta.sh/)や[nvm](https://github.com/nvm-sh/nvm)など`node`のバージョン管理ツールを使うのがオススメです。
1. ソースコードをclone
   ```bash
   git clone https://github.com/takatama/cf-workers-hono-spa-translator.git
   ```
1. 開発用サーバーを起動
   ```bash
   npx wrangler dev
   ```
   `http://localhost:8787`にアクセスして、翻訳アプリの動作を確認します。

# 翻訳アプリの構成

全体のファイル構成は次の通りです。

```bash
.
├── public           # フロントエンド
│   ├── index.html
│   ├── pico.blue.min.css
│   ├── script.js
│   └── style.css
├── src              # バックエンド
│   ├── index.ts
│   └── translate.ts # 翻訳API
└── wrangler.toml
```

## フロントエンド

フロントエンドは翻訳履歴、日本語入力欄、翻訳ボタン、で構成されます。利用者が日本語を入力して翻訳ボタンを押すと、バックエンドの翻訳API`/api/translate`を呼び出します。翻訳結果は、フロントエンドの翻訳履歴に追加されていきます。

軽量なCSSフレームワーク[Pico CSS](https://picocss.com/)を利用しています。

<details><summary>public/index.html</summary>

```public/index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Workers AI ハンズオン</title>
  <link rel="stylesheet" href="pico.blue.min.css" />
  <link rel="stylesheet" href="style.css" />
  <script src="script.js" defer></script>
</head>

<body>
  <main class="container">
    <div id="turnstile-widget"></div>
    <h2>日本語から英語に翻訳</h2>
    <section aria-labelledby="翻訳履歴">
      <ul id="history-list"></ul>
    </section>
    <form id="translate-form">
      <textarea id="prompt" name="prompt" placeholder="日本語を入力してください" required></textarea>
      <button type="submit" id="translate-btn" disabled>英語に翻訳</button>
    </form>
    <p id="translate-error-message" class="error-message"></p>
  </main>
</body>

</html>
```

</details>

<details><summary>public/script.js</summary>

```javascript:public/script.js
document.addEventListener("DOMContentLoaded", () => {
  const promptInput = document.getElementById("prompt");
  const translateBtn = document.getElementById("translate-btn");
  const errorMessage = document.getElementById("translate-error-message");
  const historyList = document.getElementById("history-list");
  const translateForm = document.getElementById("translate-form");

  // 入力欄の変更を監視してボタンの有効化を制御
  promptInput.addEventListener("input", () => {
    translateBtn.disabled = promptInput.value.trim() === "";
  });
  
  // 翻訳履歴の追加
  function addToHistory(inputText, translatedText) {
    const historyItem = document.createElement("li");
    historyItem.innerHTML = `<strong>日本語:</strong> ${inputText}<br><strong>英語:</strong> ${translatedText}`;
    historyList.appendChild(historyItem);
    historyList.scrollTop = historyList.scrollHeight; // 履歴リストをスクロールダウン
  }

  // 翻訳処理
  async function translateText(event) {
    event.preventDefault();
    translateBtn.disabled = true;
    errorMessage.style.display = "none";

    try {
      const formData = new FormData(translateForm);
      const response = await fetch(`/api/translate`, {
        method: "POST",
        body: formData,
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const translatedText = await response.text();
      addToHistory(promptInput.value, translatedText);
      promptInput.value = "";
    } catch (error) {
      console.error("エラー: ", error);
      errorMessage.textContent = "翻訳に失敗しました。もう一度試してください。";
      errorMessage.style.display = "block";
      translateBtn.disabled = false;
    }
  }

  // フォームの送信イベントに翻訳処理をバインド
  translateForm.addEventListener("submit", translateText);

  // Enterキーでの送信を制御
  promptInput.addEventListener("keydown", (event) => {
    if (!event.isComposing && event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      if (!translateBtn.disabled) {
        translateForm.requestSubmit();
      }
    }
  });

  // ページ読み込み時に入力欄にフォーカス
  promptInput.focus();
});
```

</details>

## バックエンド

バックエンドは、ベストプラクティスに従って、翻訳APIを別ファイル `translate.ts` に分けています。

https://hono.dev/docs/guides/best-practices#building-a-larger-application

アプリのエントリーポイントである `index.ts` は `app.route()` で翻訳APIにルーティングします。

```typescript:src/index.ts
import { Hono } from 'hono'
import translate from './translate'

const app = new Hono()
app.route('/api/translate', translate)

export default app
```

翻訳APIではWorkers AIのText Generation Modelである `@cf/meta/llama-3.2-3b-instruct` を使って、日本語を英語に翻訳します。

<details><summary>src/translate.ts</summary>

```typescript:src/translate.ts
import { Hono } from 'hono'

type Bindings = {
  AI: any
}

const app = new Hono<{ Bindings: Bindings }>()

app.post('/', async (c) => {
  const formData = await c.req.formData()
  const prompt = formData.get('prompt')
  if (typeof prompt !== 'string') {
    return c.text('Invalid input', 400);
  }

  const messages = [
    {
      role: 'system',
      content: `Translate the following japanese into English phrases without additional comments.`,
    },
    { role: 'user', content: prompt },
  ]

  const answer = await c.env.AI.run('@cf/meta/llama-3.2-3b-instruct', {
    messages,
  })

  return c.text(answer.response)
})

export default app
```
</details>

## Cloudflare Workersの設定

Cloudflare Workersの設定ファイル `wrangler.toml` では、静的コンテンツを上限なく配信できるStatic Assetsと、Workers AIをそれぞれ有効にしています。

```toml:wrangler.toml
name = "translator"
main = "src/index.ts"
compatibility_date = "2024-12-08"
assets = { directory = "public" } # Static Assetsを設定

[ai]
binding = "AI" # Workers AIを設定
```

# Step1：Turnstileによるボット対策

CAPTCHAと違い、せいぜい1クリックで人間であることを証明できるのが[Turnstile](https://developers.cloudflare.com/turnstile/)です。無料で利用できます。

<img src="https://cf-assets.www.cloudflare.com/slt3lc6tev37/2atsfrGgvgOc3DZ91qMlKN/0412afa63e5fac20964377c70c1a9a17/turnstile_gif.gif" width="400">

[Cloudflare Turnstile](https://www.cloudflare.com/ja-jp/application-services/products/turnstile/)より引用

ちなみに、Turnstileは「一度に一人だけ通れる入場ゲート」を指すそうです。

フロントエンドにはTurnstileウィジェットを導入し、ウィジェットからバックエンドを呼び出して、Turnstileトークンを検証します。検証が完了してはじめて翻訳ボタンを有効化します。

## CloudflareダッシュボードでTurnstileを追加

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は絶対に公開しないでください。
:::

## `.dev.vars`ファイルにSecret Keyを設定

環境変数`TURNSTILE_SECRET_KEY`でSecret Keyを管理します。

開発用に`.dev.vars`ファイルを新しく追加して記述します。

```bash:.dev.vars
TURNSTILE_SECRET_KEY = "あなたのSecret Key"
```

:::note info
`.dev.vars`ファイルはGitで管理しないように`.gitignore`に追加します。
Wranglerコマンドで自動生成される`.gitignore`には追記済です。
:::

開発用サーバーを起動したときに、`TURNSTILE_SECRET_KEY`を読み込んでいればOKです。

```console
 ⛅️ wrangler 3.93.0
-------------------

Using vars defined in .dev.vars
Your worker has access to the following bindings:
- AI:
  - Name: AI
- Vars:
  - TURNSTILE_SECRET_KEY: "(hidden)"
▲ [WARNING] Using Workers AI always accesses your Cloudflare account in order to run AI models, and so will incur usage charges even in local development.
```

なお、本番用の環境変数はCloudflareダッシュボードで設定します。デプロイする前に実施します。

## フロントエンドにTurnstileウィジェットを導入

`public/index.html`にTurnstileウィジェットを追加します。

```html:public/index.html
<head>
  ...
  <!-- Step1 Turnstile導入 -->
  <script src="turnstile.js" defer></script>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onLoadTurnstile" defer></script>
```

`public/turnstile.js`を新たに追加し、Cloudflare Turnstileからトークンを取得し、バックエンドのトークン検証API`/auth`を呼び出します。

<details><summary>public/turnstile.js</summary>

```javascript:public/turnstile.js
const SITE_KEY = "あなたのSite Key";

const TurnstileManager = (() => {
  let callbacks = {};

  function init(widgetId, options = {}) {
    callbacks = {
      started: options.onStarted || (() => {}),
      success: options.onSuccess || (() => {}),
      error: options.onError || (() => {}),
      expired: options.onExpired || (() => {}),
    };

    // 初期化開始のコールバックを呼び出し
    callbacks.started();

    // Turnstileウィジェットを描画
    turnstile.render(widgetId, {
      sitekey: SITE_KEY,
      callback: handleSuccess,
      'error-callback': handleError,
      'expired-callback': handleExpired,
    });
  }

  async function handleSuccess(token) {
    try {
      const formData = new FormData();
      formData.append('cf-turnstile-response', token);

      const response = await fetch('/auth', {
        method: 'POST',
        body: formData,
      });

      if (response.ok) {
        callbacks.success();
      } else {
        callbacks.error();
        alert('認証に失敗しました。再度お試しください。');
      }
    } catch (error) {
      console.error('Error during authentication:', error);
      callbacks.error();
    }
  }

  function handleError() {
    callbacks.error();
    alert('Turnstileエラーが発生しました。再度お試しください。');
  }

  function handleExpired() {
    callbacks.expired();
    turnstile.reset();
  }

  return { init };
})();

function onLoadTurnstile() {
  const translateBtn = document.getElementById('translate-btn');
  
  TurnstileManager.init('#turnstile-widget', {
    onStarted: () => {
      translateBtn.disabled = true;
    },
    onSuccess: () => {
      translateBtn.disabled = false;
    },
    onError: () => {
      translateBtn.disabled = true;
    },
    onExpired: () => {
      translateBtn.disabled = true;
    },
  });
}
```

</details>


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

`src/turnstile.ts`を新たに追加し、フロントエンドから渡されたTurnstileトークンを検証します。

<details><summary>src/turnstile.ts</summary>

```typescript:src/turnstile.ts
import { Hono } from 'hono'

type ResponseJson = {
  success: boolean
}

export async function verifyTurnstileToken(secretKey: string, token: string, ip: string | undefined) {
  const formData = new FormData()
  formData.set('secret', secretKey)
  formData.set('response', token)
  formData.set('remoteip', ip ?? '')
  const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
    method: 'POST',
    body: formData,
  })
  const data: ResponseJson = await response.json()
  if (!data.success) {
    console.error(data)
  }
  return data.success
}

type Bindings = {
  TURNSTILE_SECRET_KEY: string,
}

const app = new Hono<{ Bindings: Bindings }>()

app.post('/', async (c) => {
  const secret = c.env.TURNSTILE_SECRET_KEY
  const formData = await c.req.formData()
  const token = formData.get('cf-turnstile-response')
  const ip = c.req.header('CF-Connecting-IP')
  if (!token || typeof token !== 'string') {
    return c.text('Invalid input', 400)
  }
  const isValid = await verifyTurnstileToken(secret, token, ip);
  if (!isValid) {
    return c.text('Unauthenticated', 401)
  }

  return c.text('Authenticated')
})

export default app
```

</details>

`src/index.ts`で、`/auth`へのリクエストを`src/turnstile.ts`へルーティングします。

```typescript:src/index.ts
import turnstile from './turnstile'
...
app.route('/auth', turnstile)
```

## トラブルシューティング

Turnstileの設定に不備があると、認証に失敗します。

### Turnstileウィジェットが画面に表示されない

`public/turnstile.js`のSite Keyが間違っていると、ブラウザーのコンソールに次のエラーメッセージが表示されます。

```console
hook.js:608 [Cloudflare Turnstile] Error: 400020.
overrideMethod	@	hook.js:608
```
`src/tunrstile.js`の`SITE_KEY`が正しいか、確認してください。

### Turnstileウィジェットでは成功しているが、認証に失敗する

`.dev.vars`ファイルのSecret Keyが間違っていると、開発用サーバーのターミナルに次のエラーメッセージが表示されます。

```console
✘ [ERROR] {

    success: false,
    'error-codes': [ 'invalid-input-secret' ],
    messages: []
  }
```

`.dev.vars`の`TURNSTILE_SECRET_KEY`が正しいか、確認してください。

### デベロッパーツールを開いた状態でアクセスすると認証に失敗する

ブラウザーのデベロッパーツールを開いた状態でアプリにアクセスすると、私の環境では「人間であることを確認します」というチェックボックスが現れます。チェックをしても、認証に失敗します。

<img alt="Turnstileで失敗しましたと表示される" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/62461418-279d-e9b7-054b-d3b4de38ae1b.png" width="400">

Send Feedbackを送っても改善されないため、デベロッパーツールは使わずに利用しています。

# Step2：JWTによるセッション管理

Turnstileで人間であることが証明できたら、JWTのセッションを開始します。APIでセッションが有効か検証することで、不正な利用を防ぎます。

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: リソースまたはエラーを返す
```

<details><summary>シーケンス図の説明</summary>

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エラーを返します。

</details>

このフローにより、ユーザーが**Turnstile認証に成功すると、APIを呼び出せる**ようになります。

なお、JWTセッションの有効期限は、Turnstileの有効期限（5分間）とほぼ同じにしています。Turnstileの再認証に失敗すると、JWTのセッションも無効にするためです。

HonoではJWTを扱うために[JWT Authentication Helper](https://hono.dev/docs/helpers/jwt)と[JWT Auth Middleware](https://hono.dev/docs/middleware/builtin/jwt)を、クッキーを扱うために[Cookie Helper](https://hono.dev/docs/helpers/cookie)をそれぞれ利用できます。

## `.dev.vars`に`JWT_SECRET`を追加

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シークレットキー"
```

## バックエンドでトークンを認証したらJWTセッション開始

`src/session.ts`を新たに追加し、JWTを作成してクッキーに保存します。また、APIでJWTセッションを検証するためのミドルウェアも追加しておきます。

<details><summary>src/session.ts</summary>

```typescript:src/session.ts
import { jwt, sign } from 'hono/jwt'
import { setCookie } from 'hono/cookie'
import { createMiddleware } from 'hono/factory'

const COOKIE_NAME = 'session'

export async function createSessionCookie(context:any, value: any) {
  const sessionSecret = context.env.JWT_SECRET
  if (!sessionSecret) {
    throw new Error('環境変数 JWT_SECRET が設定されていません。'); 
  }
  const payload = {
    ...value,
    // Turnstileの有効期限（5分）より、少しだけ長くする（6分）
    exp: Math.floor(Date.now() / 1000) + 60 * 6
  }
  const token = await sign(payload, context.env.JWT_SECRET)
  setCookie(context, COOKIE_NAME, token, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict',
    path: '/',
  })
}

type Bindings = {
  JWT_SECRET: string,
}

export const sessionMiddleware = () => createMiddleware<{ Bindings: Bindings }>((c, next) => {
  const jwtMiddleware = jwt({
    secret: c.env.JWT_SECRET,
    cookie: COOKIE_NAME,
  })
  return jwtMiddleware(c, next)
})
```

</details>

`src/turnstile.ts`を修正し、Turnstileの認証が成功したら、JWTのセッションを開始します。

```typescript:src/turnstile.ts
import { createSessionCookie } from './session'
...
  await createSessionCookie(c, { user: 'authenticated' }) // 追加
  return c.text('Authenticated')
})

```

## APIでJWTセッションを検証

`src/index.ts`でAPIの呼び出し時にJWTセッションを検証します。

```typescript:src/index.ts
import { sessionMiddleware } from './session'
...
app.use('/api/*', sessionMiddleware())
```

## フロントエンドでTurnstileを再認証

APIを呼び出した結果、JWTの検証が失敗した場合にTurnstileを再認証します。フロントエンド`src/script.js`を修正します。

```javascript:src/script.js
  async function translateText(event) {
    ...
      // if (!response.ok) {
      //   throw new Error(`HTTP error! status: ${response.status}`);
      // }
      
      // Step2 JWTでセッションを管理
      verifyResponse(response);
```

`verifyResponse()`は`src/turnstile.js`に定義してあります。

```javascript:src/turnstile.js
function verifyResponse(response) {
  if (response.ok) return;
  if (response.status === 401) {
    onTurnstileExpired();
    throw new Error('セッションが無効です。再度認証してください。');
  }
  throw new Error(`エラー ${response.status} ${response.statusText}`);
}
```

## 動作確認

JWTによるセッション管理が正しく動作しているか確認していきます。

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

**手順**

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

**期待される結果**

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

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

**手順**

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

**期待結果**

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

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

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

```typescript
exp: Math.floor(Date.now() / 1000) + 60 * 6
```

## トラブルシューティング

`.dev.vars`ファイルに環境変数`JWT_SECRET`を設定していないと、開発用サーバーのターミナルに次のエラーが表示されます。

```console
[wrangler:inf] POST /auth 500 Internal Server Error (399ms)
✘ [ERROR] Error: 環境変数 JWT_SECRET が設定されていません。
```

JWTシークレットを正しく設定できているか確認してください。

# Step3：CSRF対策

CSRFは、認証がOKになった利用者を攻撃者が誘導し、APIを不正に実行させる攻撃です。今回の翻訳アプリでは、攻撃者の誘導によりAPIが無駄に実行され、Workers AIの無料枠が無駄に消費させられてしまいます。

例えば、攻撃者が準備したサイトに次のフォームが設置されると、利用者がボタンをクリックしたときに、翻訳APIが実行されてしまいます。

```html
<form action="https://translator.<サブドメイン>.workers.dev/api/translate">
  <input type="hidden" id="prompt" name="prompt" value="...めちゃくちゃ長い入力..." />
  <button type="submit">クリックすると、めちゃくちゃ面白いゲームで遊べるよ！</button>
</form>
```

CSRF攻撃への対策について、次のブログに丁寧に解説されています。

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

Honoでは[CSRF Protection Middleware](https://hono.dev/docs/middleware/builtin/csrf)を使うことで、ほぼ同等の対策が可能になります。

## `.dev.vars`に`ALLOWED_ORIGIN`を追加

環境変数`ALLOWED_ORIGIN`で許可されたホスト名を管理します。

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

:::note warn
ホスト名の最後にスラッシュ（/）は不要です。
:::

## バックエンドにCSRF Protection Middlewareを導入

`src/csrf.ts`を新しく追加します。`Sec-Fetch-Site`ヘッダーがあれば`same-origin`になっていることをチェックします。さらに、CSRF Protection Middlewareを利用して、`Origin`ヘッダーが環境変数`ALLOWED_ORIGIN`と同じかをチェックします。

<details><summary>src/csrf.ts</summary>

```typescript:src/csrf.ts
import { createMiddleware } from "hono/factory"
import { csrf } from "hono/csrf"
import { HTTPException } from "hono/http-exception"

type Bindings = {
  ALLOWED_ORIGIN: string,
}

export const csrfMiddleware = () => createMiddleware<{ Bindings: Bindings }>(async (c, next) => {
  const secFetchSite = c.req.header('Sec-Fetch-Site')
  if (secFetchSite && secFetchSite !== 'same-origin') {
    console.error(`Sec-Fetch-Site が不正です: ${secFetchSite}`)
    const res = new Response('Forbidden', {
      status: 403,
    })
    throw new HTTPException(403, { res })
  }
  const origin = c.env.ALLOWED_ORIGIN
  if (!origin) {
    throw new Error('環境変数 ALLOWED_ORIGIN が定義されていません。')
  }
  const csrfProtection = csrf({ origin })
  await csrfProtection(c, next)
})
```

</details>

`src/index.ts`を修正して、CSRF対策を有効にします。

```typescript:src/index.ts
import { csrfMiddleware } from './csrf'
...
app.use('*', csrfMiddleware())
```

## 動作確認

環境変数`ALLOWED_ORIGIN`の値を、わざと間違えてみましょう。例えば`http://localhost:8888`と設定します。

開発用サーバーを起動しなおしてブラウザーにアクセスすると、`/auth`の呼び出しに失敗します。開発用サーバーのターミナルには次のエラーメッセージが表示されています。

```console
[wrangler:inf] POST /auth 403 Forbidden (45ms)
```

動作確認が終了したら、環境変数`ALLOWED_ORIGIN`を正しい値（`http://localhost:8787`）に戻しておきます。

## トラブルシューティング

`.dev.vars`ファイルに環境変数`ALLOWED_ORIGIN`が定義されていないと、開発用サーバーのターミナルに次のエラーメッセージが表示されます。

```console
✘ [ERROR] Error: 環境変数 ALLOWED_ORIGIN が定義されていません。
```

`ALLOWED_ORIGIN`を正しく設定しているか確認してください。

# Step4：XSS対策

Honoには[Secure Headers Middleware](https://hono.dev/docs/middleware/builtin/secure-headers)があり、Content Security Policy（CSP）の設定も可能です。ただ、今回のアプリはStatic Assetsを利用していて、ミドルウェアの対象にできませんでした。

今回のアプリではフロントエンドを修正してXSS攻撃を対策します。

## フロントエンドにCSPを導入

`public/index.html`の`<meta>`ヘッダーでCSPを定義します。

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

**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'**  
   - CSSスタイルシートは現在のオリジン（`self`）からのみ許可

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

:::note warn
動的に生成されるページで、インラインスクリプトを安全に実行したい場合には`nonce`（一時的な値）を使用します。

Turnstileも`nonce`の使用を推奨しており、`script-set`と`frame-set`を指定する方法は代替策としています。

https://developers.cloudflare.com/turnstile/reference/content-security-policy/
:::

## フロントエンドにエスケープを導入

`src/script.js`を修正し、利用者からの入力はエスケープしてから画面に表示します。

```javascript:src/script.js
  ...
  function escapeHTML(str) {
    return str
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }
  ...
      historyItem.innerHTML = `<strong>日本語:</strong> ${escapeHTML(inputText)}<br><strong>英語:</strong> ${escapeHTML(translatedText)}`;
```

## 動作確認

上記の対策を施したことで、日本語欄に以下を入力しても画面にアラートが表示されなくなりました。

```html
<img src=x onerror="alert(1)">
```

## バックエンドで入力をサニタイズ（無害化）

上記のXSS攻撃はフロントエンドで対策しました。今回のアプリでは対策にならないのですが、バックエンドで利用者の入力から危険なスクリプトを除去するサニタイズの実装をご紹介しておきます。

`src/sanitize.ts`ファイルを新しく追加します。

```typescript:src/sanitize.ts
const MAX_LENGTH = 2048;

export function sanitize(input: string) {
  const sanitized = input
    // 不要な特殊文字を削除し、安全な文字列に変換
    .replace(/[^\p{L}\p{N}\s,.!?'"-]/gu, "")
    .slice(0, MAX_LENGTH);
  return sanitized;
}
```

利用者からの入力を受け付けるAPI`src/translate.ts`で、入力を無害化します。

```typescript:src/translate.ts
import { sanitize } from './sanitize'
...
    { role: 'user', content: sanitize(prompt) },
```

# デプロイ

お疲れさまでした！セキュリティの強化が完了しました。全世界に向けてデプロイしてみましょう。

```bash
npm run deploy
```

デプロイが完了すると、本番系のURL `https://translator.<あなたのサブドメイン>.workers.dev` が表示されます。ブラウザーでアクセスしてみると、まだ設定が足りないようです。

Clodflareダッシュボードにログインして、設定をしていきます。

## Turnstileの設定

<img width="400" alt="Turnstileウィジェットにドメインが無効ですと表示される" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6760/2c7029c3-a248-fdc0-32e1-93d3b64a1375.png">

Turnstileウィジェットに「ドメインが無効です」と表示されている場合、Turnstileの設定でHostnameに`translator.<あなたのサブドメイン>.workers.dev`を追加して、更新しましょう。`https://`は**不要**です。

## 本番環境用の環境変数を設定

左メニュー「Workers & Pages」で`translator`プロジェクトを選択し、Settings > Variables and Secretsで環境変数を追加します。

`TURNSTILE_SECRET_KEY`と`JWT_SECRET`は公開してはいけません。必ずType: `Secret`を選んでください。それぞれ`.dev.vars`に定義されたものを入力します。

`ALLOWED_ORIGIN`はType: `Plaintext`でOKです。デプロイ後のURLを設定します。`https://translator.<あなたのサブドメイン名>.workers.dev`を入力します（最後にスラッシュ（/）は**不要**です）。

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

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

設定が完了したら、再びデプロイして動作を確認してみましょう。お疲れ様でした！

# まとめ

初めてHonoを使ってウェブアプリを作成し、そのセキュリティを強化してみました。コンパクトにコードを記述できるのが気持ちいいですね！セキュリティ対策は大変重要なのですが、実装が複雑になると開発者の負担が増え、メンテナンスも難しくなりがちです。しかし、Honoを使うことで、これらの課題をクリアしながら、スマートなコード設計を維持できる可能性を感じました。

これからも、Honoによるアプリ開発の可能性を探っていきたいと思います。この記事が、Honoやセキュリティ対策に興味を持つ方々にとって、少しでも参考になれば幸いです。