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 から読んだユーザーデータが壊れていてもアプリが全停止しないようにしました。linkedUsers や stationTabs のような配列/ネスト構造を、防御的にキャストして、型が想定外でもホーム画面が丸ごと落ちないようにしています。
- 想定外の型・null は安全側にフォールバックする。
- 「1件の壊れたデータ」で画面全体を巻き込まない。
まとめ
- QR の中身は外部入力。表示名のような無害に見えるフィールドでも、長さクランプ(runes 単位)+制御/bidi/ゼロ幅文字の除去でサニタイズする。
- 外部由来のデータは、壊れていても画面全体を止めない防御的パースとセットで扱う。
- こうした入力検証は、リリース前に攻撃者視点で「悪意ある QR」を仮定して洗い出すと抜けを潰しやすいです。