0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

保存・読み込み機能は、それ自体が信頼境界になる(バックエンド無しの静的ツールを監査した話)

0
Posted at

「このアプリはサーバーもDBも無いから、セキュリティは考えなくていい」

自分でそう思っていて、実際に監査したら3件出てきた、という話です。出てきた場所が全部同じところでした。自分のアプリが書き出したファイルを、自分のアプリで読み込む処理です。

エクスポート/インポート機能を持っているアプリなら、スタックに関係なく同じ穴が空きうると思うので、パターンとして共有します。

この記事はZennに書いた記事をベースに、汎用的に使える形へ再構成したものです。内容はすべて自分のOSS(MIT)で確認し、修正・再検証まで済ませています。

前提:監査したものの構成

App Storeのスクリーンショットを作る個人ツールです。

  • サーバーなし、DBなし、ログインなし、外部通信なし
  • index.html 1枚。ビルドもなし
  • ブラウザで開いて、画像をドロップして、書き出して終わり

そして、デッキ全体(文言・画像・スタイル)を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.srcfetcha.hreflink.href、CSSの url()iframe.src。ファイル由来の文字列がこれらに届いていないか。届いているなら、スキームを検証しているか。

パターン2:パースしたJSONを Object.assign でマージしていないか

同じ処理にこれがありました。

// 修正前
if (d.style) Object.assign(state.style, d.style);

ぱっと見ふつうのマージですが、JSON.parseObject.assign の組み合わせには落とし穴があります。

  • JSON.parse('{"__proto__":{...}}') は、__proto__自分自身のプロパティとして持つオブジェクトを作ります。ここではsetterは呼ばれません
  • 一方 Object.assign はコピー先に [[Set]] を使います。つまり target.__proto__ = {...} と書いたのと同じ扱いになり、今度はsetterが呼ばれます

結果、コピー先のプロトタイプが差し替わります。Object.prototype 全体の汚染ではないので影響は限定的ですが、存在しないはずのキーが infor...in に出てくる状態は想定していません。

あと Object.assign は未知のキーも値の妥当性も見ないので、titleSize: 999bg1: "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 || "" };

|| "" は「空なら空文字」のつもりで書いたものですが、title12345 だと数値のまま通ります。表示側は 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-srchttp(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つ
    1. ファイル由来の文字列が、URLとして使われていないか(img.src / fetch / href / url()
    2. パースしたJSONを Object.assign でマージしていないか。既知キーだけ組み立て直す
    3. UIで守っている制約(型・長さ・件数)が、ファイル経路でも守られているか
  • CSPは、ネットワークに出ないことを口約束から検証可能な状態にしてくれる

AIに書かせたコードだと、保存と読み込みを続けて書かせることが多いと思います。そのとき読み込み側は、保存側の出力しか想定しません。自分が書いた形式を暗黙に信頼するコードになるので、そこは人間が指摘しないと出てこないところでした。

監査したツールはこれです。App Storeのスクショを作るだけの小さいものですが、MITで公開しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?