HonoXでお手軽にSecureなサイトを作成する
個人開発で忘れがちなことの1つにセキュリティがあります。
最近、評価の高い「フロントエンド開発のためのセキュリティ入門 知らなかったでは済まされない脆弱性対策の必須知識」の輪読会をする中で、セキュリティ対策を一通り実験してみたくなりました。
そこで、HonoXを使って、Secureなサイトを作成することにしました。
この記事では、HonoXを使って、Secureなサイトを作成する方法を紹介します。
採用した技術は以下の通りです。
- HonoX
- daisyUI
- Drizzle
- D1
- bun
小説家になろうで連載中の「現代社会で乙女ゲームの悪役令嬢をするのはちょっと大変」のファンサイトを作成しました。
実際に作成したサイトはこちらです。
GitHubリポジトリはこちらです。
HonoX とは
HonoXとは「HonoとViteを組み合わせたメタフレームワーク」です。
Honoは、Edge、Node.js、Denoでも動作するJavaScriptのFWです。
詳細は作者のyusukebe氏の記事を参照してください。
フロントエンドのセキュリティ対策
今回対象としたのは、Security Headers Powered by Probelyなどの検証ツールで確認ができるヘッダの設定です。
Security Headersで確認できる主なヘッダは以下の通りです。
- Content Security Policy (CSP)
- X-Frame-Options
- X-Content-Type-Options
- Referrer-Policy
- Permissions-Policy
- Strict-Transport-Security (HSTS)
これらのヘッダを適切に設定することで、さまざまな攻撃からWebサイトを保護できます。
HonoXを使用することで、これらのヘッダを簡単に設定できます。
HonoX でのセキュリティヘッダの設定
HonoXでは、app/routes/_middleware.ts
ファイルを作成し、ミドルウェアを定義することで、すべてのルートに対してセキュリティヘッダを適用できます。
以下は、愚直にセキュリティヘッダを設定する例です。
import { createRoute } from "honox/factory";
import { logger } from "hono/logger";
export default createRoute(logger(), async (c, next) => {
// X-Frame-Options
c.res.headers.append("X-Frame-Options", "DENY");
// X-Content-Type-Options
c.res.headers.append("X-Content-Type-Options", "nosniff");
// Referrer-Policy
c.res.headers.append("Referrer-Policy", "strict-origin-when-cross-origin");
// Permissions-Policy
c.res.headers.append(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()"
);
// Strict-Transport-Security
c.res.headers.append(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload"
);
await next();
});
この実装でも問題はありませんが、HonoにはsecureHeaders
というミドルウェアが用意されており、より簡潔にヘッダを書くことができます。
import { createRoute } from "honox/factory";
import { secureHeaders } from "hono/secure-headers";
import { logger } from "hono/logger";
export default createRoute(logger(), secureHeaders());
secureHeaders の設定
secureHeaders
ミドルウェアは、以下のオプションを受け取ることができます。
Option | Header | Value | Default |
---|---|---|---|
- | X-Powered-By | (Delete Header) | True |
contentSecurityPolicy | Content-Security-Policy | Usage: Setting Content-Security-Policy | No Setting |
crossOriginEmbedderPolicy | Cross-Origin-Embedder-Policy | require-corp | False |
crossOriginResourcePolicy | Cross-Origin-Resource-Policy | same-origin | True |
crossOriginOpenerPolicy | Cross-Origin-Opener-Policy | same-origin | True |
originAgentCluster | Origin-Agent-Cluster | ?1 | True |
referrerPolicy | Referrer-Policy | no-referrer | True |
reportingEndpoints | Reporting-Endpoints | Usage: Setting Content-Security-Policy | No Setting |
reportTo | Report-To | Usage: Setting Content-Security-Policy | No Setting |
strictTransportSecurity | Strict-Transport-Security | max-age=15552000; includeSubDomains | True |
xContentTypeOptions | X-Content-Type-Options | nosniff | True |
xDnsPrefetchControl | X-DNS-Prefetch-Control | off | True |
xDownloadOptions | X-Download-Options | noopen | True |
xFrameOptions | X-Frame-Options | SAMEORIGIN | True |
xPermittedCrossDomainPolicies | X-Permitted-Cross-Domain-Policies | none | True |
xXssProtection | X-XSS-Protection | 0 | True |
各オプションによるセキュリティ評価の変化
Security Headers Powered by Probelyを使って、各オプションを設定した場合のセキュリティ評価を確認しました。
Secure Headers なし
secureHeadersを設定しない場合、セキュリティを高めるヘッダが設定されていないため、セキュリティ評価が低くなります。
secureHeaders のみ
secureHeadersのみを設定した場合でも、多くの場合で設定する項目はデフォルト値が設定されており、セキュリティ評価が向上します。
export default createRoute(secureHeaders());
オプション追加(CSP/HSTS)
Contents Security Policy (CSP)とStrict-Transport-Security (HSTS)を追加すると、セキュリティ評価が向上します。
Strict-Transport-Security (HSTS)は、HTTPSを強制するためのヘッダです。
現代では、HTTPSを使用することが推奨されており、HSTSを使用することで、HTTPからHTTPSへのリダイレクトを強制できます。
Content Security Policy (CSP)は、クロスサイトスクリプティング(XSS)などの攻撃からWebサイトを保護するためのヘッダです。
CSPを使用することで、許可されたリソースのみが読み込まれるように制限できます。
以下のように指定することで、scriptSrcをnonceで指定し、デフォルトのリソースを'self'に制限し、Client-Componentだけを許可するのも容易です。
export default createRoute(
secureHeaders({
strictTransportSecurity: "max-age=63072000; includeSubDomains; preload",
contentSecurityPolicy: import.meta.env.PROD
? {
scriptSrc: [NONCE],
defaultSrc: ["'self'"],
}
: undefined,
})
);
nonce
attributeの詳細は公式Docを参照してください。
Permissions-Policy の設定
Permissions-Policyは、カメラ、マイク、ジオロケーションなどの機能へのアクセスを制御するためのヘッダです。
ブラウザによって対応状況が異なるため、注意が必要です。
export default createRoute(
secureHeaders({
strictTransportSecurity: "max-age=63072000; includeSubDomains; preload",
contentSecurityPolicy: import.meta.env.PROD
? {
scriptSrc: [NONCE],
defaultSrc: ["'self'"],
}
: undefined,
}),
async (c, next) => {
c.res.headers.append(
"Permissions-Policy",
"accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=()"
);
await next();
}
);
また、今回の実装でPermissions-PolicyもSecure Headersで提供されたいと思ったため、HonoにPRを作成しました。
以下のように指定できる機能が4.6.0でリリースされました。
app.use(
'*',
secureHeaders({
permissionsPolicy: {
fullscreen: ['self'], // fullscreen=(self)
bluetooth: ['none'], // bluetooth=(none)
payment: ['self', 'https://example.com'], // payment=(self "https://example.com")
syncXhr: [], // sync-xhr=()
camera: false, // camera=none
microphone: true, // microphone=*
geolocation: ['*'], // geolocation=*
usb: ['self', 'https://a.example.com', 'https://b.example.com'], // usb=(self "https://a.example.com" "https://b.example.com")
accelerometer: ['https://*.example.com'], // accelerometer=("https://*.example.com")
gyroscope: ['src'], // gyroscope=(src)
magnetometer: [
'https://a.example.com',
'https://b.example.com',
], // magnetometer=("https://a.example.com" "https://b.example.com")
},
})
)
余談
OGP と Twitter Card の設定
HonoXでは、app/routes/_renderer.ts
ファイルを作成し、<meta>
タグを追加することで、Open Graph(OG)やTwitter Cardを設定できます。
// app/routes/_renderer.ts
export default jsxRenderer(({ children, frontmatter }) => {
const c = useRequestContext()
const currentPath = c.req.routePath
// 省略
return (
<html lang='ja'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta http-equiv='content-language' content='ja' />
{/* OGP */}
<meta property='og:site_name' content={SITE_TITLE} />
<meta property='og:title' content={pageTitle} />
<meta property='og:description' content={description} />
<meta property='og:image' content={ogImagePath} />
<meta property='og:locale' content='ja_JP' />
{/* Twitter */}
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:site' content='@kbkn3' />
<meta name='twitter:title' content={pageTitle} />
<meta name='twitter:description' content={description} />
// 省略
</head>
<body>
<BaseLayout title={headerTitle} top={isTop}>
{children}
</BaseLayout>
</body>
</html>
)
})
HonoXのレンダラーをReactに変更することで、HonoXにapiを作成してOGPを取得できますが、今後作るアプリケーションでも使いたいので、OGP作成用のapiを作成しました。
別リポジトリでHono(not HonoX)を利用してOGP作成用のapiを作成し、CloudFlare Workerにデプロイしています。
OGP作成用のpackageとして有名な@vercel/og
がありますが、Next.jsを前提にしているのでCloudFlare Worker向けにsatori
やresvg
を使っている例があります。
今はWorkerで使えるようにした@cloudflare/pages-plugin-vercel-og/api
というパッケージが提供されているのでこれを使いました。
daisyUI と Tailwind CSS
daisyUIは、Tailwind CSS上に構築されたコンポーネントライブラリです。
ModalやDropdownなどの便利なコンポーネントを提供していますが、Tailwind以外に依存しないため、hono/jsx
と組み合わせやすく、どんなレンダラーでも使えるのでお勧めです。
今回、モバイル用のハンバーガーメニューをdaisyUIのdropdownコンポーネントを使って作成しました。
dropdownが以下のコードで実装できてしまいます。
<details className="dropdown">
<summary className="btn m-1">open or close</summary>
<ul className="menu dropdown-content bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
<li><a>Item 1</a></li>
<li><a>Item 2</a></li>
</ul>
</details>
Drizzle と D1 を使ったキリ番カウント
今回、サイト上にキリ番カウントを設けたかったので、DrizzleとD1を組み合わせました。
DrizzleはTypeScriptファーストのO/Rマッパで、D1はCloudFlareが提供するSQLiteベースのデータベースサービスです。
(個人レベルでは)無料・高速なD1は個人開発にはぴったりです。CloudFlareのサービスですので、CloudFlare Pagesとの相性も抜群です。
HonoXプロジェクトでこれらを利用するには、以下の手順を実行します。
必要なパッケージをインストールします。
bun add drizzle-orm
bun add -D drizzle-kit
Drizzleのスキーマを定義します(app/db/schema.ts)。
import { sql } from 'drizzle-orm'
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
// キリ番カウント用のテーブル
export const counter = sqliteTable('counter', {
id: integer('id').primaryKey({ autoIncrement: true }),
count: integer('count').default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(strftime('%s', 'now'))`),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(strftime('%s', 'now'))`)
})
// キリ番取得者用のテーブル
export const getter = sqliteTable('getter', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name'),
count: integer('count').default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(strftime('%s', 'now'))`),
})
D1データベースとの接続を設定します。drizzleに渡す環境変数は、dev.vars
に置き、起動時に読ませています。
HonoのContextに含まれているので、c.env
から取得できます。
// app/lib/getKiriban.ts
import { Context } from 'hono'
import { drizzle } from 'drizzle-kit'
import { getter } from '../db/schema'
/**
* kiribanを取得した人の名前と訪問者数のリストを返します。
* @param c - コンテキストオブジェクト。
* @returns kiribanを取得した人の名前と訪問者数のリスト。
*/
export const getKiriban = async (c: Context) => {
// データベース接続
const db = drizzle(c.env.DB)
// kiribanを取得した人の名前と訪問者数のリストを取得
const kiribanList = await db
.select()
.from(getter)
return kiribanList
}
今回はLayoutコンポーネントにキリ番カウントを表示するため、BaseLayoutコンポーネントでContextを使ってキリ番カウントの取得と取得者リストのアップデートを行っています。
export default async function BaseLayout({
children,
title = '資料集',
top = false,
}: {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
children: any
title?: string
top?: boolean
}) {
const c = useRequestContext()
const currentNumberOfVisitors = await kiribanUpdate(c)
const kiribanList = await getKiriban(c)
drizzleの詳細は公式Docを参照してください。
まとめ
この記事では、HonoXでSecureなサイトを構築する方法を紹介しました。
個人開発や小規模プロジェクトでも、セキュリティを考慮することは非常に重要です。Honoのmiddlewareを活用することで、比較的容易にセキュアなWebアプリケーションを構築できます。
今後も、セキュリティに関する知識を深め、安全なWeb開発を心がけていきましょう。
「現代社会で乙女ゲームの悪役令嬢をするのはちょっと大変」は史実の歴史を元にした政治経済小説です。1990年代の日本を舞台に、主人公が乙女ゲームの悪役令嬢に転生し、歴史改竄と経済バトルに身を投じる悪役令嬢「桂華院瑠奈」の奮闘記です。
小説家になろうで連載中ですので、興味がある方はぜひ読んでみてください。
Discussion