綴Photo の勉強ノートに戻る
コードレビュー学習2026.06.17

見えない ≠ 無い — dart-define で焼き込んだトークンと、kDebugMode で隠したボタン

セキュリティFlutterdart-defineJWT綴Photo

Codex のフルレビューを Claude が観点別に検証する中で出た、綴Photo モバイル版のセキュリティ寄りの2つの「思い込み」を、勉強のためにまとめたものです。

綴Photo のモバイル版は、撮った写真と綴り(気持ち)をローカルDBに保存していて、クラウド同期を少しずつ足している段階だ。同期に使う認証トークン(JWT)は、ログイン機能がまだ無いので、開発中は手で発行したものを --dart-define でビルド時に渡している。

レビューで指摘されたのは、その周辺に「見えてないだけで、実はそこにある/生きている」という思い込みが2つ重なっていたこと。1つはトークンの渡し方、もう1つは同期ボタンの隠し方。どちらも「見えない」を「無い・安全」と読み替えていた。まとめておく。

思い込み①: --dart-define のトークンは「環境変数だから隠せる」?

トークンはバイナリ。写真は端末 SQLite。サーバの環境変数は端末に届かない。サーバのプロセス環境変数実行時に外から注入リポジトリに無い端末に届かない配布バイナリJWT_TOKEN の文字列ビルド時に焼き込み端末に渡る文字列検索で抜ける端末 SQLitephotos / 綴りローカル DBトークンはここに無い保存先は別「環境変数だから隠せる」はサーバ側の話である。モバイルのクライアントでは、配布物に文字列が残る。

コードはこういう形だった。トークンとベースURLを String.fromEnvironment で受け取り、通信時にそのまま Authorization ヘッダへ載せます。

dart
// ビルド時に --dart-define で渡す想定の設定
static const String jwtToken = String.fromEnvironment('JWT_TOKEN', defaultValue: ''); // ← ビルド時に値が確定(実行時には外から変えられない)=バイナリに焼き込まれる
static const String apiBaseUrl =
    String.fromEnvironment('API_BASE_URL', defaultValue: 'http://localhost:8000'); // ← 平文HTTP・デフォルト値もバイナリに残る
 
// 通信時:トークンがあれば Bearer として付ける(送り先のスキームは見ていない)
if (token.isNotEmpty) headers['Authorization'] = 'Bearer $token'; // ← 焼き込まれたトークンを送り先に関係なくそのまま載せて送る

引っかかるのは2つ。1つは、アプリのファイルを文字列検索すると焼き込まれた文字列がそのままで出てきます——特別なハッキングは要らず、手軽にできます。

もう1つは送り先。ベースURLのデフォルトが平文の http://、しかもスキーム(http か https か)も送り先がローカルかどうかも見ずに Bearer を付けています。誤って非ローカルの http:// を指したまま実トークンを渡すと、トークンが平文で飛ぶ=同じネットワークにいる第三者に盗み見られ得ます。

思い込み②: kDebugMode で隠したボタンは「無い」?

同期と画像アップロードのボタンは、設定画面で kDebugMode で囲って、本番では出ないようにしてあった。

ボタンは画面。出口のコードはバイナリ。DB にも prefs にも無い。設定画面同期ボタン本番では見えない画面から消えるだけprefs には無いリリースバイナリ同期・アップロード関数コードとして残る出口は生きている画面には無い場所を変えた瞬間に、この壁は剥がれる。見えないのは、無いことではない。

UIをどこに置くかは設計上の壁ではありません。将来「同期を本番導線に昇格させよう」としたとき、思い込み①(平文にトークンを載せる)が一緒に表に出てきます——②のUI壁が外れた瞬間に、焼き込みトークン(①)と平文HTTP黙認が同時に効いてきます。個別には「まだ大丈夫」に見えていた偶然の壁が、1枚剥がれると残りも連鎖します。

直した方向

いま実装したのは、送り先のガードだ。平文HTTP かつ送り先が非ローカルのときは、トークンを載せようとした時点で例外を投げて止めます(フェイルクローズ=迷ったら送らない側に倒します)。https と、ローカル開発の接続先(localhost など)は通します。トークンが空のとき(守る秘密が無い)は素通り。

dart
// 平文HTTP × 非ローカル宛にトークンを送ろうとしたら弾く(送ってから後悔できないので手前で止める)
if (token.isEmpty) return;               // 守る秘密が無い
if (uri.scheme == 'https') return;       // 暗号化されている
if (localHosts.contains(uri.host)) return; // ローカル開発接続は許可
throw StateError('平文で非ローカルへトークンを送ろうとした → 拒否');
トークンはバイナリか保管庫。SQLite にも prefs にも書かない。いまの配布バイナリJWT_TOKEN の文字列ビルド時に焼き込みリリースでは渡さない根治ではない本筋の保管庫iOS KeychainAndroid Keystore / Windows DPAPI都度読むDB にも prefs にも無い送り先のガードは関所である。置き場所は動かない。保管庫は OS の箱で、アプリの DB ファイルではない。

リリースビルドにはトークンを渡さない運用は、方針としてコメントに残し、実装はログイン機能を作るときに回した。

kDebugMode の方は、ゲートの意図をコメントで明文化した。「これは本番からクラウド送信に到達させない設計上の壁であって、単なるUI配置ではない。外す前に上のガードと保管庫移行を固めること」と書いて、偶然の壁を意図のある壁に格上げしておいた。

学びポイント

  • --dart-define はコンパイル時の定数で、バイナリに文字列として焼き込まれます。サーバの環境変数の「実行環境に隠せる」感覚を持ち込むと裏切られます。モバイルのクライアントに本物の秘密は置けない前提で考えます
  • 「画面に出していない(kDebugMode)」は「機能が無い」ではありません。出口のコードが生きているなら、UIの非表示は設計上のガードではなく、いつでも剥がれる偶然の壁
  • 平文HTTP × 非ローカルへトークンを送らない、はフェイルクローズ(条件を満たさないなら送らない・弾く)で書きます。https とローカルだけ通します。判定に迷う・想定外の入力が来たとき「通す」と漏れは静かに成功して気づけません。だからデフォルト拒否にして、通すのは明示的に安全だと分かったケースだけにします
  • 「見えない」状態(バイナリの中・UIの裏)を「無い・安全」と読み替えません。見えないだけでそこにあります

おわりに

2つの指摘は別の話に見えて、根っこは同じ「見えない=無い」という錯覚だった。バイナリの中に焼き込まれたトークンも、UIの裏に隠れた送信コードも、画面や普段の開発からは見えません。でも見えないことと存在しないことは違います。

レビューがここを突けたのは、「このトークンはどこに焼かれて、どの送り先まで届き得るか」「このボタンの先のコードは本番に残るのか」と、見えている表面の一枚下まで追ったからだった。表面上は「本番には出ない開発用ボタン」で片付くものを、出口まで辿って初めて「壁になっていない」と分かります。


この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。