create-validator-tsというTypeScriptの型定義からJSON Schemaを使ったバリデーションコードを生成するツールを書きました。

モチベーション

expressなどでAPIを書くときに、Request/Responseが意図したものかどうかをバリデーションする必要があります。 特にreq.queryなどはStringが入ると予想しますが、オブジェクトが入ってくることもあります。 これは、expressの内部で使っているqsというURLクエリのパーサが、オブジェクトや配列へ展開する機能を持っているためです。

また、Node.jsではMongoDBやORMマッパーを使うことが多いです。 このようなDB/KVS周りのライブラリはだいたいクエリにJavaScriptのオブジェクトを指定できます。 このとき、req.queryなどをなにもチェックせずに、クエリのオブジェクトに渡すとNoSQL Injectionという脆弱性を発生させやすいです。

NoSQL Injection: MongoDB

express + Mongooseを使ったNoSQL Injectionを例に説明してみます。 これはユーザーのリクエストに基づいてMongoに対してQueryを発行する時に、ユーザーが任意のMongo Queryを指定できてしまう問題です。

次のようにHTTPリクエストのbodyをそのままMongooseのqueryに渡すと発生する脆弱性です。 req.query.usernamereq.query.passwordは文字列を期待しているが、実際にはオブジェクトを渡すことができて、オブジェクトを指定できるとMongo queryの$neなどの演算子も指定できてしまう問題です。

// これはわざと脆弱性にしてるサンプルコードなので使えません
app.post('/user', function (req, res) {
    const query = {
        username: req.body.username,
        password: req.body.password
    }
    // 任意のユーザーを取得できるNoSQL Injectionが起きている
    // Note: queryにpasswordを指定してる状況もあんまりよくない
    db.collection('users').findOne(query, function (err, user) {
        console.log(user);
    });
});

このようなコードが動いている場合、次のような$neを使ったリクエストをbodyを指定すれば、queryはなにか一つにマッチします。 $neはマッチしないという意味の演算子で、nullにマッチしない = なにか一つのモデルが取れるため、実在するユーザー名やパスワードを知らなくても、任意のuserモデルが取得できてしまいます。

{
    "username": {"$ne": null},
    "password": {"$ne": null}
}

このようなNoSQL Injection/Mongo Query Injectionはexpress + ORMマッパーの組み合わせて特に起きやすい問題です。 これは、req.bodyだけではなく、req.queryでも同様に発生します。

次のようなreq.queryを参照する/checkというGET APIがある場合に、req.query.usernamereq.query.passwordにもオブジェクトを渡せます。

import express from "express";

const app = express();
const port = 3000;

// → http://localhost:3000/check?username[$ne]=0&password[$ne]=0
app.get("/check", (req) => {
    console.log(req.query); // { 'username': { '$ne': '0' }, password: { '$ne': '0' } }
});

app.listen(port, () => {
    console.log(`http://localhost:${port}`);
});

expressでは、?username[$ne]=0&password[$ne]=0 のようなURLパラメータを渡すと、JSONオブジェクトとしてreq.queryに渡されます。 これらのreq.*として受け取ったオブジェクトをMongoDBに対してクエリとして渡すことNoSQL Injectionが発生します。

[全体的な軽減策] expressでリクエストを受け付けた段階での $. の削除

Mongoのクエリでは$.が特殊な意味を持ちます。 これらの文字列が含まれるリクエストを強制的に排除することで、Mongoのクエリとして特殊な意味をもつOperatorをInjection攻撃に利用できなくなります。(Injection自体が防げるわけではない) そのため、リクエストのオブジェクトから 次のパターンにマッチするものを強制的に削除することで、Mongo Query Injectionへの段階的な防御策となります。 (ただ実際のリクエストで使っている場合は壊れるのでできません)

  • プロパティ名が $ から始まるプロパティ
  • . をプロパティ名に含むプロパテ

express-mongo-sanitizeというmiddlewareでは、次のreq.*に含まれる $. を含むプロパティを削除できます。

  • req.params
  • req.body
  • req.headers
  • req.query

しかし、この対策ではあくまでMongoクエリとして特殊な意味を持つクエリを通さなくなるだけであるため、Query Injection自体への対策にはなりません。(あくまで軽減策の一種であるということ) Query Injection自体は、APIリクエストのバリデーションを組み合わせて対応する必要があります。

APIのバリデーションをする

この問題に対する正攻法はAPI(expressのroutingなど)でreq.*が意図したリクエストなのかをバリデーションすることです。 NoSQL Injectionは、stringだと思っていた箇所にobjectが入ることで発生しやすいという話でした。 そのため、型のチェックだけでもある程度バリデーションは効果があります。(アクセス制御などは別途仕組みが必要です)

バリデーションにはスキーマファイルを使うものとコードとして書くものがあります。

TypeScriptでコードベースを書いている場合に、JSON Schemaは手書きしたくないし、バリデーションライブラリは定番が難しいという問題がありました。 そのため、TypeScriptの型定義を書いて、その型定義からバリデーションコードを生成することで大雑把な型チェックをするバリデーションをすることにしました。

TypeScriptの型には数値の範囲や正規表現などはないためバリデーションライブラリに比べると扱える範囲は狭いです。 しかし、stringだと思っていた箇所にobjectが入るという問題はTypeScriptの型情報だけでも十分バリデーションできます。

📝 実際にはcreate-validator-tsでは@minimum 0 のようなアノテーションで細かい値の範囲も指定できそうですが、ちゃんとは確かめていません。

create-validator-ts

create-validator-tsは、TypeScriptの型定義からJSON SchemaとAjvを使ったバリデーションコードを生成するツールです。 生成するバリデーションコード自体は自由にカスタマイズできるので、プロジェクトごとに生成するコードは変更できます。

使い方

create-validator-tsは、単純さを意識作っているので動作もシンプルです。 次のようなファイル構造があるとします。

.
└── src/
    ├── hello/
    │   ├── api-types.ts
    │   └── index.ts
    └── status/
        ├── api-types.ts
        └── index.ts

このときに、create-validator-tsapi-types.tsというファイル名を対象にして実行します。(ファイル名は任意のglobで指定できます)

$ create-validator-ts "src/**/api-types.ts"

この結果、それぞれのapi-types.tsに対応するapi-types.validator.tsというファイルが生成されます。

.
└── src/
    ├── hello/
    │   ├── api-types.ts
    │   ├── api-types.validator.ts <- Generated
    │   └── index.ts
    └── status/
        ├── api-types.ts
        ├── api-types.validator.ts <- Generated
        └── index.ts

バリデーションのコード

デフォルトのコードジェネレーターでは、api-types.tsに対して生成されるapi-types.validator.tsは次のようなコードです。

api-types.ts:

// Example api-types
// GET /api
export type GetAPIRequestQuery = {
    id: string;
};
export type GetAPIResponseBody = {
    ok: boolean;
};

api-types.validator.ts (generated):

// @ts-nocheck
// eslint-disable
// This file is generated by create-validator-ts
import Ajv from 'ajv';
import * as apiTypes from './api-types';

const SCHEMA = {
    "$schema": "http://json-schema.org/draft-07/schema#",
    "definitions": {
        "GetAPIRequestQuery": {
            "type": "object",
            "properties": {
                "id": {
                    "type": "string"
                }
            },
            "required": [
                "id"
            ],
            "additionalProperties": false
        },
        "GetAPIResponseBody": {
            "type": "object",
            "properties": {
                "ok": {
                    "type": "boolean"
                }
            },
            "required": [
                "ok"
            ],
            "additionalProperties": false
        }
    }
};
const ajv = new Ajv({ removeAdditional: true }).addSchema(SCHEMA, "SCHEMA");
export function validateGetAPIRequestQuery(payload: unknown): apiTypes.GetAPIRequestQuery {
  if (!isGetAPIRequestQuery(payload)) {
    const error = new Error('invalid payload: GetAPIRequestQuery');
    error.name = "ValidatorError";
    throw error;
  }
  return payload;
}

export function isGetAPIRequestQuery(payload: unknown): payload is apiTypes.GetAPIRequestQuery {
  /** Schema is defined in {@link SCHEMA.definitions.GetAPIRequestQuery } **/
  const ajvValidate = ajv.compile({ "$ref": "SCHEMA#/definitions/GetAPIRequestQuery" });
  return ajvValidate(payload);
}

export function validateGetAPIResponseBody(payload: unknown): apiTypes.GetAPIResponseBody {
  if (!isGetAPIResponseBody(payload)) {
    const error = new Error('invalid payload: GetAPIResponseBody');
    error.name = "ValidatorError";
    throw error;
  }
  return payload;
}

export function isGetAPIResponseBody(payload: unknown): payload is apiTypes.GetAPIResponseBody {
  /** Schema is defined in {@link SCHEMA.definitions.GetAPIResponseBody } **/
  const ajvValidate = ajv.compile({ "$ref": "SCHEMA#/definitions/GetAPIResponseBody" });
  return ajvValidate(payload);
}

この生成するコードは、--generatorScriptで任意のジェネレーターを定義できます。

$ create-validator-ts "src/**/api-types.ts" --generatorScript ./custom.js

express middlewareとかも書こうと思えば生成できると思うので、その辺興味ある人は次のIssueを見てみてください。

📝 Prettierの対応

生成するコードはフォーマットが多少崩れてるので、.prettierignoreで無視するかコード生成の時点でprettierするのを推奨です。

*.validator.ts

📝 Tips: 型定義と実装は分離するのを推奨

create-validator-tsは、TypeScriptの型定義からJSON Schemaを生成するためにts-json-schema-generatorを使っています。 どうしても複雑なTypeScriptのコードからJSON Schemaを生成すると時間がかかったり、パースに失敗するケースがあります。(パースの失敗はかなりレアな感じなぐらいには安定している)

api-types.ts のように型定義だけをファイルとして分けているのは、このような問題を避けやすくするためです。 TypeScriptの実装を含むコードからもJSON Schemaを生成はできますが、基本的には型だけをファイルとして分けることを推奨しています。

これは、型だけを分けておけばimport type を使ってサーバのReqest/Responseの型定義をクライアントサイドからも利用できためです。

たとえば、philan.netではNext.jsを使って書いていて、Next.jsのAPIにはcreate-validator-tsを利用しています。

.
├── api-types.ts
├── api-types.validator.ts
├── create.ts
├── get.ts
├── list.ts
└── update.ts

このときに、api-types.tsという型定義にリクエストとレスポンスの型を書いています。 サーバ側のAPIでは、api-types.tsの型定義と生成したapi-types.validator.tsのバリデーションコードを利用しています。

一方で、このAPIを叩くクライアントサイドからもapi-types.tsの型定義を共有して利用しています。 Type-Only Imports and Exportを使えば、間違ってサーバのコードをクライアントにbundleすることなく、型だけを参照できるので便利です。

import type { GetUserResponseBody, UserResponseObject } from "../pages/api/user/api-types";

コード生成のチェック

create-validator-tsでは、TypeScriptからJSON Schemaを使ったバリデーションコードを生成していますが、コードジェネレーターには欠点があります。 コードジェネレーターはコードを生成しないといけないので、TypeScriptの型定義を変更したらコードを生成する必要があるという点です。

生成したコードをGitで管理している場合は、コードを生成し忘れて差分が出てしまうかもしれません。 そのようなケースを避けるためにcreate-validator-tsでは、--checkというフラグで差分があるかをチェックできます。 これをCIで回せば差分がでるという問題は起きなくなります。(差分がでたら再生成すれば良いだけです)

$ create-validator-ts "src/**/api-types.ts" --check
# $? → 0 or 1

あとはコミット時に生成し直すなども入れれば、差分はかなり出にくくなるかもしれません。

また、--watchフラグで変更があるたびに自動生成もできます。

おわりに

create-validator-tsというTypeScriptの型からバリデーションを生成するツールを作りました。 TypeScript → JSON Schemaの発想自体は何年も前から持っていましたが、生成したコードも管理しないといけないのがイケてない点なのも分かっていました。 コードを生成することで差分が生まれやすい問題は--checkでのチェックなどを導入して軽減しています。

別のアプローチとしては、tRPCではYupmyzodZodなどを使ってバリデーションを書くことで、コード生成をしないですむアプローチを選んでいます。

また、ts-transformer-ajvcreate-validator-tsと似たアプローチですが、ttypescriptのtrasnsformプラグインとしてTypeScriptのコンパイル時にバリデーションコードを生成しています。 (このtransformの仕組みが公式じゃないので、create-validator-tsではこのアプローチを取らなかった)

その他には、Open APIなどすでにAPIのSpecファイルが別にある人は、そのファイルからバリデーションを生成するのが良さそうです。

個人的にはコードとの距離の一番近い型がTypeScriptの型定義なので、TypeScriptに寄せています。 create-validator-tsはTypeScriptをSingle source of truthとして扱うためのアプローチの一種です。 create-validator-tsのアプローチもまだ完璧ではないですが、 api-types.tsのような型定義を持っておくこと自体は別のアプローチになった場合も移行しやすかなと思ってこのようなツールを作りました。