evalや動的テンプレートのコンパイルによるコードインジェクション

入力や動的テンプレートをコードとして実行するインジェクション

説明

利用者の入力をコードとして評価したり、テンプレートエンジンが実行するソースとして渡したりすると、コードインジェクションが発生するおそれがあります。eval、new Function、文字列を受け取るブラウザーのタイマー、EJS・Pug・Lodashなどでの動的なテンプレート実行が例です。影響はエンジンの機能や実行権限により、サーバーではリモートコード実行(RCE)、ブラウザーではスクリプト実行(XSS)につながる可能性があります。Node.jsのタイマーは文字列のコールバックを実行せず、拒否します。

想定される影響

  • サーバー上でコマンド実行、ファイルアクセス、内部ネットワークの探索を行われるおそれがあります。
  • アプリケーションの権限でリソースにアクセスされ、さらに権限昇格を試みられる可能性があります。
  • データベースの内容、環境変数、トークンを読み取られたり変更されたりするおそれがあります。
  • ブラウザー向けの応答にスクリプトを挿入され、利用者のセッションが侵害される可能性があります。
  • 無限ループや大量のメモリー使用でサービスが停止するおそれがあります。

対処方法

  • eval、new Function、vm.runIn*などのコード実行APIに利用者の入力を渡さないでください。
  • 信頼できる固定のテンプレートだけをコンパイル・レンダリングしてください。入力はデータとして渡し、自動エスケープを維持してください。EJSの<%- %>などの非エスケープ出力に信頼できない値を使わないでください。
  • 動的な処理が必要なら、許可リストから事前定義した演算や関数を選んでください。
  • タイマーには固定の処理を行うコールバックを渡し、その中でも入力をコードとして評価しないでください。
  • 入力を検証し、実際のHTML・URL・JavaScriptの出力先に合うエンコードを適用してください。
  • Node.jsのvmモジュールを、信頼できないコードに対するセキュリティ境界として使わないでください。

例

変更前

javascript
// 入力をコードとして評価し、利用者のテンプレートを描画
const express = require("express");
const ejs = require("ejs");
const app = express();
app.use(express.json());

app.get("/calc", (req, res) => {
  const expr = req.query.expr; // 例: "process.env"や悪意のあるコード
  // BAD: 入力をコードとして評価
  const result = eval(expr);
  res.send(String(result));
});

app.post("/preview", (req, res) => {
  const userTpl = req.body.tpl; // 利用者が送信したテンプレート文字列
  // BAD: 利用者のテンプレートを直接描画(SSTI)
  const html = ejs.render(userTpl, { name: req.body.name });
  res.send(html);
});

app.get("/wait", (req, res) => {
  // BAD: タイマーのコールバック内で入力をコードとして評価
  setTimeout(() => eval(req.query.code), 10);
  res.send("scheduled");
});

app.listen(3000);

変更後

javascript
// 許可した演算、固定テンプレート、固定のタイマー処理
const express = require("express");
const ejs = require("ejs");
const app = express();
app.use(express.json());

// 1) 許可した演算を選択
const OPS = new Map([
  ["add", (a, b) => a + b],
  ["sub", (a, b) => a - b],
  ["mul", (a, b) => a * b],
]);
app.get("/calc", (req, res) => {
  const op = req.query.op; // add/sub/mulのいずれか
  const a = Number(req.query.a);
  const b = Number(req.query.b);
  const operation = typeof op === "string" ? OPS.get(op) : undefined;
  if (!operation || !Number.isFinite(a) || !Number.isFinite(b)) {
    return res.status(400).send("invalid input");
  }
  const result = operation(a, b); // コード評価をせず関数を選択
  res.send(String(result));
});

// 2) 自動エスケープを維持し、固定テンプレートにデータを渡す
const PROFILE_TPL = "<h1>User</h1><p>Name: <%= name %></p>";
app.post("/preview", (req, res) => {
  const name = typeof req.body.name === "string" ? req.body.name : "";
  const html = ejs.render(PROFILE_TPL, { name }); // 利用者の入力はデータとしてのみ渡す
  res.send(html);
});

// 3) タイマーには関数のコールバックだけを渡す
app.get("/wait", (req, res) => {
  const ms = Math.min(Number(req.query.ms) || 0, 5000);
  setTimeout(() => {
    // 処理を関数で定義し、文字列のコードを評価しない
  }, ms);
  res.send("scheduled");
});

app.listen(3000);

変更前はevalや利用者が指定するテンプレートからサーバーコードを実行できます。Node.jsのタイマーも、文字列のコールバックではなくコールバック内のevalで同じ危険を示しています。

変更後はMapから許可した演算を選び、有限の数値だけを受け付けます。EJSには固定テンプレートとデータを分けて渡し、タイマーにも固定のコールバックを使います。演算に応じた値の範囲やリクエスト数の制限は別途適用してください。

参考資料