node.js Webアプリケーションで意図しない状態共有が起きているか検知する
tl;dr
- ヒューリスティックにはこの記事のようにできそう。(綺麗な方法があればぜひ知りたいです)
- Proxyを使ってsetハンドラにログを仕込む。
- オブジェクト作成後ある程度時間が経ってから値のsetが行われているオブジェクトは、意図しない状態共有を引き起こしている可能性があるので、その条件に当てはまる処理が実行されたらログに出すと良い。
- Babelプラグインを作りProxyを仕込めば、既存のコードに手を加えなくてもBabelを通すだけで済む。
問題
node.js Webアプリケーションでの状態共有とは
node.jsでのWebアプリケーションは、プロセスが立ち上がりHTTPリクエストを受け付け続ける仕組みとなっています。プロセスのメモリは共有され続けるため、もしシングルトンオブジェクトに値を設定すると、そのプロセスで受け付ける全てのリクエストにおいて設定された値が共有されます[1]。
なぜ検知したいのか
その性質から、リクエスト単位で行う処理の中でシングルトンオブジェクトに値を設定することはnode.js Webアプリケーションでは通常やらないことであり、例えば機密情報をセットしてしまうとそれは全てのリクエストで共有されるため、情報漏洩・セキュリティインシデントに繋がる重いバグの原因となります。
通常やらないこととは言え、php-fpmを使わずにPHPを書いてきたエンジニアや元々クライアントサイドでのみ実行されるJavaScriptを書いてきたエンジニアが、node.jsでのWebアプリケーション開発に携わるとシングルトンオブジェクトに値を設定しまうという事例を私は何度か目にしてきました。また、ライブラリのラッパーを作成すると、ライブラリでのシングルトンにも注意する必要があるため、誰でもバグを埋め込む可能性はあります。
(ちなみに、Spring Bootでも同様の事例をよく目にします)
この問題に対してはまず教育が必要ですが、人の目でこのようなバグを完全に防ぐのは、コードレビュワーにとって非常に負担です。そのため、何かしらの形で楽に検知できる仕組みがあると有用ではあるのかなと思います。
問題の検知
問題のあるコード
以下のようなコードを考えます。
const express = require('express');
const app = express();
const OrderModule = require("./lib/order");
const order = new OrderModule();
app.get('/', async (req, res) => {
order.setOrderId(req.query["order"]);
const getOrderFromDb = await new Promise(resolve => {
// DBからの取得をシミュレート
setTimeout(() => {
// DBからの値では無く、リクエスト受付時にセットした(後、値が変わるとは思っていなかった)オブジェクトからIDを取得
resolve(order.getOrderId());
}, 100);
});
return res.json({
order: getOrderFromDb,
});
});
app.listen(3000);
class OrderModule {
constructor() {
this.orderId = "";
}
setOrderId(value) {
this.orderId = value;
}
getOrderId() {
return this.orderId;
}
}
module.exports = OrderModule;
orderIdをメンバ変数に持っている時点で危うさを感じるコードです。以下のように同時にアクセスすると、最後に受け付けたorderIdが全てのリクエストに対して返却されてしまいます。
$ echo -n "a b c" | xargs -P 3 -d ' ' -I {} curl "http://localhost:3000/?order={}"
{"order":"c"}{"order":"c"}{"order":"c"}
どうすれば良いのか
そもそもorderIdをメンバ変数に持たないよう設計を見直す方が良いですが、例えば以下のように、シングルトンではなくリクエスト毎に生成されるオブジェクトに修正すれば意図しない状態共有は無くなります。しかし、このオブジェクトが依存している別のオブジェクトがもしシングルトンであれば、この修正で終わりではなく、コードを更に読み進めていく事になります。
const app = express();
const OrderModule = require("./lib/order");
-const order = new OrderModule();
app.get('/', async (req, res) => {
+ const order = new OrderModule();
order.setOrderId(req.query["order"]);
const getOrderFromDb = await new Promise(resolve => {
$ echo -n "a b c" | xargs -P 3 -d ' ' -I {} curl "http://localhost:3000/?order={}"
{"order":"b"}{"order":"c"}{"order":"a"}
問題の本質
ここで問題の改めて考えてみます。プロセスが立ち上がりHTTPリクエストを待ち受け始めた時に作られたオブジェクトが、リクエストの度に書き換えられているのが問題です。
JavaScriptにはProxyという仕様があり、値のsetを検知することができます。オブジェクトが作られてから時間が経っているのにリクエスト単位で値のsetが行われてる事を検知できれば、今回の問題はほぼ検知できると思われます(100%正しくないので、ヒューリスティックです)。
Proxyを仕込む
Proxyの詳細はMDN等を見ていただくとして、先述のOrderModule
のProxyをこのように作成しindex.js
で使用すれば、インスタンス化されてから10秒経過以降の値のsetを検知してconsole.logに出力できます。
const OrderModule = require("./order");
const ProxiedOrderModule = new Proxy(OrderModule, {
construct(target, args) {
const ignoreModuleNames = ["Foo", "Bar"];
const waitUntilEnableHeuristic = 10000;
const className = target.name;
let heuristicEnable = false;
if (!ignoreModuleNames.includes(className)) {
setTimeout(() => {
// インスタンス化した後10秒後にsetが走っている場合、初期化時では無くリクエスト単位でmutationされている可能性がある
console.log(`Trap started on ${className}`);
heuristicEnable = true;
}, waitUntilEnableHeuristic);
}
return new Proxy(new target(...args), {
set(obj, prop, value) {
if (heuristicEnable) {
console.log(`${className}#${prop} = ${value}`);
}
return Reflect.set(...arguments);
}
});
}
});
module.exports = ProxiedOrderModule;
src/index.jsを問題のあるコードに戻し、以下を実行するとログが出力されます。
$ sleep 10 && echo -n "a b c" | xargs -P 3 -d ' ' -I {} curl "http://localhost:3000/?order={}"
{"order":"b"}{"order":"b"}{"order":"b"}
$ node ./src/index.js
Trap started on OrderModule
OrderModule#orderId = a
OrderModule#orderId = c
OrderModule#orderId = b
これで検知はできました。
Babelプラグインを作り、既存のコードに手を加えずProxyを仕込む
また、以下のようなBabelプラグインを作ってトランスパイルすれば、既存のコードに手を加えず、トランスパイルするだけでProxyを仕込むことができます。(もっと綺麗な書き方があるかもしれません)
const template = require("@babel/template").default;
const proxiedSource = template(`
new Proxy(__SOURCE__, {
construct(target, args) {
const ignoreModuleNames = ["Foo", "Bar"];
const waitUntilEnableHeuristic = 10000;
const className = target.name;
let heuristicEnable = false;
if (!ignoreModuleNames.includes(className)) {
setTimeout(() => {
// インスタンス化した後10秒後にsetが走っている場合、初期化時では無くリクエスト単位でmutationされている可能性がある
console.log("Trap started on " + className);
heuristicEnable = true;
}, waitUntilEnableHeuristic);
}
return new Proxy(new target(...args), {
set(obj, prop, value) {
if (heuristicEnable) {
console.log("" + className + "#" + prop + " = " + value);
}
return Reflect.set(...arguments);
}
});
}
});`);
module.exports = function ({ types: t }) {
return {
name: "my-babel-plugin",
visitor: {
ExpressionStatement(path) {
if(path.node.expression.type === "AssignmentExpression") {
if (path.node.expression.left.object && path.node.expression.left.object.name === "module" && path.node.expression.left.property.name === "exports") {
path.node.expression.right = proxiedSource({
__SOURCE__: t.identifier(path.node.expression.right.name),
})
}
}
return;
}
}
};
};
サンプルコード
一通りのサンプルコードは以下にあります。
-
https://github.com/ptarjan/node-cache などのインメモリキャッシュはこの仕組みを使っています。 ↩︎
Discussion