防御の設計/ Defense in Depth
アカウントという手がかりが無いまま、何を積み上げているか
受取ページは、URL さえ知っていれば世界中の誰でも到達できます。そこにログインという手がかりはありません。この面を守るために、性質の違う仕掛けを重ねています。1 つ 1 つは特別なものではありませんが、破り方が互いに違うので、重ねると全部を同時に満たす必要が生まれます。ここではその層を外側から順に開きます。
四層のレート制限とロックRate Limits
外側から内側へ、粗いものから細かいものへと 4 段階に絞ります。それぞれ数える単位が違うので、どこか 1 つをすり抜けても次で捕まります。
第 1 層と第 2 層は接続元 IPで数え、第 3 層はリンクと接続元の組み合わせで、第 4 層はリンクごとの失敗で数えます。数える単位が違うので、IP を変えても第 3・第 4 層は逃れられません。
数え方の違いが効く場面
たとえば、1 本のリンクのパスワードを総当たりしようとした場合。第 3 層で端点別のレート制限に絞られ、それでも続けると第 4 層が立って正しいパスワードを入力しても 1 時間開きません。接続元 IP を次々に変えても、第 3 層と第 4 層はリンク側で数えているため効きません。逆に、たくさんのリンクへ広く浅く当たろうとすれば、第 1・第 2 層が IP 側で数えて止めます。
発行する側にも上限がある
発行の API にも、ログインした利用者ごとの上限を置いています。パスワードのハッシュ計算は 1 回あたり数十ミリ秒の CPU を使うため、機械的な連打で処理能力を占有されないようにするためです。人が手で操作する範囲では到達しない値で、こちらは数える仕組みが壊れたときは通す側に倒しています(発行業務そのものを止めないため)。
| 層 | 数える単位 | 上限 | 障害時の挙動 |
|---|---|---|---|
| Cloud Armor | 接続元 IP | 上限あり | — |
| ホスト全体 | 接続元 IP | 上限あり | 通さない |
| 端点ごと | リンク × 接続元 | 上限あり | 通さない |
| 失敗ロック | リンクごとの失敗 | 1 時間に 3 件 | — |
| 発行(参考) | ログイン利用者 | 上限あり | 通す |
推測できない URLThe Token
共有リンクの最初の鍵は URL そのものです。ここが弱いと、他の層が全部無駄になります。
| 項目 | 内容 |
|---|---|
| 生成 | 暗号学的に安全な乱数から 32 バイト |
| 表記 | base64url で 43 文字(URL にそのまま置ける文字だけを使う表記) |
| 情報量 | 256 bit |
| 一意性 | テーブル全体で重複しない。無効化した行も残すため過去の値が再利用されることもない |
| 短縮 URL | 提供しない(短くすると推測の余地が生まれるため) |
| 通信 | HTTPS のみ。HTTP は恒久的な転送で HTTPS へ回す |
256 bit は、仮に 1 秒あたり 1 兆回の試行を続けても宇宙の年齢では到底終わらない規模です。実際にはその手前で第 1〜第 3 層のレート制限が働くため、総当たりは成立する前に止まります。
存在しないトークンへのアクセスも記録します。ただし、アクセス記録は「どのリンクへのアクセスか」を必ず持つ形になっているため、存在しないリンクの分は監査ログ側へ「不明なリンクへのアクセス」として残します。
秘密の保存方式Hashing
パスワードも確認コードも、元に戻せない形で保存します。データベースを丸ごと持ち出されても、そこから値そのものは取り出せません。
| パスワード | 確認コード | |
|---|---|---|
| 方式 | scrypt(計算時間とメモリの両方を要求する方式) | HMAC-SHA256(鍵つきの要約) |
| 設定 | N=16384, r=8, p=1 = メモリ 16 MiB / 鍵 64 バイト | 鍵は既存の秘密から用途別に導き出したもの(新しい秘密を増やしていない) |
| ソルト | 行ごとに 16 バイトの乱数 | リンクの識別子と宛先を計算対象に含めるため、別のリンクへ使い回せない |
| 保存形 | scrypt$N=16384,r=8,p=1$<ソルト>$<ハッシュ> の 1 列 | 要約の値のみ |
| 照合 | 比較にかかる時間が値によって変わらない方式を使う(時間差から正解の桁数を推し量られないようにするため) | |
scrypt と HMAC の使い分け
パスワードは人が決めた短い文字列なので、総当たりに耐える必要があります。scrypt はわざと計算を重くする方式で、しかもメモリを要求するため、専用ハードウェアを並べて大量に試す攻撃に効きます。
一方、確認コードはシステムが生成する 6 桁で、有効期限が 10 分、1 つのコードにつき試行できる回数にも上限があります。総当たりの余地は仕組みの側で潰してあるので、重い計算は不要です。むしろ、受取人が画面の前で待っている場面で数十ミリ秒を積む方が損になります。
列挙への対策Enumeration
本人確認つきのリンクでは、受取人のメールアドレスを入力してもらいます。ここが素直な作りだと、「このリンクの受取人は誰か」を 1 件ずつ当てられます。
| 対策 | 内容 |
|---|---|
| 応答を同じにする | 入力したアドレスが一致してもしなくても、常に「送信しました」と返します(一致しないときは実際には送りません) |
| 応答時間も同じにする | 一致するかどうかを判定する前に、送信の間隔と回数の確認をまとめて済ませます。判定結果によって読み取りの回数が変わらないので、応答時間の差から推し量ることもできません |
| 送信の間隔 | 同じリンクへの送信は 60 秒に 1 回まで。間隔内の要求は送らずに、同じ「送信しました」を返します |
| 送信の上限 | 同じリンクから 1 日あたりの送信数に上限があります。超えたら送らずに、やはり同じ応答を返します |
| 失敗の理由を細かく返さない | コードが違うのか期限が切れたのかを区別せず、同じ文言にします。残りの試行回数も返しません |
送信の上限を置いているのは、列挙とは別の理由もあります。60 秒に 1 回でも 1 日回し続ければ 1440 通になり、受取人のメールボックスへの嫌がらせに使えてしまいます。正規の利用(入力を間違えて 2〜3 回送り直す)には十分な余裕を残しつつ、この使い方を封じています。
データベース側の範囲制限Row Level Security
ここまでの層はすべてアプリの中の話です。アプリに書き間違いがあれば通り抜けられます。そこでデータベース自身にも同じ制限を持たせて、アプリの外側からもう一度絞っています。
共有リンクの経路に開いているのは、リンク本体・ファイル・ファイルの版の 3 つの表の読み取りだけです。アクセス記録も確認コードも、この経路からは読むことも書くこともできません。
「最新版だけ」が効く場面
共有したあとにファイルが差し替えられた場合、受取人に見えるのは常に最新の検査済みの版です。過去の版は経路の外にあるため、版の識別子を直接指定してもたどれません。逆に、差し替えた新しい版がまだ検査中であれば、その間は検査済みの版が 1 つも無い状態になり、受取人からはダウンロードできなくなります。
キャッシュと露出の制御Cache & Exposure
共有リンクの URL は、それ自体が鍵です。どこかに写しが残ると、期限や回数の設定を回り込まれます。写しが残りうる場所を 1 つずつ塞いでいます。
| 写しが残りうる場所 | 対処 |
|---|---|
| 配信網やブラウザのキャッシュ | 受取ページと公開 API のすべてに Cache-Control: no-store, private を付けます。前段の配信網もこの指定をそのまま尊重します |
| 検索エンジン | 受取ページは検索エンジンからの収集と索引付けを明示的に拒否します |
| チャットやメールのリンクプレビュー | プレビュー用の情報にファイル名・フォルダ名・発行者名を一切入れません。固定のブランドカードだけを出します |
| サーバの動作ログ | ログに出す URL からトークン部分を伏せます |
| 確認前の画面 | パスワードや本人確認を通すまで、対象のファイル名を返しません |
リンクプレビューを固定にした理由
チャットやメールに URL を貼ると、多くのサービスが自動的にそのページを取りに行き、タイトルや説明文をカードとして表示します。ここに動的な値を入れると、クローラーが取得した内容が外部サービス側にキャッシュされます。パスワードを設定していても、カードにファイル名が出ていれば「何を渡したか」は漏れます。
そこで、プレビューに出すのは「共有ファイルの受け取り」という固定のタイトルと、松葉のブランド画像だけにしました。素の URL がそのまま出るより信頼できる見た目になり、なおかつ中身は一切漏れません。