ハコヅミを作る #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で終わる名前は、断る- リダイレクトの行き先も、同じように確かめる

IP アドレスを直書きした URL は、取り込まない。断った理由は、細かく出さない
画像の数の上限も、ここに掛けました。上限に達した人が、取り込みだけをくり返せたからです。ただし、今ある画像の差し替えは、数が増えないので通します。そのために、「どのギアの画像を差し替えるのか」を、取り込みのときに伝えるようにしました。伝えられたギアが、本当にその人のもので、画像が付いているかは、サーバーで確かめます。
断る名前を、絞りすぎた
1つ、つまずきがありました。断る名前の一覧に、最初は .test と .invalid も入れていました。どちらも、実在しないと決まっている名前です。
ところが、今までの E2E が、まさにその名前を使っていました。「届かない URL を貼ったら、エラーになる」ことを確かめるテストです。実在しない名前は、どのみち読めずにエラーになります。断る一覧からは外しました。
上限のない欄が、7つあった
点検で見つかった、いちばん素朴な抜けです。
メモは 1000 文字まで、表示名は 30 文字まで、と決めてきました。ところが、ギアの製品名・メーカー・カテゴリ、消耗品の名前・カテゴリ、コンテナとプランの名前には、長さの上限がありませんでした。
件数の上限があっても、1件にとても長い文字を入れれば、データベースを埋められます。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件の大きさにも、上限が要る
次回は、利用規約とプライバシーポリシーです。