事象
Hono の RPC を導入済みのWEBアプリケーションにて、予期せぬエラー事象が発生。
原因は、「フロントエンドからAPIに送られたリクエストボディに不要なパラメータが設定されていた」こと。不要なパラメータがアプリ内部処理で悪さをしていた。
「……あれ?RPC入れてるから型安全なはずでは?フロントとAPIで型共有できてるし型安全なのでは?」
↑そんなことはない。ちゃんと実装レベルで担保しないといけない部分がある。
今回の問題点
問題点は大きく2つ。
「フロント側で不要なプロパティが含まれたリクエストボディを送信していた」という点と、「API側で リクエストボディの型チェックをちゃんと実施していなかった」点である。
さらに深掘ると、TypeScript型チェックの穴に気づけていなかった ことと、この状態でTSチェックやRPC機能を過大に信用し過ぎ、Zodなどバリデーションライブラリを用いたリクエストボディの妥当性チェックを怠った ことが見えてくる。
結果、不要なプロパティが含まれたリクエストボディを送信してしまったり、API側の型チェックが漏れたと言える。
1. フロント側で不要なプロパティが含まれたリクエストボディを送信していた
TypeScript型チェックの穴
リクエストボディの元となるデータには、型アノテーションを付与していなかった。
そのため不要なプロパティが混入し得る状態だった。
(参照:「TSチェックを信頼しすぎた例.ts」例1)
リクエストボディの整形関数や、APIの呼び出し関数では、関数引数の型指定はちゃんと記載していた。
(参照:「TSチェックを信頼しすぎた例.ts」例2)
が、この場合、TypeScriptでは「渡された引数が、関数引数の指定型に解釈可能かチェック」だけが実施される。
余剰プロパティが混入していても、TSエラーが発生しない。
そのため余剰プロパティの混入に気づけなかった。
import { z } from "@hono/zod-openapi";
/** スキーマ定義 **/
const postAccountRequestSchema = {
accountName: z.string(),
email: z.string(),
};
/** 型定義 **/
type PostAccountRequest = z.infer<typeof postAccountRequestSchema>;
// 例1:型アノテーションで 変数の型を `PostAccountRequest` と指定する →余剰プロパティが含まれていると TSエラーが発生する
const defaultPostAccountRequestData: PostAccountRequest = {
accountName: "",
email: "",
// userId: "" <- コメントアウトを外すと、TSエラーが発生する
};
// 例2:関数の第一引数の型を `PostAccountRequest` と指定する
const formatPostAccountDate = (data: PostAccountRequest): PostAccountRequest => {
// ~~~ 何らかの処理 ~~~
// -> `data`が PostAccountRequest型として解釈可能であればエラーは発生しない
// -> 余剰プロパティが `data`に含まれていても TSエラーは発生しない
};
// -------------------------------------------------------------
// エラー事象再現
// 1. 不要プロパティの含まれたデータを作る
const NGData = {
accountName: "",
email: "",
userId: "" // <- PostAccountRequest には含まれない
};
// 2. 1で作成したデータを、関数の第一引数(`PostAccountRequest`を期待)に設定
const NGFormattedData = formatPostAccountDate(NGData) // -> 型エラーが発生しない
参考:サバイバルTypeScript の記事
余剰プロパティチェックはオブジェクトの余計なプロパティを禁止するため、コードが型に厳密になるよう手助けをします。しかし、余剰プロパティチェックが効くのは、オブジェクトリテラルの代入に対してのみです。なので、変数代入にはこのチェックは働きません。
スキーマライブラリによるデータ検証の漏れ
Zodなどのスキーマライブラリを用いている場合、parseなどのバリデーション用関数を用いて型チェックを実行すると、余剰プロパティはトリミングされる。
しかし、今回は「RPCだし型安全だよね〜〜〜!」と思い込み、Zodスキーマによるデータ検証を怠っていた。
そのため、リクエストボディの余剰プロパティがそのままになっていた。
フロントアプリのAPI呼び出し元で、下記のような具合でZodスキーマによるバリデーションを実施するべきだった……
// API接続関数 →下記のように、APIリクエストボディに対するリクエスト型検証を実施すれば良い
const postAccount = async (token: string, body: PostAccountRequest) => {
const client = postAccountRouteClient(import.meta.env.VITE_API_URL);
try {
// Zodスキーマによる`body`の検証 + 余剰プロパティのトリミング
const validBody = postAccountRequestSchema.parse(data);
// API接続処理
const res = await client.api["account"].$post({ json: validBody });
if (res.ok) {
return res.json();
} else {
const message = await res.text();
throw honoErrorResponse(res, message);
}
} catch (e) {
// body の検証でエラーが発生した場合
console.error("リクエストボディの型が不適切です。", e);
}
};
const apiRes = await postAccount(NGFormattedData); // -> リクエストボディからは、`NGFormattedData`の余剰プロパティが削除されている
2. API側で リクエストボディの型チェックをちゃんと実施していなかった
今回 APIルーター関数内では、c.req.json()という関数を用いてリクエストボディを取得し、後続の処理を進めていた。
がこのjson()は、単純にリクエストボディを取得することしかできない。
つまりリクエストボディの型が適切か否かバリデーションチェックが全くできていないまま、ビジネスロジックを進めてしまっていた……。
余剰プロパティが受け渡ってきていても気づけないまま、そのままの状態でAPI内部処理が進んでしまった……。
フロントから本当に期待データ型が受け渡ってきているのか??を疑って実装すべきだった。
Honoの場合は、c.req.json()ではなくc.req.valid("json")を用いることで、Zodのバリデーションチェックを通過したリクエストボディを取得できる。
/** POST /api/account */
export const postAccountRoute = accountApp.openapi(
createRoute({
method: "post",
path: "/api/account",
description: "アカウントの作成",
tags: ["アカウント情報"],
request: {
headers: headersSchema,
body: {
content: {
"application/json": { schema: postAccountRequestSchema },
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: postAccountResponseSchema,
},
},
description: "Account created",
},
},
}),
async (c) => {
// リクエストボディの型チェックができていない!
const body = c.req.json();
const res = await application.postAccount(body);
return c.json(res, 200);
},
);
async (c) => {
// リクエストボディの型チェック実装OK!
// `c.req.json()` -> `c.req.valid("json")`
const validBody = c.req.valid("json");
const validRes = await application.postAccount(validBody);
return c.json(validRes, 200);
},
参考:Honoにおけるリクエストデータの扱いについて
リクエストボディだけでなく、レスポンスボディもスキーマ検証が必要。意図しないプロパティがレスポンスに含まれる可能性があり、セキュリティインシデントに繋がりかねない。
総括
-
APIのルーター関数(プレゼンテーション層)で、リクエストボディ/レスポンスボディの型チェックを必ず実施しよう。セキュリティ事故や謎バグのリスクを回避できる。
- TypeScriptを使っているのであれば、Zodなどスキーマライブラリの機能でバリデーションチェックをするのがおすすめ。Zodを入力フォームのバリデーションルール生成にだけ使うのは勿体無い!(自戒)
- 「TypeScriptの型チェックが通ってればOK!」は短絡的。結構理解の漏れがあったりする。特に関数の型定義は要注意。引数に渡されるデータが、期待された型形式でない(余剰パラメータが含まれている)可能性がある……ことを考慮しよう。
- 「RPCだから型安全!」もまた超絶短絡的。あくまでRPCは型共有でしかない。そして2に記載通り、TypeScriptの型チェック結果だけを信頼するのはよろしくない。API側/フロントアプリ側それぞれで リクエスト/レスポンスデータのバリデーションチェックを実装しよう。