🔥

HonoXでお手軽にSecureなサイトを作成する

2024/09/12に公開

個人開発で忘れがちなことの1つにセキュリティがあります。
最近、評価の高い「フロントエンド開発のためのセキュリティ入門 知らなかったでは済まされない脆弱性対策の必須知識」の輪読会をする中で、セキュリティ対策を一通り実験してみたくなりました。
そこで、HonoXを使って、Secureなサイトを作成することにしました。

この記事では、HonoXを使って、Secureなサイトを作成する方法を紹介します。
採用した技術は以下の通りです。

  • HonoX
  • daisyUI
  • Drizzle
  • D1
  • bun


小説家になろうで連載中の「現代社会で乙女ゲームの悪役令嬢をするのはちょっと大変」のファンサイトを作成しました。

実際に作成したサイトはこちらです。
https://gensya-akuyaku-source.pages.dev/

GitHubリポジトリはこちらです。
https://github.com/kbkn3/gensya-akuyaku-source

HonoX とは

HonoXとは「HonoとViteを組み合わせたメタフレームワーク」です。
Honoは、Edge、Node.js、Denoでも動作するJavaScriptのFWです。
詳細は作者のyusukebe氏の記事を参照してください。

https://zenn.dev/yusukebe/articles/0c7fed0949e6f7
https://zenn.dev/yusukebe/articles/724940fa3f2450

フロントエンドのセキュリティ対策

今回対象としたのは、Security Headers Powered by Probelyなどの検証ツールで確認ができるヘッダの設定です。

Security Headersで確認できる主なヘッダは以下の通りです。

  1. Content Security Policy (CSP)
  2. X-Frame-Options
  3. X-Content-Type-Options
  4. Referrer-Policy
  5. Permissions-Policy
  6. 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


https://hono.dev/docs/middleware/builtin/secure-headers

各オプションによるセキュリティ評価の変化

Security Headers Powered by Probelyを使って、各オプションを設定した場合のセキュリティ評価を確認しました。

Secure Headers なし

secureHeadersを設定しない場合、セキュリティを高めるヘッダが設定されていないため、セキュリティ評価が低くなります。

no secureHeaders

secureHeaders のみ

secureHeadersのみを設定した場合でも、多くの場合で設定する項目はデフォルト値が設定されており、セキュリティ評価が向上します。

export default createRoute(secureHeaders());

add 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だけを許可するのも容易です。

https://developer.mozilla.org/ja/docs/Web/HTTP/CSP
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Strict-Transport-Security

export default createRoute(
  secureHeaders({
    strictTransportSecurity: "max-age=63072000; includeSubDomains; preload",
    contentSecurityPolicy: import.meta.env.PROD
      ? {
          scriptSrc: [NONCE],
          defaultSrc: ["'self'"],
        }
      : undefined,
  })
);

add CSP/HSTS

nonce attributeの詳細は公式Docを参照してください。

https://hono.dev/docs/middleware/builtin/secure-headers#nonce-attribute

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

add Permissions-Policy

また、今回の実装でPermissions-PolicyもSecure Headersで提供されたいと思ったため、HonoにPRを作成しました。
以下のように指定できる機能が4.6.0でリリースされました。

https://github.com/honojs/hono/releases/tag/v4.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にデプロイしています。

https://github.com/kbkn3/ogp-image-generator

OGP作成用のpackageとして有名な@vercel/ogがありますが、Next.jsを前提にしているのでCloudFlare Worker向けにsatoriresvgを使っている例があります。

https://zenn.dev/uzimaru0000/articles/satori-workers

今はWorkerで使えるようにした@cloudflare/pages-plugin-vercel-og/apiというパッケージが提供されているのでこれを使いました。

https://github.com/kbkn3/ogp-image-generator/blob/develop/src/gensya.tsx#L1-L16

https://developers.cloudflare.com/pages/functions/plugins/vercel-og/

daisyUI と Tailwind CSS

daisyUIは、Tailwind CSS上に構築されたコンポーネントライブラリです。
ModalやDropdownなどの便利なコンポーネントを提供していますが、Tailwind以外に依存しないため、hono/jsxと組み合わせやすく、どんなレンダラーでも使えるのでお勧めです。
今回、モバイル用のハンバーガーメニューをdaisyUIのdropdownコンポーネントを使って作成しました。

daisyui

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>

https://daisyui.com/

Drizzle と D1 を使ったキリ番カウント

今回、サイト上にキリ番カウントを設けたかったので、DrizzleとD1を組み合わせました。

kiriban

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を参照してください。

https://orm.drizzle.team/docs/get-started-sqlite#cloudflare-d1

まとめ

この記事では、HonoXでSecureなサイトを構築する方法を紹介しました。
個人開発や小規模プロジェクトでも、セキュリティを考慮することは非常に重要です。Honoのmiddlewareを活用することで、比較的容易にセキュアなWebアプリケーションを構築できます。
今後も、セキュリティに関する知識を深め、安全なWeb開発を心がけていきましょう。


「現代社会で乙女ゲームの悪役令嬢をするのはちょっと大変」は史実の歴史を元にした政治経済小説です。1990年代の日本を舞台に、主人公が乙女ゲームの悪役令嬢に転生し、歴史改竄と経済バトルに身を投じる悪役令嬢「桂華院瑠奈」の奮闘記です。

小説家になろうで連載中ですので、興味がある方はぜひ読んでみてください。

LIFULLテックブログ

Discussion