開発Tips

QR で受け取った表示名を安全に扱う — クランプと不可視文字の除去

「ぽんっ!」(旧称:でいりんぐ)は QR コードで家族を連携し、相手の表示名やタブ名を画面に出します。ここで見落としがちなのが、QR の中身は「外部入力」だということ。信頼して画面に流すと、表示崩れや悪用の余地が生まれます。リリース前の監査で入れたサニタイズを残します。

背景(当時の状況)

これも同じリリース前 QA 監査(2026-07-11〜12)で、攻撃者になったつもりで「悪意ある QR」を仮定して洗い出した項目です。表示名という一見無害なフィールドこそ、外部入力として疑ってかかるべきだと再確認しました。

前提: QR ペイロードは信頼できない

QR は誰でも生成できます。連携相手の表示名やタブ名として QR に入っている文字列を、そのまま UI に流すと次のような問題が起きえます。

  • 極端に長い文字列でレイアウトを破壊する。
  • 双方向テキスト(bidi)制御文字で表示順を混乱させる。
  • ゼロ幅文字や制御文字を紛れ込ませる。

これらは「表示名」という一見無害なフィールドでも成立します。

対処: クランプ + 危険な文字の除去

QR ペイロードをパースする段階で、表示名・タブ名にサニタイズ関数を通すようにしました。除去対象を Unicode のコードポイントで指定します。

String sanitizeQrDisplayName(String raw) {
  // 1) 制御文字・双方向制御・ゼロ幅文字を除去
  //    - C0/C1 制御:      U+0000..U+001F, U+007F..U+009F
  //    - bidi 制御:       U+200E, U+200F, U+202A..U+202E, U+2066..U+2069
  //    - ゼロ幅 / BOM:    U+200B..U+200D, U+FEFF
  final cleaned = raw.replaceAll(
    RegExp(
      r'[�--Ÿ'
      r'​-‏‪-‮⁦-⁩]',
    ),
    '',
  );
  // 2) 長さを runes(書記素に近い単位)でクランプ
  final runes = cleaned.runes.toList();
  const maxLen = 20;
  return runes.length <= maxLen
      ? cleaned
      : String.fromCharCodes(runes.take(maxLen));
}
  • 長さは runes 単位でクランプ(絵文字や結合文字を途中で割らないよう、コードユニットではなく runes で数える)。
  • 制御文字・bidi 制御・ゼロ幅文字を除去して、表示崩れや順序操作を防ぐ。

防御的パース: 壊れたデータでホームを止めない

もう一つ、QR に限らずですが、Firestore から読んだユーザーデータが壊れていてもアプリが全停止しないようにしました。linkedUsersstationTabs のような配列/ネスト構造を、防御的にキャストして、型が想定外でもホーム画面が丸ごと落ちないようにしています。

  • 想定外の型・null は安全側にフォールバックする。
  • 「1件の壊れたデータ」で画面全体を巻き込まない

まとめ

  • QR の中身は外部入力。表示名のような無害に見えるフィールドでも、長さクランプ(runes 単位)+制御/bidi/ゼロ幅文字の除去でサニタイズする。
  • 外部由来のデータは、壊れていても画面全体を止めない防御的パースとセットで扱う。
  • こうした入力検証は、リリース前に攻撃者視点で「悪意ある QR」を仮定して洗い出すと抜けを潰しやすいです。