本文へ移動

防御の設計/ Defense in Depth

アカウントという手がかりが無いまま、何を積み上げているか

受取ページは、URL さえ知っていれば世界中の誰でも到達できます。そこにログインという手がかりはありません。この面を守るために、性質の違う仕掛けを重ねています。1 つ 1 つは特別なものではありませんが、破り方が互いに違うので、重ねると全部を同時に満たす必要が生まれます。ここではその層を外側から順に開きます。

01

四層のレート制限とロックRate Limits

外側から内側へ、粗いものから細かいものへと 4 段階に絞ります。それぞれ数える単位が違うので、どこか 1 つをすり抜けても次で捕まります。

図 08 · 防御の層 — 外周から確認の通過まで
外から来る要求第 1 層 ─ Cloud Armor接続元 IP ごとのレート制限。共有リンクのホスト全体を守る外周(本体アプリ側とは別のルール)IP ごとの上限第 2 層 ─ ホスト全体正規の受取人が通常の操作で到達しない天井。数える仕組みが壊れたときは通さない側に倒すIP ごとの上限第 3 層 ─ 端点ごと(リンク × 接続元)状態確認・ZIP 準備上限あり配布・進捗・配下一覧上限ありパスワード・コード系上限あり第 4 層 ─ 失敗ロックパスワードと確認コードで独立に、直近 1 時間の失敗 3 件で 1 時間通過済みの確認(短時間)署名つきの短命 Cookie。ここまで通ったものだけがファイルに届く

第 1 層と第 2 層は接続元 IPで数え、第 3 層はリンクと接続元の組み合わせで、第 4 層はリンクごとの失敗で数えます。数える単位が違うので、IP を変えても第 3・第 4 層は逃れられません。

数え方の違いが効く場面

たとえば、1 本のリンクのパスワードを総当たりしようとした場合。第 3 層で端点別のレート制限に絞られ、それでも続けると第 4 層が立って正しいパスワードを入力しても 1 時間開きません。接続元 IP を次々に変えても、第 3 層と第 4 層はリンク側で数えているため効きません。逆に、たくさんのリンクへ広く浅く当たろうとすれば、第 1・第 2 層が IP 側で数えて止めます。

接続元 IP の取り方: 負荷分散を経由すると、本来の接続元 IP はリクエストのヘッダに書かれた一覧の中にあります。この一覧は誰でも先頭に好きな値を書き足せるため、先頭を採ると偽装できてしまいます。そこで、信頼できる中継の範囲を設定しておき、末尾から手前へ辿って最初に現れる信頼範囲外の値を接続元とします。

発行する側にも上限がある

発行の API にも、ログインした利用者ごとの上限を置いています。パスワードのハッシュ計算は 1 回あたり数十ミリ秒の CPU を使うため、機械的な連打で処理能力を占有されないようにするためです。人が手で操作する範囲では到達しない値で、こちらは数える仕組みが壊れたときは通す側に倒しています(発行業務そのものを止めないため)。

層数える単位上限障害時の挙動
Cloud Armor接続元 IP上限あり—
ホスト全体接続元 IP上限あり通さない
端点ごとリンク × 接続元上限あり通さない
失敗ロックリンクごとの失敗1 時間に 3 件—
発行(参考)ログイン利用者上限あり通す
02

推測できない URLThe Token

共有リンクの最初の鍵は URL そのものです。ここが弱いと、他の層が全部無駄になります。

項目内容
生成暗号学的に安全な乱数から 32 バイト
表記base64url で 43 文字(URL にそのまま置ける文字だけを使う表記)
情報量256 bit
一意性テーブル全体で重複しない。無効化した行も残すため過去の値が再利用されることもない
短縮 URL提供しない(短くすると推測の余地が生まれるため)
通信HTTPS のみ。HTTP は恒久的な転送で HTTPS へ回す

256 bit は、仮に 1 秒あたり 1 兆回の試行を続けても宇宙の年齢では到底終わらない規模です。実際にはその手前で第 1〜第 3 層のレート制限が働くため、総当たりは成立する前に止まります。

存在しないトークンへのアクセスも記録します。ただし、アクセス記録は「どのリンクへのアクセスか」を必ず持つ形になっているため、存在しないリンクの分は監査ログ側へ「不明なリンクへのアクセス」として残します。

03

秘密の保存方式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 つのコードにつき試行できる回数にも上限があります。総当たりの余地は仕組みの側で潰してあるので、重い計算は不要です。むしろ、受取人が画面の前で待っている場面で数十ミリ秒を積む方が損になります。

設定値を保存形の中に書いている理由: 将来コストを引き上げたくなったとき、設定値が値そのものに書いてあれば、古い行はその行の設定で検証し続けられます。全件を作り直す移行作業も、両方式を並行運用する分岐も要りません。引き上げは新規に発行するリンクから順に効いていきます。
04

列挙への対策Enumeration

本人確認つきのリンクでは、受取人のメールアドレスを入力してもらいます。ここが素直な作りだと、「このリンクの受取人は誰か」を 1 件ずつ当てられます。

対策内容
応答を同じにする入力したアドレスが一致してもしなくても、常に「送信しました」と返します(一致しないときは実際には送りません)
応答時間も同じにする一致するかどうかを判定する前に、送信の間隔と回数の確認をまとめて済ませます。判定結果によって読み取りの回数が変わらないので、応答時間の差から推し量ることもできません
送信の間隔同じリンクへの送信は 60 秒に 1 回まで。間隔内の要求は送らずに、同じ「送信しました」を返します
送信の上限同じリンクから 1 日あたりの送信数に上限があります。超えたら送らずに、やはり同じ応答を返します
失敗の理由を細かく返さないコードが違うのか期限が切れたのかを区別せず、同じ文言にします。残りの試行回数も返しません

送信の上限を置いているのは、列挙とは別の理由もあります。60 秒に 1 回でも 1 日回し続ければ 1440 通になり、受取人のメールボックスへの嫌がらせに使えてしまいます。正規の利用(入力を間違えて 2〜3 回送り直す)には十分な余裕を残しつつ、この使い方を封じています。

画面側も同じ扱いにしています: アドレスの入力後に出る「確認コードを送信しました」は、一致・不一致にかかわらず同じ表示です。画面の側だけ正直に「そのアドレスは違います」と出してしまうと、サーバ側の対策が丸ごと無意味になります。
05

データベース側の範囲制限Row Level Security

ここまでの層はすべてアプリの中の話です。アプリに書き間違いがあれば通り抜けられます。そこでデータベース自身にも同じ制限を持たせて、アプリの外側からもう一度絞っています。

図 09 · 共有リンクの経路から見える範囲
見える行の絞り込みBox の全ファイル検査中・隔離済みも過去の版も含む対象範囲だけ共有したファイル、またはそのフォルダの配下検査済みだけ検査中・隔離済み・検証不能は見えない最新版だけ過去の版はたどれない見える行これだけ経路ごとにできること共有リンクの経路(ログインしていない受取人)読み取りのみ・3 つの表だけ書き込みは一切できないログインした利用者(Box のメンバー)自分の Box の分を読める書けるのは無効化の 2 列だけシステムの経路(アプリの内部処理だけ)アクセス記録・回数・ZIP の状態を書くアプリ側に書き間違いがあっても、範囲外の行はデータベースが返しません。たとえば「共有していない別のファイルの識別子を指定する」という呼び出しは、アプリが弾かなくても該当 0 件になります。同じく「期限を延ばす」「回数を戻す」という書き込みは、ログインした利用者の権限ではそもそも実行できません(その列に書き込む権限を与えていないため)。

共有リンクの経路に開いているのは、リンク本体・ファイル・ファイルの版の 3 つの表の読み取りだけです。アクセス記録も確認コードも、この経路からは読むことも書くこともできません。

「最新版だけ」が効く場面

共有したあとにファイルが差し替えられた場合、受取人に見えるのは常に最新の検査済みの版です。過去の版は経路の外にあるため、版の識別子を直接指定してもたどれません。逆に、差し替えた新しい版がまだ検査中であれば、その間は検査済みの版が 1 つも無い状態になり、受取人からはダウンロードできなくなります。

06

キャッシュと露出の制御Cache & Exposure

共有リンクの URL は、それ自体が鍵です。どこかに写しが残ると、期限や回数の設定を回り込まれます。写しが残りうる場所を 1 つずつ塞いでいます。

写しが残りうる場所対処
配信網やブラウザのキャッシュ受取ページと公開 API のすべてに Cache-Control: no-store, private を付けます。前段の配信網もこの指定をそのまま尊重します
検索エンジン受取ページは検索エンジンからの収集と索引付けを明示的に拒否します
チャットやメールのリンクプレビュープレビュー用の情報にファイル名・フォルダ名・発行者名を一切入れません。固定のブランドカードだけを出します
サーバの動作ログログに出す URL からトークン部分を伏せます
確認前の画面パスワードや本人確認を通すまで、対象のファイル名を返しません

リンクプレビューを固定にした理由

チャットやメールに URL を貼ると、多くのサービスが自動的にそのページを取りに行き、タイトルや説明文をカードとして表示します。ここに動的な値を入れると、クローラーが取得した内容が外部サービス側にキャッシュされます。パスワードを設定していても、カードにファイル名が出ていれば「何を渡したか」は漏れます。

そこで、プレビューに出すのは「共有ファイルの受け取り」という固定のタイトルと、松葉のブランド画像だけにしました。素の URL がそのまま出るより信頼できる見た目になり、なおかつ中身は一切漏れません。

残っている範囲: アプリ自身が出すログではトークンを伏せていますが、クラウド基盤が自動的に記録するリクエストログには URL がそのまま残ります。ここはアプリ側から制御できない範囲で、閲覧できるのがクラウドの管理権限を持つ担当者に限られることを踏まえて許容しています。