セキュリティモデル

管理画面、問い合わせフォーム、シークレットの守り方と、goodnight-web では守れないものをまとめます。

goodnight-web が何をどう守るかと、運用であなたがすることをまとめます。守れないものは守れないものの節にあります。

運用でやること

  • APP_SECRET は 32 文字以上のランダムな値にします(シークレット)。
  • セットアップが終わっても APP_SECRET は残し、控えをパスワードマネージャーなどに保管します。サイトはログインと IP アドレスのハッシュにこの値を使います。別に SETUP_TOKEN を設定した場合は、セットアップ後に削除します。
  • Cloudflare アカウントの 2 要素認証を有効にします(データベースがネットワークに公開されない理由)。
  • 管理用のパスに Cloudflare Access を設定します。任意ですが、強く推奨します(Cloudflare Access)。
  • 独自ドメインで動くようになったら、wrangler.jsonc で "workers_dev": false にしてコミットします。プレビュー URL は最初から無効です("preview_urls": false)。
  • ゾーンの Always Use HTTPS を有効にします(HTTPS)。
  • リポジトリは private にし、公式アップデートのコメントが付き、更新チェックが成功している PR だけをマージします(更新とリポジトリ)。

原則

  • 足りなければ止めます(フェイルクローズ)。 必要な設定やシークレットが足りなければ、それを使う機能を止めます。たとえば TURNSTILE_SECRET がなければ、問い合わせ API は 503 を返します。
  • ログインなしで管理画面を開かせません。 開発用の抜け道、既定のアカウント、共有のキーはありません。
  • 問い合わせを失いません。 応答の前に保存します。スパムの疑いがある問い合わせ(honeypot が埋まっている、スパムスコアが高い)も捨てずに保存し、分けて表示します。
  • 個人情報を Cloudflare の外に出しません。 通知に個人情報を含めません。生の IP アドレスも保存しません。

管理画面

項目扱い
ログインBetter Auth の Passkey だけです。メールとパスワードでのログインは無効です。パスワードのハッシュ処理は、Workers 無料プランの CPU 上限を超えることがあります(better-auth#8860)。Passkey でのログインを参照してください。
セッション管理 API が受け付けるのは、そのユーザーの最初の Passkey 以降に作られたセッションだけです。セットアップで作られたセッションは管理者のセッションになりません。セッションとセットアップを参照してください。
ADMIN_EMAILSリクエストのたびに読み直します。アドレスを外すと、次のリクエストからそのセッションでは管理 API を使えず、そのリクエストでセッションも削除します。管理者を外すを参照してください。
HTTPS管理画面、管理 API、セットアップ、ログイン、問い合わせ API は https:// でだけ動きます。HTTPS を参照してください。
Cloudflare Access任意ですが、強く推奨します。50 ユーザーまで無料です。Cloudflare Access を参照してください。
最初の管理者セットアップトークン(APP_SECRET、または設定していれば SETUP_TOKEN)と、ADMIN_EMAILS にあるメールアドレスが必要です。最初の Passkey を登録するとセットアップは完了し、それ以降は使えません。最初の管理者の作成を参照してください。
Cloudflare WAF任意です。設定したルールで、ドメインへのリクエストを制限します。Cloudflare WAF を参照してください。
workers.dev とプレビュー URLAccess のポリシーから漏れやすいアドレスです。プレビュー URL は starter の wrangler.jsonc で無効にしてあります。workers.dev は、独自ドメインを追加した後に同じファイルで "workers_dev": false にするまで有効です。ダッシュボードで切り替えても、次のデプロイで元に戻ります。Access がなくても、管理画面には Passkey でのログインが必要です。
リクエストの大きさログインのリクエスト(/api/auth/*)は application/json で送る必要があり、本文は 64 KiB までです。セットアップと、管理 API での問い合わせの更新は 4 KiB までです。これより大きい本文には、読み取る前に 413 を返します。Better Auth には渡しません。
CSRFOrigin の確認と SameSite の Cookie で防ぎます。
ヘッダーWorker の応答に Content Security Policy などのセキュリティヘッダーを付けます。パスを正規化してから決めるので、/%61dmin、//admin、/admin/..%2f にも /admin と同じヘッダーが付きます。

管理画面のページ自体はセッションなしでも読み込めます。ただし、データの取得には必ずセッションが必要です。

公開ページと管理画面は同じオリジン

サイトのページ、管理画面、管理 API は、同じオリジン(たとえば https://example.com)から配信されます。そのため、ブラウザは公開ページのスクリプトを、管理画面のスクリプトと同じものとして扱います。管理者がログインしている間、そうしたスクリプトは管理者のセッションで管理 API を呼び出し、問い合わせやフォームを読んだり、書き出したり、削除したり、変更したりできます。HttpOnly の Cookie と Origin の確認では、同じオリジンで動くスクリプトは止められません。

starter は、このリスクを次のように小さくしています。

  • ページはご自身のリポジトリからビルドされます。ほかのオリジンから読み込むのは Cloudflare Turnstile だけです。
  • public/_headers が、すべてのページに Content Security Policy を付けます。スクリプトはサイト自身と https://challenges.cloudflare.com からだけ許可し、インラインのスクリプトは許可せず、ほかのサイトへの埋め込みを禁止します(frame-ancestors 'none')。そのために、Astro はすべてのスクリプトを /_astro/ の下のファイルに書き出します。管理画面と API には、Worker がより厳しいヘッダーを付けます。
  • starter の AGENTS.md は、頼まれない限り、外部のスクリプト、埋め込み、スクリプトを含む HTML を追加しないよう AI ツールに伝えます。

アクセス解析のタグ、チャットのウィジェット、タグマネージャー、スクリプトを読み込む埋め込みを追加すると、それらはこの権限で動きます。信頼できるものだけを追加し、そのオリジンをご自身で public/_headers に加えてください。完全に分けるには、管理画面に別のホスト名(たとえば admin.example.com)を使い、/admin、/api/admin/*、/api/setup、/api/auth/* だけを配信して、公開ページは配信しない構成にするしかありません。

セッションとセットアップ

管理者のセッションになれるのは、Passkey でのログインか、最初の Passkey の登録で作られたセッションだけです。そのために、次の決まりがあります。

  • すでにユーザーがいるアドレスでセットアップをやり直すと、そのユーザーの以前のセッションを先に削除します。
  • 最初の Passkey を登録するとセットアップが完了し、データベースのセッションをすべて削除します。ブラウザには、Passkey の登録で作った新しいセッションを渡します。
  • セットアップの完了後は、Passkey を持たないユーザーは Passkey を登録できません。セットアップのときのセッションが残っていても、別の管理者は作れません。
  • 最初の Passkey より前に作られたセッションには、管理 API は 401 を返します。削除されずに残っていても同じです。

HTTPS

平文の HTTP では、セットアップトークン、セッションの Cookie、Passkey のやり取り、訪問者が問い合わせフォームに入力した値が暗号化されずに流れます。そこで、/admin、/admin/* と、/api/ の下のすべてのパスでは https:// を必須にします。管理 API、セットアップ、ログイン、問い合わせ API が対象です。

  • http:// での GET と HEAD は、同じ URL の https:// にリダイレクトします(308)。
  • http:// でのそれ以外のメソッドには、コード https_required の 403 を返します。本文はもう送られてしまっているので、リダイレクトしません。
  • wrangler dev で使えるように、localhost、127.0.0.1、[::1] は対象外です。

そのため、http:// で開いたページの問い合わせフォームは、読み込みも送信もできません。静的なページはこの決まりの対象外です。すべてのページを HTTPS で配信するには、ゾーンの Always Use HTTPS を有効にしてください(Cloudflare ダッシュボード > SSL/TLS > Edge Certificates)。

Cloudflare Access

Cloudflare Access を使うと、Passkey の前にもう 1 段のログインを置けます。たとえば Google のログインや、メールで届くワンタイムコードを使えます。50 ユーザーまで無料です。

Access のアプリケーションは、次のパスに設定します。

  • /admin と /admin/*
  • /api/admin/*
  • /api/setup
  • /api/auth/*

/api/contact は公開したままにしてください。訪問者の問い合わせフォームはここに送信します。

Worker にも Access のトークンを確認させるには、次の 2 つを設定します。

設定値
ACCESS_AUDAccess アプリケーションの Application Audience (AUD) Tag。カンマで区切って複数指定できます。
ACCESS_TEAM_DOMAINチームドメイン。たとえば your-team.cloudflareaccess.com です。

両方あると、Worker は上のパスで Cf-Access-Jwt-Assertion ヘッダーを確認します。最初の管理者を作るセットアップにも、正しいトークンが必要になります。確認するのは、RS256 の署名、発行者、aud、exp、nbf です(Cloudflare: JWT の検証)。署名の鍵は https://<チームドメイン>/cdn-cgi/access/certs から取得し、Worker が 1 時間キャッシュします。取得に失敗したときは 10 秒間は取得し直さず、その間のリクエストには 503 を返します。

  • トークンがない、または正しくないときは 403(access_denied)を返します。
  • 2 つの設定の片方しかないとき、ACCESS_TEAM_DOMAIN がホスト名だけの形でないとき、署名の鍵を取得できないときは、これらのパスは 503(access_unavailable)を返します。
  • どちらの設定もなければ、Worker はトークンを確認しません。サイトの前の Access はそのまま働きます。Worker が二重に確認しないだけです。
  • Access を使っていても、Passkey でのログインは必要です。

Cloudflare WAF

Cloudflare WAF は、ドメインへのリクエストを、Worker に届く前にルールで確認します。任意の機能です。goodnight-web は WAF に頼っていません。Turnstile、レート制限、入力の確認は、WAF の有無にかかわらず Worker の中で動きます。

WAF のルールは、Cloudflare アカウントに追加したドメイン(ゾーン)に対して働きます。カスタムルールは、URL のパスや IP アドレスなどでリクエストを判定し、ブロックしたりチャレンジを出したりできます。どのプランでも使えますが、作れるルールの数はプランによって違います。詳しくは Cloudflare のドキュメントを参照してください。

ルールは、本番で頼る前に試してください。/api/contact をブロックしたりチャレンジを出したりするルールは、実際の訪問者が問い合わせを送れなくなる原因になります。管理画面に入れる人を絞りたいときは、Cloudflare Access が向いています。

管理者を外す

管理者を外すには、ADMIN_EMAILS からそのアドレスを消します。Worker はリクエストのたびに ADMIN_EMAILS を読むので、その管理者が次に管理 API へ送るリクエストは 403 になります。同じリクエストで、そのユーザーのセッションをすべて削除します。そのリクエストまでは、セッションは D1 に残ります。先にアドレスを戻すと、そのセッションはまた使えます。今すぐ終わらせるには、下のコマンドを実行してください。Passkey でログインし直しても使えません。次の管理 API へのリクエストがそのセッションを拒否し、同じように削除します。

この確認の対象は管理 API(/api/admin/*)です。Passkey の追加など、/api/auth/* の下にある Better Auth 自身のエンドポイントは ADMIN_EMAILS を読みません。ただし、そこでできることでは、問い合わせやフォームには届きません。

管理 API へのリクエストでは、セッションの有効期限を延ばしません。延ばすのは Better Auth 自身のセッションのエンドポイントだけです。そのため、外された管理者が管理 API を使ってセッションを保ち続けることはできません。

ユーザーと Passkey は D1 に残ります。そのため、同じアドレスを戻すと、同じ Passkey でまたログインできます。削除済みのセッションは戻りません。ユーザーと Passkey を完全に消すには、次のコマンドをご自身で実行してください(アドレスは置き換えます)。

npx wrangler d1 execute DB --remote --command "DELETE FROM session WHERE user_id IN (SELECT id FROM user WHERE email = 'old@example.com'); DELETE FROM passkey WHERE user_id IN (SELECT id FROM user WHERE email = 'old@example.com'); DELETE FROM account WHERE user_id IN (SELECT id FROM user WHERE email = 'old@example.com'); DELETE FROM user WHERE email = 'old@example.com';"

このコマンドは form_definitions.created_by には触れません。この列には、フォームの各バージョンを保存した管理者のアドレスが残ります。このテーブルは追記しかできない(データベースのトリガーが更新と削除を拒否する)ため、アドレスはデータベースがある限り残ります。

これでセットアップがまた使えるようになることはありません。settings.setup_closed_at は残ります。Passkey をすべて失くしたときなどに、意図してセットアップを開き直す手順はPasskey が使えないとき、失くしたときにあります。

問い合わせフォーム

問い合わせ API は、送信を次のように確かめてから保存します。しくみは問い合わせフォームの保護で説明しています。

  • 本文を読む前に、Origin、Content-Type、本文のサイズを確認します。
  • Turnstile をサーバー側で検証し、hostname と action も確認します。
  • Rate Limiting binding で、ハッシュにした IP アドレスごとに送信回数を制限します。IPv6 では /64 のプレフィックスごとに数えます。
  • 保存する送信は、すべての送信者の合計で 1 時間に 500 件までです。それを超えると、API は 503(contact_busy)を返し、何も保存しません。同時に届いたリクエストは、上限をわずかに超えることがあります。
  • honeypot 欄が埋まっていても、応答は変えません。その送信も Turnstile、回数制限、検証を通ったときだけ、スパムの疑いとして保存します。honeypot 欄の値そのものは保存しません。
  • スパムスコアが高い送信にも、スパムの疑いの印を付けます。スパムの疑いは Slack に通知せず、管理画面では分けて表示します。
  • ページに API キーを入れません。ブラウザが読めるキーは秘密ではなく、守っているという誤解を生むだけです。

データベースがネットワークに公開されない理由

D1 は、ネットワーク越しに接続して使うデータベースではありません。Postgres や MySQL はドライバーを使って TCP で接続しますが、D1 について Cloudflare のドキュメントは、ネットワーク接続ではなく Workers の binding を通して使うと説明しています。D1 を操作する方法は、次の 3 つです(Cloudflare: Query a database)。

  • binding を設定した Worker。 binding を設定すると、その Worker だけがデータベースを使えます。Worker がデータベースのパスワードやキーを持つ必要はありません(Cloudflare: Bindings)。
  • REST API。 リクエストには API トークン(古い方式の API キーでも可)が必要で、トークンには D1 Read か D1 Write の権限が要ります(Cloudflare: Query D1 Database)。
  • Wrangler と Cloudflare ダッシュボード。 Wrangler のコマンドは REST API を使います。ダッシュボードは、アカウントにログインして使います。

保存されたデータは、保存時に暗号化されます。Cloudflare のドキュメントでは、D1 に保存されるすべてのオブジェクトが対象で、自動で行われ、設定は要らないと説明されています(Cloudflare: D1 data security)。

goodnight-web で DB binding を使うのは、Worker のコードだけです。問い合わせ API、管理 API、Better Auth のログイン用ルートが、この binding を通してデータベースを読み書きします。ビルドしたページは静的なファイルで、訪問者が送れるのは問い合わせフォームだけです。その値は、Worker が確かめてから保存します。そのため、訪問者がデータベースへ直接クエリを送る手段はなく、守る必要があるのは次の 3 つです。

  • Cloudflare アカウント。 ログインできる人や、D1 の権限を持つ API トークンを持つ人は、データベースを読めます。2 要素認証を有効にします。
  • 管理画面のログイン。 Passkey を使います。さらに手前に Cloudflare Access を置けます。
  • Worker のコード。 値はすべて Drizzle を通して、バインドされたパラメーターとして D1 に届きます。入力は、使う前に確かめます。SQL インジェクションを参照してください。

SQL インジェクション

サーバーは入力を SQL の文字列に入れません。値はすべて、バインドされたパラメーターとして D1 に送ります。

  • すべてのクエリで Drizzle ORM のクエリビルダーを使います。管理画面の一覧の件数とオフセットもパラメーターです。
  • URL から来る数値は、クエリに渡す前に、API のパスごとの処理(ルート)で確かめます。page は 1 から 100,000、limit は 1 から 100 で、数字だけで書かれている必要があります。1; DROP TABLE submissions、-1、1e9、50 OFFSET 0 のような値には 400 を返します。
  • 生の SQL は、値をバインドする Drizzle の sql テンプレートでだけ書けます。サーバーのコードが sql.raw() や D1 の prepare() を直接呼ぶと、CI のチェック(bun run check:sql)が失敗します。Drizzle の $client で D1 の binding に触れたときも同じです(データベースを参照)。
  • メールアドレスの検索では、%、_、\ をエスケープします。パターンは 1 つのパラメーターとして、ESCAPE '\' 付きで送ります。
  • テストでは、一覧の API にインジェクション用の文字列を送り、400 になることを確かめます。データベースに送った文も記録し、入力がパラメーターにだけ現れることを確かめます。

URL の数値は、ルートで次のスキーマを通します。数字だけの文字列を、上限付きの整数に変えます。

packages/server/src/features/admin-api/admin-query.ts
const boundedInteger = (max: number) =>
  z
    .string()
    .max(String(max).length)
    .regex(/^[1-9][0-9]*$/)
    .transform(Number)
    .pipe(z.number().int().min(1).max(max));
packages/server/src/features/admin-api/admin-query.ts
export const listQuerySchema = z
  .strictObject({
    ...filterShape,
    dir: z.enum(['asc', 'desc']).default('desc'),
    page: boundedInteger(ADMIN_MAX_PAGE).default(1),
    limit: boundedInteger(ADMIN_MAX_PAGE_SIZE).default(ADMIN_PAGE_SIZE),
  })
  .refine(bothOrNeither);

filterShape には、view(unread、read、all、spam)、q(メールアドレスの検索。254 文字まで)、バージョンと選択肢の絞り込みがあります。どれも zod のスキーマを持ちます。そのあと、ストアが Drizzle でクエリを組み立てます(データベースを参照)。

たとえば ?view=unread&q=50%_off&page=3 のとき、クエリを記録するテスト用の D1 には、次の文とパラメーターが届きます。

select "id", "created_at", "email", "status", "suspected_spam", ... from "submissions"
where (("submissions"."status" = ? and "submissions"."suspected_spam" = ?)
  and lower("submissions"."email") like ? escape '\')
order by "submissions"."created_at" desc, "submissions"."id" desc
limit ? offset ?
["unread", 0, "%50\\%\\_off%", 50, 100]

検索する文字、ページ、件数が変わっても、SQL の文字列は同じです。変わるのはパラメーターだけです。データベースを参照してください。

シークレット

  • シークレット(APP_SECRET、ADMIN_EMAILS、TURNSTILE_SECRET、SLACK_WEBHOOK_URL、任意の SETUP_TOKEN、BETTER_AUTH_SECRET、IP_HASH_SECRET)は、すべて Worker のシークレットに置きます。
  • シークレットをブラウザに返すことも、D1 に書くこともありません。Slack の Webhook URL は管理画面にも表示しません。管理画面で確認できるのは、URL が設定されているかどうかと、形式が正しいかどうかだけです。
  • 管理画面のログインセッションに署名する鍵と、IP アドレスをハッシュにする鍵は、Web Crypto の HKDF で APP_SECRET から作ります。用途ごとに決まったラベルを使うので、一方の鍵から、もう一方の鍵や APP_SECRET はわかりません。作った鍵は Worker のメモリーの中にだけあり、保存はしません。
  • SETUP_TOKEN を設定していなければ、APP_SECRET がセットアップトークンも兼ねます。この値を持つ人はセッションの鍵の元をすでに持っていてセッションに署名できるので、これで新しく得るものはありません。鍵を分けたい場合は、SETUP_TOKEN、BETTER_AUTH_SECRET、IP_HASH_SECRET を設定してください。設定した値が、作った鍵より優先されます(鍵を個別に設定する)。
  • セットアップ後に APP_SECRET を削除したり変えたりしないでください。削除すると、セットアップ、ログイン、管理 API、お問い合わせフォームが止まり、503 を返します。変えると、すべての管理者がログアウトされ、IP アドレスのハッシュも変わります。Passkey と問い合わせには影響しません。
  • どのシークレット(APP_SECRET と、任意の SETUP_TOKEN、BETTER_AUTH_SECRET、IP_HASH_SECRET)も 32 文字以上にしてください。短い値は設定されていないものとみなします。それを使うセットアップ、ログイン、問い合わせフォームは 503 を返します。使われている文字が 10 種類より少ない値や、短い文字列を繰り返しただけの値も同じ扱いです。この確認は、仮の値や手で打った値を見つけるための簡単なもので、ランダムさを測るものではありません。値は openssl rand -base64 32 で作ってください。goodnight-web も Deploy ボタンの設定フォームも、値を作りません。設定した値が短すぎる場合や、ランダムさが足りない場合も、APP_SECRET から作った鍵には置き換えません。
  • セットアップが完了するまでは、GET /api/setup が、足りない設定、短すぎる設定、ランダムさが足りない設定の名前を返します(値は返しません)。IP アドレスのハッシュに使う鍵がないときや、短すぎるとき、ランダムさが足りないときは、直すべきシークレットの名前を管理画面にも表示します。
  • Better Auth は、Worker の環境変数のいくつかを自分で読みます。goodnight-web は Origin と CSRF の確認を明示的に有効にしているため、TEST や NODE_ENV という名前の変数があっても、確認は止まりません。BETTER_AUTH_TRUSTED_ORIGINS と BETTER_AUTH_SECRETS は、信頼する Origin を増やしたり BETTER_AUTH_SECRET を置き換えたりする変数です。どちらかが設定されている間、ログインは 503 を返します。
  • 管理画面に更新ボタンはありません。あなたの GitHub リポジトリに書き込めるトークンが必要になるからです。

CSV の書き出し

  • 1 回のクエリで 200 行ずつ読み、順に送り出します。テーブルが大きくても、1 つのファイルとして Worker のメモリに載ることはありません。
  • 表計算アプリがセルを数式として実行しないようにします。目に見える最初の文字が =、+、-、@ のセルと、タブや復帰文字で始まるセルには、先頭に ' を付けます。表計算アプリは、その値を文字として表示します。前にある空白と、ゼロ幅スペースや双方向制御文字のような見えない文字は読み飛ばします。全角の =、+、-、@ も対象です。

更新とリポジトリ

  • 修正は Dependabot の PR で届きます。公式アップデートのコメントが付き、goodnight-web/update-check のチェックが成功している PR だけをマージしてください。配布と更新のしくみを参照してください。
  • リポジトリは private にしてください。public の間は、starter の公開範囲チェックが失敗します。
  • goodnight-web 自体の依存関係は少なくし、バージョンを固定しています。

守れないもの

  • あなたの Cloudflare アカウントや GitHub リポジトリに入れる人による操作。
  • あなたの Passkey が入った端末を、ほかの人が使うこと。
  • 公式アップデートではない PR をマージすること。
  • あなたがサイトに載せる内容と、starter に加えた変更のコード。
  • Turnstile と回数制限をすり抜けるスパム。honeypot とスパムスコアで多くに印を付けますが、通常の一覧に届くものもあります。

脆弱性の報告

脆弱性は GitHub Security Advisories で非公開に報告してください。goodnight-web リポジトリの Security タブを開き、Report a vulnerability を選びます。公開の Issue には書かないでください。

目次