10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HonoAdvent Calendar 2024

Day 11

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

Last updated at Posted at 2024-12-10

この記事はHono Advent Calendar 2024 11日目の記事です。

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

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

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

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

日本語から英語に翻訳するウェブアプリ

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

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

セキュリティ強化の流れ

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

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

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

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

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

HonoのSecure Headers Middlewareも様々な攻撃を防ぐのに有効なのですが、今回はindex.htmlをStatic Assetsで配信しているため適用できませんでした。ぜひやり方をコメントで教えてください🙇

開発環境の準備

  1. Cloudflareのアカウントを作成
    クレジットカードは不要です。
  2. Node.jsをインストール
    Voltanvmなどnodeのバージョン管理ツールを使うのがオススメです。
  3. ソースコードをclone
    git clone https://github.com/takatama/cf-workers-hono-spa-translator.git
    
  4. 開発用サーバーを起動
    npx wrangler dev
    
    http://localhost:8787にアクセスして、翻訳アプリの動作を確認します。

翻訳アプリの構成

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

.
├── 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を利用しています。

public/index.html
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>
public/script.js
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();
});

バックエンド

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

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

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 を使って、日本語を英語に翻訳します。

src/translate.ts
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

Cloudflare Workersの設定

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

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です。無料で利用できます。

Cloudflare 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 ModeManagedを選択
    • pre-clearanceNoを選択
  4. Create」ボタンをクリックすると、Site KeySecret Keyが発行されます

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

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

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

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

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

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

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

 ⛅️ 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ウィジェットを追加します。

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を呼び出します。

public/turnstile.js
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;
    },
  });
}

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

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

src/turnstile.ts
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

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

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

トラブルシューティング

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

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

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

hook.js:608 [Cloudflare Turnstile] Error: 400020.
overrideMethod	@	hook.js:608

src/tunrstile.jsSITE_KEYが正しいか、確認してください。

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

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

✘ [ERROR] {

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

.dev.varsTURNSTILE_SECRET_KEYが正しいか、確認してください。

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

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

Turnstileで失敗しましたと表示される

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

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

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

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

シーケンス図の説明
  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認証に成功すると、APIを呼び出せるようになります。

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

HonoではJWTを扱うためにJWT Authentication HelperJWT Auth Middlewareを、クッキーを扱うためにCookie Helperをそれぞれ利用できます。

.dev.varsJWT_SECRETを追加

JWTシークレットキーは、ある一定以上の長さ(32文字以上)で、ランダムな文字列が望まれます。

ターミナルでNode.jsを実行して、64文字のランダムな文字列を作成しJWTシークレットキーとして使います。

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

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

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

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

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

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

src/session.ts
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)
})

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

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

APIでJWTセッションを検証

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

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

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

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

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に定義してあります。

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の有効期限を変更します
    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の有効期限を戻しておきましょう

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

トラブルシューティング

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

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

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

Step3:CSRF対策

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

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

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

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

HonoではCSRF Protection Middlewareを使うことで、ほぼ同等の対策が可能になります。

.dev.varsALLOWED_ORIGINを追加

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

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

ホスト名の最後にスラッシュ(/)は不要です。

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

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

src/csrf.ts
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)
})

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

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

動作確認

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

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

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

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

トラブルシューティング

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

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

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

Step4:XSS対策

HonoにはSecure Headers Middlewareがあり、Content Security Policy(CSP)の設定も可能です。ただ、今回のアプリはStatic Assetsを利用していて、ミドルウェアの対象にできませんでした。

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

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

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

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)からのみ許可

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

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

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

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

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

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

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)}`;

動作確認

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

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

バックエンドで入力をサニタイズ(無害化)

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

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

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;
}

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

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

デプロイ

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

npm run deploy

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

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

Turnstileの設定

Turnstileウィジェットにドメインが無効ですと表示される

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

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

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

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

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

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

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

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

まとめ

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

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

10
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?