TypeScriptの型定義からバリデーションコードを生成するツールを書いた
create-validator-tsというTypeScriptの型定義からJSON Schemaを使ったバリデーションコードを生成するツールを書きました。
モチベーション
expressなどでAPIを書くときに、Request/Responseが意図したものかどうかをバリデーションする必要があります。
特にreq.query
などはStringが入ると予想しますが、オブジェクトが入ってくることもあります。
これは、expressの内部で使っているqsというURLクエリのパーサが、オブジェクトや配列へ展開する機能を持っているためです。
expressを使ってるサイトは
— azu (@azu_re) March 21, 2021
?q=text
があるときに
req.query.q
には オブジェクトが入る可能性をちゃんと考慮しないといけない。
?q[a]=text
で
req.query.q ; // { a: "text" }
になる
また、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.username
やreq.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.username
やreq.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が発生します。
- Testing for NoSQL Injection
- PayloadsAllTheThings/NoSQL Injection at master · swisskyrepo/PayloadsAllTheThings
[全体的な軽減策] 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-ts
をapi-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
- philan.net/web/pages/api/user at main · azu/philan.net
- サーバのAPI側のコード(user)
このときに、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";
- https://github.com/azu/philan.net/blob/62aba2320917f92f53f590bb353390c23607afee/web/components/useLoginUser.tsx#L2-L13
- フロントからAPIのレスポンスの型を
import type
で参照しているコード
- フロントからAPIのレスポンスの型を
コード生成のチェック
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ではYup、myzod、Zodなどを使ってバリデーションを書くことで、コード生成をしないですむアプローチを選んでいます。
また、ts-transformer-ajvはcreate-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
のような型定義を持っておくこと自体は別のアプローチになった場合も移行しやすかなと思ってこのようなツールを作りました。
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。