入力値を信用しない

基本的にユーザーの入力を信用してはいけません。例えユーザーが家族や友達であってもです。ユーザーはあらゆる入力をしてくるので、アプリケーションはそれに備えなければなりません。昨今ニュースで話題になっている企業の個人情報流出の中には、正しくユーザーの入力を制御できていないが故に発生しているものも少なからずあります。

Discordのボットのようなものでは、ユーザーの入力を素通ししてもそれほど深刻な問題には発展しないと考えられますが、それでもアプリケーションに一時的な損害を与えたり、意図しないデータへのアクセスは発生しうるので、必ずユーザーの入力は検証して、自分の意図したデータのみ受け付けるようにしましょう。

想定した値域に収まるか?

値域というのは値の範囲のことを指します。数値型(Number)であれば、その値の大小ですし、文字列型(String)であれば文字数が該当するでしょう。ほとんどのアプリケーションではとてつもなく大きいあるいは長い入力は必要ないことが普通です。

単純な方法ですが、一度に大量のデータを送りつけたり膨大な計算をさせたりするのは、アプリケーションを攻撃する手段として極めて有効です。

ここでダイスロールを再度考えてみましょう。 というダイスを振るときに、 のうち、どちらを増やされるほうがコストが高くなるでしょうか?これは の値が増えるほうが高くなります。それを確認するためにダイスロールを実装したコードを振り返ってみましょう。

roll() {
   const values = []
   // 個数の数だけ繰り返して出た目をvaluesに追加していく
   for (let i = 0; i < this.rolls; i++) {
       const value = Math.floor(Math.random() * this.sides) + 1
       values.push(value)
   }
   return new DiceResult(values)
}

ここで はダイスの個数でコード中ではrolls はダイスの面数でsidesで表現されています。 を増やすことを考えると、乱数の幅が広がるだけということがわかります。JavaScriptでは数値を64bit実数として扱っているので、どんな値でも64bit分のメモリと計算時間しかかかりません。

次に を増やすことを考えてみましょう。 が増えるとその値分だけ、乱数を求めることになります。そのため に依存した計算時間とメモリが必要になります。雰囲気的に くらいであればすぐに求められそうですが、 くらいになるとそれなりに時間がかかりそうな気はします。

コンピュータも無限にリソースが存在するわけではないので、高いCPU負荷やメモリ不足が続くと性能が落ちますし、場合によってはそういうアプリケーションの実行を強制的に止めることもあります。

  • 入力データが想定した範囲に収まっているか検証しよう
  • 入力データによって計算量に影響が出ないか確認しよう

想定した形式になっているか?

値域を検証することで大きな入力を防げることはわかりました。ですが、その中身が望んだ形になっているかどうかはまた別の話です。これについても簡単な例を挙げてみましょう。入力した値を整数値に変換する関数parseInt() は、ほとんどの状況で概ね期待通りに動作しますが、入力値によっては意図しない挙動になります。

console.log(parseInt('0x10'))  // 16

parseInt() は与えられた文字列が16進数として認識できると、自動的に16進数として整数値に変換します。これが便利なこともあれば、そうでないこともあるので、ここでは10進整数値だけ受け取るようにしてみましょう。

最初に思いつくのは、それぞれの文字ごとに0~9であるかどうかを確認するコードを書くというものです。

function isDigit(text) {
    return [...text].every(x => '0' <= x && x <= '9')
}

このコードはやりたいことを実現できますが、少し冗長です。もっと賢い方法として正規表現を使うやりかたがあります。正規表現は文字列を構成するパターンを指定することで、与えられた文字列がパターンに一致するかどうかを確認できるというものです。

function isDigit(text) {
    // 10進整数値は、1文字以上の0~9の文字の連続で表現される
    return /^[0-9]+$/.test(text)
}

正規表現は便利ですが万能ではありません。入れ子構造や、長さがパターン中の特定の要素に依存するもの、複雑すぎるものについては、正規表現だけでは対応できません。自分で場合分けをするか、個々の小さな問題に分解した判定ロジックを組む必要があります。

検証された値同士の処理

検証された値同士でもその結果が失敗することがあります。いやいや、それぞれ前提を満たしているのだからそんなことあり得ないですよ、と思うかもしれませんが、これは起きえるのです。

ひとつ例を示しましょう。JavaScriptでは整数値も実数も同じNumber型として扱われますが、整数の範囲で誤差がなく表現できるのはNumber.isSafeInteger()がtrueを返す値、すなわちからの範囲に限られます。これを検証して、誤差のない整数値の範囲内で足し合わせる関数を定義します。

function add(x, y) {
    const a = parseInt(x)
    const b = parseInt(y)
    if (Number.isSafeInteger(a) && Number.isSafeInteger(b)) {
        return a + b
    }
    return NaN
}

add()メソッドは正しく動くように見えますが、とすれば、計算自体はできますが、安全な整数の範囲には収まらなくなります。このように一見正しく計算できそうな処理でも落とし穴があります。

計算の成功を保証するには?

いろいろな方法があります。答えはひとつではないので考えてみてください。

誰かに試してもらおう

プログラマ本人は注意深くコードを書いているつもりでも、それは正常な入力をされたときのことしか想定していないのは往々にしてあることです。もし身近にアプリケーションを試してくれる人がいるなら、その人にエラーを起こしそうな入力を試してもらうといいでしょう。

素人とプログラマでは異なる視点を持っているので、可能であれば双方による試験ができると、思いがけない見落としや考慮不足を発見できることがあります。

演習

ダイスロール

ダイスボットを作り、 ロールの結果を返すために、 の値を文字列としてユーザーから受け取ることにしました。

  1. はどのような文字列であることを期待しているか?
  2. を数値に変換したときに、その値域に制限があるか?あるならその理由を述べよ
  3. ロールに拡張したときに、考慮しなければならないことについて述べよ

思わぬ落とし穴

  1. ある郵便番号は、必ずどこかひとつの都道府県に対応しているか?
  2. すべての文字は1文字か? '🍯'.length を確認して、なるべく正しく文字数を確認する方法を調べてみよ

第6回 返事を遅らせる