この記事は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で配信しているため適用できませんでした。ぜひやり方をコメントで教えてください🙇
開発環境の準備
-
Cloudflareのアカウントを作成
クレジットカードは不要です。 - Node.jsをインストール
Voltaやnvmなどnode
のバージョン管理ツールを使うのがオススメです。 - ソースコードをclone
git clone https://github.com/takatama/cf-workers-hono-spa-translator.git
- 開発用サーバーを起動
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
<!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
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.ts
は app.route()
で翻訳APIにルーティングします。
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
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をそれぞれ有効にしています。
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です。無料で利用できます。
ちなみに、Turnstileは「一度に一人だけ通れる入場ゲート」を指すそうです。
フロントエンドにはTurnstileウィジェットを導入し、ウィジェットからバックエンドを呼び出して、Turnstileトークンを検証します。検証が完了してはじめて翻訳ボタンを有効化します。
CloudflareダッシュボードでTurnstileを追加
- 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は絶対に公開しないでください。
.dev.vars
ファイルにSecret Keyを設定
環境変数TURNSTILE_SECRET_KEY
でSecret Keyを管理します。
開発用に.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ウィジェットを追加します。
<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
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
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
へルーティングします。
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.js
のSITE_KEY
が正しいか、確認してください。
Turnstileウィジェットでは成功しているが、認証に失敗する
.dev.vars
ファイルのSecret Keyが間違っていると、開発用サーバーのターミナルに次のエラーメッセージが表示されます。
✘ [ERROR] {
success: false,
'error-codes': [ 'invalid-input-secret' ],
messages: []
}
.dev.vars
のTURNSTILE_SECRET_KEY
が正しいか、確認してください。
デベロッパーツールを開いた状態でアクセスすると認証に失敗する
ブラウザーのデベロッパーツールを開いた状態でアプリにアクセスすると、私の環境では「人間であることを確認します」というチェックボックスが現れます。チェックをしても、認証に失敗します。
Send Feedbackを送っても改善されないため、デベロッパーツールは使わずに利用しています。
Step2:JWTによるセッション管理
Turnstileで人間であることが証明できたら、JWTのセッションを開始します。APIでセッションが有効か検証することで、不正な利用を防ぎます。
Turnstile認証からJWTを利用したAPIアクセスまでの流れを図で示します。
シーケンス図の説明
-
ユーザーがアプリにアクセス
(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認証に成功すると、APIを呼び出せるようになります。
なお、JWTセッションの有効期限は、Turnstileの有効期限(5分間)とほぼ同じにしています。Turnstileの再認証に失敗すると、JWTのセッションも無効にするためです。
HonoではJWTを扱うためにJWT Authentication HelperとJWT Auth Middlewareを、クッキーを扱うためにCookie Helperをそれぞれ利用できます。
.dev.vars
にJWT_SECRET
を追加
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シークレットキー"
バックエンドでトークンを認証したらJWTセッション開始
src/session.ts
を新たに追加し、JWTを作成してクッキーに保存します。また、APIでJWTセッションを検証するためのミドルウェアも追加しておきます。
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のセッションを開始します。
import { createSessionCookie } from './session'
...
await createSessionCookie(c, { user: 'authenticated' }) // 追加
return c.text('Authenticated')
})
APIでJWTセッションを検証
src/index.ts
でAPIの呼び出し時にJWTセッションを検証します。
import { sessionMiddleware } from './session'
...
app.use('/api/*', sessionMiddleware())
フロントエンドでTurnstileを再認証
APIを呼び出した結果、JWTの検証が失敗した場合にTurnstileを再認証します。フロントエンド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
に定義してあります。
function verifyResponse(response) {
if (response.ok) return;
if (response.status === 401) {
onTurnstileExpired();
throw new Error('セッションが無効です。再度認証してください。');
}
throw new Error(`エラー ${response.status} ${response.statusText}`);
}
動作確認
JWTによるセッション管理が正しく動作しているか確認していきます。
セッション管理用のクッキーを削除する
手順
- ブラウザの開発者ツールを開き、「Application」タブで Storage > Cookies >
http://localhost:8787
を確認します -
session
という名前のCookieを削除します - 「英語に翻訳」ボタンを押してAPIを呼び出します
期待される結果
- セッションが無効なため、APIリクエストが失敗し、401エラーが返されます
- 401エラーに対して、自動的にTurnstileウィジェットが再認証します
- 再認証が成功するとクッキーが復活します。再度「英語に翻訳」を実行しても成功します
JWTの有効期限を短くする
手順
- ブラウザの開発者ツールを開き、すでに払い出された
session
クッキーを消しておきます -
src/session.ts
でJWTの有効期限を変更しますexp: Math.floor(Date.now() / 1000) + 30 // 有効期限を6分から30秒に短縮
- アプリを起動します。Turnstileの再認証が成功すると、短い有効期限のJWTが払い出されます
- 30秒以上待ちます
- 再度「英語に翻訳」を実行して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.vars
にALLOWED_ORIGIN
を追加
環境変数ALLOWED_ORIGIN
で許可されたホスト名を管理します。
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
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対策を有効にします。
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を定義します。
<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の設定内容
-
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'
- CSSスタイルシートは現在のオリジン(
self
)からのみ許可
- CSSスタイルシートは現在のオリジン(
この設定により、必要な外部リソースを適切に許可しつつ、それ以外のリソースの読み込みを制限することでセキュリティを強化できます。
動的に生成されるページで、インラインスクリプトを安全に実行したい場合にはnonce
(一時的な値)を使用します。
Turnstileもnonce
の使用を推奨しており、script-set
とframe-set
を指定する方法は代替策としています。
https://developers.cloudflare.com/turnstile/reference/content-security-policy/
フロントエンドにエスケープを導入
src/script.js
を修正し、利用者からの入力はエスケープしてから画面に表示します。
...
function escapeHTML(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
...
historyItem.innerHTML = `<strong>日本語:</strong> ${escapeHTML(inputText)}<br><strong>英語:</strong> ${escapeHTML(translatedText)}`;
動作確認
上記の対策を施したことで、日本語欄に以下を入力しても画面にアラートが表示されなくなりました。
<img src=x onerror="alert(1)">
バックエンドで入力をサニタイズ(無害化)
上記のXSS攻撃はフロントエンドで対策しました。今回のアプリでは対策にならないのですが、バックエンドで利用者の入力から危険なスクリプトを除去するサニタイズの実装をご紹介しておきます。
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
で、入力を無害化します。
import { sanitize } from './sanitize'
...
{ role: 'user', content: sanitize(prompt) },
デプロイ
お疲れさまでした!セキュリティの強化が完了しました。全世界に向けてデプロイしてみましょう。
npm run deploy
デプロイが完了すると、本番系のURL https://translator.<あなたのサブドメイン>.workers.dev
が表示されます。ブラウザーでアクセスしてみると、まだ設定が足りないようです。
Clodflareダッシュボードにログインして、設定をしていきます。
Turnstileの設定
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ファイルで定義しても大丈夫です。
[vars]
ALLOWED_ORIGIN = "https://translator.<あなたのサブドメイン>.workers.dev"
設定が完了したら、再びデプロイして動作を確認してみましょう。お疲れ様でした!
まとめ
初めてHonoを使ってウェブアプリを作成し、そのセキュリティを強化してみました。コンパクトにコードを記述できるのが気持ちいいですね!セキュリティ対策は大変重要なのですが、実装が複雑になると開発者の負担が増え、メンテナンスも難しくなりがちです。しかし、Honoを使うことで、これらの課題をクリアしながら、スマートなコード設計を維持できる可能性を感じました。
これからも、Honoによるアプリ開発の可能性を探っていきたいと思います。この記事が、Honoやセキュリティ対策に興味を持つ方々にとって、少しでも参考になれば幸いです。