asが危険かどうかは、書いた場所で決まった

転職用のポートフォリオとして、個人でWebアプリ「Hubpin」を作っています。
制作の経緯は「Hubpin 開発記録」に時系列でまとめています。
設計と判断は自分で行い、実装も原則自分で書いています。AIには手順書・レビュー・調査を任せています。

型シリーズの続きです。
前回は、生成した型が手書きの型より弱くなった、という話でした。

その穴が、3工程あとに別の形で返ってきました。

編集ページを作っていて、as によるキャストを1箇所だけ書きました。
「これは安全なんだろうか」と気になって、値の流れを追ってみた記録です。

目次

書いたのは1箇所

書いたのは1行です。

type: item.type as ItemInput['type']

データベースから読んだカードの種類を、<select> の初期値として渡すためのものです。
生成された型では string になっているので、そのままでは選択肢の型に入りませんでした。

値がどこへ行くかを追った

安全かどうかは、キャストした値がこのあと何に使われるかで決まると考えて、流れを書き出しました。

DB → 編集ページ(ここで as)→ <select> → 送信 → 検証 → DB

そのうえで、自分のコードで気になる3点を見ました。

観点 評価
表示に埋め込まれて悪さをするか しない。<select> の値にしかならず、React が自動でエスケープする
権限の判定に影響するか しない。権限の判定に使っているのはユーザーIDのほう
不正な値がそのまま保存されるか されない。書き戻す前に必ず検証を通る

この位置なら影響は小さい、と判断しました。
1行のままにしました。

危険になる条件のほうを書き出した

ただ、これは as が一般に安全という話ではありません。
危険になる条件を3つ書き出したら、今回はどれにも当たっていなかった、というだけでした。

1. キャストした値が権限の判定に使われるrole as 'admin' | 'user' が最悪の形)
2. 検証を挟まずに外へ出る
3. HTML や SQL に埋め込まれる

同じ1行でも、置く場所で評価が完全に逆になります。
真逆です。
読む側(画面に出す側)に置いたから、後段の検証が受け止めてくれる。
保存する処理の中に書いていたら、その検証を迂回することになって、意味が反対でした。

as のリスクは「キャストしたこと」ではなく、「その値が次に何に使われるか」で決まる。
これが今回の結論です。

根拠は2つあって、強さが違った

安全だと判断した根拠は、実は2つありました。

  • 上流: データベース側に、3つの値しか入らない制約がある
  • 下流: 保存する前に、必ず検証を通る

このうち上流は、マイグレーションの書き換えで消えることがあります。
下流のほうは、保存処理の中にあるので残ります。
強さが違うので、コードのコメントには下流のほうを書きました。

将来この行を読む人が「DB に制約があるから大丈夫」と読むと、
制約が外れたときに根拠ごと消えます。
消えたことにも気づけません。

キャストが要らなかった選択肢

そもそもキャストが不要になる書き方もありました。
3つの値を、制約ではなく Postgres の enum 型として持つ方法です。

手元で確認したところ、生成された型には Enums という枠が用意されていました。
enum を定義していれば、その3つの値がここに出てきます。
一方、いま使っている制約のほうは型に反映されず、string のままでした。

制約(現行) enum
型に出るか 出ない 出る
値の追加 制約を外して付け直す 型に値を追加する
値の削除 簡単 事実上できない

enum にすればキャストは消えますが、値を削除できなくなります。
v1 の仕様は凍結済みで、この3つは要件として固定なので enum 向きではあります。
ただ、いま作り直す理由がないので、今回は採りませんでした。アイデアだけ残してあります。

判断が3工程あとに返ってきた

面白かったのは、この話の出発点が3工程前だったことです。

データベース側で「3つの値のどれか」という制約をかけると決めたのは、設計の工程でした。
そのあと型を手書きから生成に切り替えたとき、
生成された型が保証するのはカラムの構成であって、値の範囲ではないと気づきました。
そして今回、その穴が「キャストが必要になる」という形で出てきました。

判断そのものが間違っていたとは思っていません。
今でも。
ただ、決めた場所と、影響が出る場所が離れていました。
記録がなければ、この1行をなぜ書いたのか、たどれなかったと思います。

目次