ハコヅミを作る #35:公開の前に、セキュリティを見直す(ヘッダー、CSP、回数の制限)

前回(#34)で、公開までに要るものを Issue にしました。今回は、その1つ目の「セキュリティの見直し」です。

ハコヅミは、ここまで Cloudflare Access(ログインした人だけが開ける仕組み)の後ろで動いてきました。守りは、「自分と、招いた人しか使わない」前提で入れたものです。その前提がなくなるので、点検します。

  • 応答に、セキュリティのためのヘッダーを付ける
  • ほかのサイトからの送信と、大きすぎる送信を断る
  • 送信の回数に、上限を付ける
  • URL から画像を取り込む機能を、踏み台にされないようにする

9つの項目を、順に見る

Issue には、点検する項目を9つ並べました。Claude Code に順に見てもらい、結果を「直したもの」と「調べて、問題がなかったもの」に分けて、プルリクエストに書いてもらっています。

問題がなかったものも、書き残すのが大事だと思いました。「見ていない」のか「見て、大丈夫だった」のかは、あとからは区別できないからです。

項目 結果
応答のヘッダー 1つも付けていなかった。付けた
ほかのサイトからの送信 フォームの形は SvelteKit が断っていた。JSON の形も断るようにした
大きすぎる送信 上限がなかった。3 MB までにした
送信の回数 制限がなかった。付けた
URL からの画像の取り込み 行き先の確かめを、厳しくした
文字の欄の長さ 上限のない欄が7つあった。100 文字までにした
他人のデータを読めないこと 総点検して、問題なし
セッションのクッキー 問題なし(本番で確かめた)
依存パッケージ 1件を直し、2件は理由を書いて直さない

応答のヘッダーと、Content-Security-Policy

前回書いたとおり、ハコヅミは、セキュリティのためのヘッダーを1つも付けていませんでした。

付けたのは、次のものです。全部のリクエストが通る src/hooks.server.ts で、どの応答にも付けています。

ヘッダー 何のためか
X-Content-Type-Options: nosniff 中身の種類を、ブラウザに推測させない
Referrer-Policy ほかのサイトへ移るときに、どの画面から来たかを伝えない
X-Frame-Options: DENY ほかのサイトの枠の中に、表示させない
Permissions-Policy カメラ・マイク・位置情報は使わない、と伝える
Strict-Transport-Security 次からも https で開かせる

もう1つが、Content-Security-Policy(CSP)です。「このページが読み込んでよいのは、ここだけ」とブラウザに伝える指定です。万一、画面に悪いスクリプトを埋め込まれても、ブラウザが動かしません。

ハコヅミが読み込むのは、自分のサイトと、Web フォントの Google Fonts だけです。そのとおりに絞りました。SvelteKit には CSP の設定があり、画面に埋め込むスクリプトには、応答ごとの合い言葉(nonce)を付けてくれます。

自分で書いたスクリプトが、止まる

CSP を入れると、自分のコードが1か所、引っかかりました。Web フォントの読み込みです。

#21で、フォントの CSS が画面の表示を待たせないよう、次の書き方をしていました。

<link rel="stylesheet" href="…" media="print" onload="this.media='all'" />

よく使われる書き方です。ただ、onload="…" は、HTML に直接書いたスクリプトです。CSP は、これを止めます。止まると、フォントの CSS がいつまでも効かず、文字が端末のフォントのままになります。

切り替えの処理を、Svelte の側(onMount)に移しました。「HTML の中に、スクリプトを直接書かない」は、CLAUDE.md の決まりにも足しています。

ログインのボタンも、止まりうる

もう1つ、気をつけた点があります。CSP には、「フォームの送り先」を絞る指定(form-action)もあります。

「Google でログイン」のボタンは、フォームを自分のサイトに送り、そのあと Google の画面へ移ります。この「移る先」も、ブラウザによっては送り先として確かめられます。Google を入れ忘れると、ボタンを押しても何も起きません。

E2E テストは、Google へは行きません(#27)。つまり、ここは自動テストでは確かめられません。開発環境にマージしたあと、自分でログインして確かめました。


ほかのサイトからの送信と、大きすぎる送信

ログインした人が、悪意のあるサイトを開いたとします。そのサイトが、こっそりハコヅミへ「ギアを削除する」送信をすると、ログインのクッキーが付いて、通ってしまうおそれがあります。

SvelteKit は、フォームの形の送信については、送り元(Origin)を確かめて断ってくれます。ただ、JSON の形の送信は、見ていません。ハコヅミには、JSON で受ける入口があります(URL からの画像の取り込み)。そこで、形を問わず、送り元が自分のサイトと違う送信は、すべて断るようにしました。

大きすぎる送信も断ります。画像は、ブラウザで縮小してから送るので、1 MB までです。ほかの欄を足しても 3 MB あれば足りるので、それを超えたら、本文を読み込む前に断ります。

判定は、どちらも短い関数に切り出して、ユニットテストしています。

export function isCrossSiteRequest(method: string, origin: string | null, url: URL): boolean {
	if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false;
	return origin !== null && origin !== url.origin;
}

送信の回数の制限

#29で、登録数の上限を作りました。件数は止まります。ただ、「登録して、消す」をくり返すことは、止まりません。

回数の制限には、Cloudflare Workers の Rate Limiting を使いました。Cloudflare の WAF(ファイアウォール)のルールで止める手もありましたが、次の理由で、こちらにしています。

  • 設定のファイル(wrangler.jsonc)とコードの中で完結する。ダッシュボードで作るものがない
  • 人ごとに数えられる(WAF は、IP アドレスごと)
  • 手元と CI で、試せる
  • 無料の範囲で使える

枠は、3つに分けました。

対象 1分あたり 数える単位
登録・保存・削除・チェックなどの送信 300 回 人
URL から画像を取り込む 10 回 人
ログインまわり 30 回 IP アドレス

300 回は、持ち物チェックを続けて押しても当たらない数、として決めました。1秒に5回のペースです。車に荷物を積みながらチェックを付ける人を、止めてはいけません。画像の取り込みは、サーバーがよそのサイトを読みに行くので、いちばん厳しくしています。

数は、どれも仮です。ベータ版で、ふつうに使っていて当たる人がいないかを見ます。

テストが、ほかのテストを止めないように

回数の制限は、E2E で試しています。10 回までは通り、11 回目から断られること。ほかの人は、断られないこと。

ここで気をつけたのは、全部のテストで使うユーザーの枠を、使い切らないことです。使い切ると、あとに流れるテストが、関係ないところで断られます。回数の制限のテストは、テストの中で別のユーザーを作って行う、と決めました。


URL から画像を取り込む機能

#12で作った、URL を貼ると画像を取り込む機能です。サーバーが、利用者の貼った URL を読みに行くので、点検の中でも、いちばん気をつけるところでした。

悪用のしかたは、こうです。サーバーに、外からは届かない場所(社内のネットワークや、サーバー自身)を読みに行かせます。

作ったときから、https だけ、という確かめは入れていました。今回、行き先の名前も確かめるようにしました。

  • IP アドレスの直書きは、断る
  • localhost や、.local・.internal で終わる名前は、断る
  • リダイレクトの行き先も、同じように確かめる
ギアの登録画面の画像の欄。URL の欄に IP アドレスを直書きした URL が入っていて、その下に赤い文字で「https で始まる URL を入力してください」と出ている

IP アドレスを直書きした URL は、取り込まない。断った理由は、細かく出さない

画像の数の上限も、ここに掛けました。上限に達した人が、取り込みだけをくり返せたからです。ただし、今ある画像の差し替えは、数が増えないので通します。そのために、「どのギアの画像を差し替えるのか」を、取り込みのときに伝えるようにしました。伝えられたギアが、本当にその人のもので、画像が付いているかは、サーバーで確かめます。

断る名前を、絞りすぎた

1つ、つまずきがありました。断る名前の一覧に、最初は .test と .invalid も入れていました。どちらも、実在しないと決まっている名前です。

ところが、今までの E2E が、まさにその名前を使っていました。「届かない URL を貼ったら、エラーになる」ことを確かめるテストです。実在しない名前は、どのみち読めずにエラーになります。断る一覧からは外しました。


上限のない欄が、7つあった

点検で見つかった、いちばん素朴な抜けです。

メモは 1000 文字まで、表示名は 30 文字まで、と決めてきました。ところが、ギアの製品名・メーカー・カテゴリ、消耗品の名前・カテゴリ、コンテナとプランの名前には、長さの上限がありませんでした。

件数の上限があっても、1件にとても長い文字を入れれば、データベースを埋められます。100 文字までにしました。

ギアの登録画面。製品名の欄に長い文字が入っていて、欄が赤い枠になり、その下に赤い文字で「製品名は 100 文字までにしてください」と出ている

名前などの短い欄は、100 文字まで

気になったのは、今あるデータです。100 文字を超える名前が入っていると、そのデータは、編集しても保存できなくなります。本番の D1 のコンソールで数えて、0 件であることを確かめました。


調べて、問題がなかったもの

他人のデータを読めないこと

データベースを読む・書く関数を、全部洗い出してもらいました。確かめたのは、「誰のデータか」で絞っているか、です。

絞っていない関数は、2種類だけでした。

  • 親(プランやキャンプ)が自分のものだと確かめたあとに呼ぶ、子のテーブルの関数
  • 管理画面の関数(管理者だと確かめたあとに呼ぶ)

どちらも、意図したとおりです。あわせて、「どの入口でも、ほかの人のデータを読めない・変えられない」ことを確かめる E2E を、1本にまとめて足しました。ほかの人のギア・消耗品・コンテナ・プラン・キャンプについて、画面、画像、編集、削除、チェックを試し、データベースが変わっていないことを見ます。

依存パッケージ

npm audit は、3つを指摘しました。

指摘 判断
undici(高) 直した。開発用の道具(wrangler)の中のもの
cookie(低) 直さない。クッキーの名前はアプリが決めていて、利用者の入力は使わない
esbuild(中) 直さない。開発サーバーの話で、本番には入らない

直さないものも、理由を書いて残しました。次に同じ指摘が出たとき、もう一度調べなくて済みます。


本番で確かめる

自動テストでは確かめられないものが、2つ残りました。本番に出してから、自分で見ています。

セッションのクッキー。ブラウザの開発者ツールで、Secure・HttpOnly・SameSite が付いていることを見ました。Better Auth が、https のときに付けてくれます。

ここで、思わぬものも見つかりました。このブログの Google アナリティクスのクッキーが、ハコヅミにも送られています。ドメインが同じ jiru-labo.com だからです。ハコヅミは読んでいませんが、プライバシーポリシーを書くときに、どう扱うかを決めます。

エラーの応答。ないページや、ない id のキャンプを開いて、スタックトレースや SQL が出ないことを見ました。出るのは、番号と短い一言だけです。


全件のテストを、手元でも流す

今回は、E2E の全件(345 件)を、手元でも流しました。#28で、手元では関係するテストだけを流す、と決めています。ただし、全部の画面に効く変更は別です。今回は、全部のリクエストが通る処理を変えています。

途中で、「結構時間がかかっているけど、全件を流し直しているせい?」と聞きました。そのとおりでした。ヘッダーを足しただけのつもりでも、ログインへ移す応答や、画像の配信まで変わります。ここは、時間をかけてよいところだと思います。


おわりに

  • 「自分しか使わない」前提の守りは、公開の前に項目を並べて見直す
  • 「調べて、問題がなかったもの」も書き残す。見ていないのと、区別するため
  • CSP を入れると、HTML に直接書いたスクリプトが止まる。onload="…" も、その1つ
  • 自動テストで確かめられないもの(Google のログイン、Web フォント)は、人が確かめる項目として書いておく
  • 回数の制限は、人ごとに数える。ふつうに使う人を止めない数にする
  • サーバーがよその URL を読みに行く機能は、行き先を確かめる
  • 件数の上限だけでは足りない。1件の大きさにも、上限が要る

次回は、利用規約とプライバシーポリシーです。

← トップページに戻る