In this article
アプリケーションセキュリティにTypeScriptだけで十分か?
JavaScriptのスーパーセットであるTypeScriptは、コードの保守性を高める強力な機能、静的型付けを導入します。しかし、セキュリティに対する期待も高めてくれるのでしょうか。
この記事では、TypeScriptのセキュリティに関する誤解や、思いがけない落とし穴によって開発者の期待が裏切られる可能性について、私自身の見解を交えながら考察します。
TypeScriptが約束するセキュリティとは何でしょうか。まずは、この問題に正面から向き合ってみましょう。
型の安全性をTypeScriptに頼っているとしましょう。簡単な質問です。この例は、明示的に許可されたものだけを通し、それ以外をすべてTypeScriptがチェックしてくれることを示しているでしょうか。
class UserController {
public getUserHelloComponent: RequestHandler = async (
_req: Request<{},{},{}, {name?: string} >,
res: Response) => {
const userName = _req.query.name || "World";
if (!sanitizeXSS(userName)) {
return res.status(400).send("Bad input detected!");
}
const helloComponent = `<h1>Hello, ${userName}!</h1>`;
return res.send(helloComponent);
}さらに重要な点として、TypeScriptの導入を「開発時」の型チェックに役立つものと捉えていますか。それとも、実行時にも意味のあるセキュリティを提供すると考えていますか。
実行時のセキュリティだと答えた方は、TypeScriptのセキュリティ制御と誤解について学ぶ旅に出ましょう。
TypeScript入門
TypeScriptでは、変数、関数、オブジェクトのデータ型を明示的に定義することで、開発サイクルの早い段階で潜在的なエラーを発見できます。TypeScriptを採用し、JavaScriptの上にアプリケーションを構築するこの先回り型のアプローチを取り入れることで、実行時エラーや脆弱性、予期しない動作のリスクを大幅に低減できます。
このTypeScriptの導入部分では、実行時の型エラーを最小限に抑えることに焦点を当てています。定義上、また実際のツールの使い方からも、TypeScriptは「開発時」のツールです。開発者はTypeScriptでコードを書き、JavaScriptにコンパイルします。TypeScriptのツールエコシステムにより、IDEのインテリジェンスを活用して型の安全性を確保し、ビルドや継続的インテグレーションのパイプラインでコードの型チェックを行えます。
以下は、TypeScriptで構築されたExpressアプリケーションにおけるAPIルートハンドラーの定義例です。
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
const filterQuery: string = _req.query.filter as string || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
}TypeScriptとセキュリティ
プログラミングにおける型関連のセキュリティ問題の一つに、型のジャグリングがあります。型のジャグリングとは、JavaScriptがある型の値を別の型に暗黙的に変換しようとすることです。JavaScriptのさまざまな「癖」を題材にしたパズルを通じて、すでに目にしたことがあるかもしれません。
たとえば、次のようになります。
// prints true
let x = 1;
console.log(x == "1")上記のコードが「true」を出力するのは、型強制が行われるためです。JavaScriptはまず2つのオペランドを同じ型に変換してから比較します。その結果、「1」は数値の1として解釈されて比較され、真となる値が評価されます。
変数、関数のパラメーター、戻り値に期待する型を定義すれば、TypeScriptは多くの型関連エラーを開発時に検出でき、本番環境で悪用される前に対処できます。しかし、それを頼りにしてよいのでしょうか。型のジャグリングは、実行時に型が厳密に扱われないため、セキュリティの脆弱性につながる可能性があります。その結果、定義した型を回避されることがあります。
ExpressとTypeScriptを使ったアプリケーション
以降の例を説明するため、ExpressウェブフレームワークとTypeScriptの連携を使ったNode.jsバックエンドAPIを使用します。
具体的には、次のような構成のNode.jsサーバーAPIアプリケーションを使います。
src/
api/
user/
userController.ts
userRouter.ts
userModel.ts
userService.ts
userRepository.ts
server.tsExpressサーバーのコードは、次のようにルート定義、ミドルウェア、その他の設定を読み込みます。
import { userRouter } from "@/api/user/userRouter";
// Middlewares
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
app.use(helmet());
app.use(rateLimiter);
// Request logging
app.use(requestLogger);
// Routes
app.use("/health-check", healthCheckRouter);
app.use("/users", userRouter);この記事で取り上げるTypeScriptセキュリティの例では、userRouterを一貫して使用します。
TypeScriptのセキュリティバイパス #1
REST APIエンドポイントとして機能する/usersエンドポイントがあります。ユーザーが自分自身を検索できるほか、特に、ユーザー名に一致するフィルター文字列を渡せます。
このフィルタリングの方法を示すリポジトリパターンは次のとおりです。findAllAsyncメソッドを公開し、filterプロパティを持つオブジェクトを受け取ります。
export class UserRepository {
async findAllAsync({ filter }: { filter?: string } = {}): Promise<User[]> {
if (filter) {
return users.filter((user) => user.name.startsWith(filter));
}
return users;
}
}ユーザーを検索するには、次のようにGET HTTPリクエストを送信します。
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
filterクエリ文字列は、ルート定義からリポジトリ層へどのように渡されるのでしょうか。このパターンと、TypeScriptをどのように活用できるかを見ていきましょう。
HTTPルート定義は次のとおりです。/ルートのコントローラーコードを/usersプレフィックスに定義し、GETメソッドのすべてのHTTPリクエストに一致させます。
import { userController } from "./userController";
export const userRouter: Router = express.Router();
userRouter.get("/", userController.getUsers);このルートへのGET HTTPリクエストを処理するExpressコントローラーのコードは次のとおりです。
import type { Request, RequestHandler, Response } from "express";
import { userService } from "@/api/user/userService";
import { handleServiceResponse } from "@/common/utils/httpHandlers";
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
}userRepositoryと同様に、ここでコントローラーが呼び出すuserServiceのサービス定義も、最初の関数引数としてfilterプロパティを持つオブジェクトを受け取り、filterQuery型に従うよう更新されています。
curlリクエストへのHTTPレスポンスでは、名前が「Al」で始まるすべてのユーザーが一覧表示されます。結果は次のとおりです。
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}ここまでは特に驚くことはありません。しかし、GETリクエストのペイロードでfilterクエリ文字列を、文字列ではなく配列として解釈される形式で送ったらどうなるでしょうか。
Node.jsウェブアプリケーションで型のジャグリングによるセキュリティ問題を引き起こすペイロードは次のとおりです。クエリ文字列のフィールドをfilter=からfilter[]=に変えるだけで、同じレスポンスが返されます。
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter[]=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}これまでと同じように動作するのは、JavaScriptが配列をtoStringの定義に従って変換し、配列の最初の要素である文字列Alになるためです。内部ではfilter変数が文字列として扱われているにもかかわらず、以前と同じ動作が維持されています。これは問題なのでしょうか。後ほど確認します。
この時点で、コントローラーコードにあるひどいTypeScriptの定義に気づいたかもしれません。
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });TypeScriptで黒魔術のようにanyを使うのは、一般的ではなく推奨されない方法です。JavaScriptでevalを使うのに次ぐほどかもしれません。ですから、型のジャグリングが起きるのも不思議ではありません。filterクエリ文字列に任意の型を許可し、それをユーザーサービスとリポジトリで使っていたのです。
もっと良い方法を試しましょう。
TypeScriptのセキュリティバイパス #2
特別な型指定のワイルドカードであるTypeScriptのanyを使わず、filter変数を文字列型として厳密かつ明示的に定義します。
ルートの実装を、文字列型を使うよう変更しましょう。
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
// we now define query as string
const filterQuery: string = _req.query.filter as string || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};すべての型が正しく設定され、型定義エラーがないことを確認するため、tscコンパイラーを実行し、エラーがないことを確かめます。
$ npx tscfilterを配列として指定する、同じ型のジャグリングを起こすHTTPペイロードを送信するとどうなるでしょうか。見てみましょう。
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter[]=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}filterクエリ文字列の型を明示的に文字列型として定義し、TypeScriptコンパイラーでもエラーが出なかったにもかかわらず、型のジャグリング攻撃を実行できました。もちろん、この攻撃は機能を示すためのもので、悪意のあるものではありません。
TypeScriptのセキュリティバイパス #3
型を宣言する別のTypeScriptの仕組みを試してみましょう。
anyやas stringの型宣言でTypeScriptを無理に使うのではなく、より一般的な型付けの方法を採用します。想定するクエリ文字列のスキーマをインターフェースとして定義し、それをRequestオブジェクト型に適用します。
このGETエンドポイントは、React Server Componentsを簡略化した代替として機能します。ユーザー名のクエリ文字列を受け取り、DOMにレンダリングされるコンポーネントのHTMLコンテンツを含むHTTPレスポンスを返します。
サーバーサイドコンポーネントの実装は次のとおりです。
interface UserComponentQueryString {
name?: string;
}
class UserController {
public getUserHelloComponent: RequestHandler = async (
_req: Request<{}, {}, {}, UserComponentQueryString>,
res: Response) => {
const userName = _req.query.name || "World";
if (!sanitizeXSS(userName)) {
return res.status(400).send("Bad input detected!");
}
const helloComponent = `<h1>Hello, ${userName}!</h1>`;
return res.send(helloComponent);
}
}ご覧のとおり、想定どおり、ReqQueryを介してRequest型オブジェクトのクエリ文字列部分に型を付けています。
(alias) interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = qs.ParsedQs, Locals extends Record<string, any> = Record<string, any>>
import Requestクロスサイトスクリプティング(XSS)という明白なセキュリティ問題があるため、ユーザー入力もサニタイズします。そのため、sanitizeXSS()関数を呼び出しています。実装は次のとおりです。
function sanitizeXSS(name: string): boolean {
const disallowList = ["<", ">", "&", '"', "'", "/", "="];
return !disallowList.some((badInput) => name.includes(badInput));
}これで、以下の仕組みによるセキュリティのガードレールを設けました。
インターフェースは
Request<T>ジェネリックを明示的にマッピングします。4番目のパラメーターReqQueryにはUserComponentQueryStringを設定し、クエリ文字列がインターフェースで定義した構造に従うようにします。つまり、_req.queryは、文字列型の省略可能なnameプロパティを持つオブジェクトになります。ユーザーがこのコンポーネント生成ロジックを悪用し、たとえば
nameクエリ文字列フィールドに<img src=x onError=alert(1) />のような値を指定する可能性があります。これは、クライアント側で有害なJavaScript操作を実行する方法の簡単な例です。それを防ぐため、sanitizeXSS関数を作成しました。この関数はnameプロパティに危険な文字列が使われていないか検出し、生成されるHTMLコンポーネントで使用されないようにします。
このような悪意のあるペイロードを含むリクエストを送ってみましょう。
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name=<img liran"
Bad input detected!ご覧のとおり、ユーザー入力の悪意ある文字をサニタイズするロジックに沿った、想定どおりのレスポンスが返されました。
しかし、学んだ型のジャグリングによるセキュリティ問題を適用し、nameフィールドをname[]に変更するとどうなるでしょうか。配列型として渡されてしまうのでしょうか。
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name[]=<img src=x onError=alert(1) />"
<h1>Hello, <img src=x onError=alert(1) />!</h1>セキュリティのガードを回避できてしまいました。なぜでしょうか。
name[]配列はsanitizeXSS()関数に渡されます。この関数は、disallowList配列の照合述語を通じて渡された名前に対し、includes()関数を実行しようとします。しかし、nameは文字列ではなく配列であるため、述語がブール値のtrueを返すことはなく、クロスサイトスクリプティングのサニタイズ処理が失敗します。
TypeScriptのプラクティス:考察と学び
TypeScriptの型指定に
anyを使わないでください。_req.query.filter as stringのように型を強制するのは悪い慣行とされ、「TypeScriptに嘘をつく」と表現されることもあります。TypeScriptの型の絞り込みという手法を使うこともできます。これは「型ガード」とも呼ばれ、実行時に想定した型であるかをチェックします。たとえば、
if (typeof padding === "number") { … }のようにします。ただし、コードベース全体で日常的に使うことになり、可読性と保守性が低下する可能性があります。
TypeScriptはセキュリティに役立つのでしょうか。はい、ただし、そもそもセキュリティのために作られたものではありません。
私の意見では、TypeScriptは開発時のチェックツールです。型ガードを実装することはできますが、プレーンなJavaScriptでも同じことができます。コード内を流れるデータに対して、想定する型や構造、バリデーションを効果的に適用するには、Zodなどのライブラリを使ったスキーマ検証という、実行時に動作する仕組みがTypeScriptには必要です。