Skip to main content

Node.jsにおけるMass Assignment脆弱性を防ぐ

feature buffer overflow

2023年3月28日

0 分で読めます

Mass Assignmentは、攻撃者が予測可能なレコードのパターンを悪用し、不正な操作を実行できる脆弱性です。通常、クライアントから提供されたデータをデータモデルにバインドする際に、プロパティがフィルタリングされていないと発生します。この種の脆弱性があると、攻撃者はPOSTリクエストのペイロードにオブジェクトを追加し、本来変更できないプロパティを変更できてしまいます。

Node.jsは、クロスプラットフォームでのWebアプリケーション開発に利用できるオープンソースのサーバー環境です。開発者は、パワフルでスケーラブルなアプリケーションをすばやく構築できます。Node.jsは実績が豊富で、本番環境にも対応したプラットフォームですが、アプリケーションの構築にインストールするサードパーティ製パッケージに関連するサプライチェーンセキュリティなど、アプリケーションセキュリティの課題もあります。Node.jsアプリケーションでは、npmレジストリから何千ものオープンソースnpmパッケージを利用することもあり、さまざまな攻撃ベクトルにさらされる可能性があります。

Mass Assignment脆弱性は、データベースにPOSTリクエストでペイロードを送信するNode.jsアプリケーションでよく見られます。攻撃者はこの脆弱性を利用してSQLインジェクションなど、データベース内のデータを標的とする攻撃を仕掛けることができます。Mass Assignment脆弱性を悪用すると、システムを完全に掌握したり、機密データを盗み出したりできるため、この種の攻撃から守ることが重要です。

この記事では、Node.jsプロジェクトにおけるMass Assignment脆弱性と攻撃者による悪用方法、Webアプリケーションを保護する方法を紹介します。

Node.jsのMass Assignment脆弱性を取り除く

この例では、Node.jsとMongoDB用のオブジェクトデータモデリング(ODM)ライブラリであるMongooseを使用します。コード内のオブジェクトとMongoDB内のオブジェクトを連携させ、スキーマを検証し、データのリレーションシップ管理を支援します。

上記を踏まえると、Node.jsのMongoDBアプリケーションをMass Assignmentから保護するには、Mongooseで対策する必要があります。以下は、Node.jsアプリケーションにおけるMass Assignment脆弱性の悪用例です。

前提条件

このプロジェクトを進めるには、次のものが必要です。

  • Node.jsがインストールされていること

  • MongoDBデータベースと有効な接続文字列へのアクセス。このチュートリアルではMongoDB Atlasのクラウド環境を使用しますが、ローカルのMongoDBサーバーを立ち上げて、接続文字列を適宜変更することもできます。

Node.jsにおけるMass Assignment

このプロジェクトを始めるには、ターミナルを開いて次のコマンドを実行します。

npm init

次に、依存関係をインストールします。

npm i mongoose
npm i express

上記のフォームから送信されたデータを処理するには、modelsフォルダにnewUser.jsファイルを作成し、ユーザースキーマを定義します。

const mongoose = require('mongoose');

const Schema = mongoose.Schema;

const userSchema = new Schema({

    first_name: String,
    last_name: String,
    email: String,
    password: String,
    isAdmin: {
        type: Boolean,
        protect: true,
        default: false
    }

});

const User = mongoose.model("User", userSchema);

module.exports = User;

このモデルには、登録フォームのすべてのフィールドが含まれています。追加フィールドisAdminはBoolean型で、ユーザーのロールを定義します。このフィールドをtrueに設定すると管理者権限を持つユーザーが作成され、falseに設定すると管理者権限を持たないユーザーが作成されます。

routesという新しいディレクトリを作成し、ユーザー情報を処理するroutes.jsファイルを追加します。

    const express = require("express");
    const userModel = require("../models/newUser");

    const app = express();

    app.post("/add_user", async (request, response) => {
        const user = new userModel(request.body);

        try {
          await user.save();
          response.send(user);
        } catch (error) {
          response.status(500).send(error);
        }
    });

module.exports = app;

上記のコードはフォームのデータを取得し、newUSerモデルを使って構造化します。

このコードには、Mass Assignment脆弱性を引き起こす問題が2つあります。1つ目は、機密フィールドisAdminに、よく使われる名前と型を使用していることです。2つ目は、ユーザーモデルに未使用の機密フィールドが含まれていることです。この脆弱性を悪用するため、サーバーを起動してみましょう。

プロジェクトのルートフォルダにserver.jsというファイルを作成し、次のコードを追加します。<APP_ID>は接続文字列内の値に置き換えてください。

const express = require("express");
const mongoose = require("mongoose");
const Router = require("./routes/routes")

const app = express();

app.use(express.json());

const username = "<USER>";
const password = "<USER_PASSWORD>";
const cluster = "<CLUSTER>";
const dbname = "<DATABSE_NAME>";

mongoose.connect(
  `mongodb+srv://${username}:${password}@${cluster}.<APP_ID>.mongodb.net/${dbname}?retryWrites=true&w=majority`,
  {
    useNewUrlParser: true,
    useUnifiedTopology: true
  }
);

const db = mongoose.connection;
db.on("error", console.error.bind(console, "connection error: "));
db.once("open", function () {
  console.log("Connected successfully");
});

app.use(Router);

app.listen(3000, () => {
  console.log("Server is running at port 3000");
});

node server.jsを実行してサーバーを起動します。

悪意ある攻撃者は、isAdminフィールドの値をtrueにしたcurl POSTリクエストを作成することで、この脆弱性を悪用できます。以下のコードを参照してください。

curl --location --request POST 'http://localhost:3000/add_user' \
--header 'Content-Type: application/json' \
--data-raw '{
    "first_name": "Mike",
    "last_name": "Jones",
    "email": "mikejones@hmail.com",
    "isAdmin":"true"
}'

上記のリクエストにより、管理者権限を持つユーザーMikeが作成されます。このユーザーは認証情報を使ってシステムにログインし、管理者ができることをすべて実行できるようになります。

Mass Assignment攻撃を防ぐ

アプリケーションのMass Assignment攻撃に対する防御を強化するには、3つの変更を加えます。

シンプルなモデルを作成する

シンプルなモデルには、ユーザーが入力する想定の項目だけを含め、機密フィールドは含めません。

上記の例では、まずnewUserモデルから機密フィールドを削除することが、最も重要な対策となります。以下のように変更します。

const mongoose = require("mongoose");

// create a schema
var userSchema = new mongoose.Schema({
	first_name: String,
	last_name: String,
	email: String,
	password: String

	//NB: there is no isAdmin field

});

const User = mongoose.model("User", userSchema);

module.exports = User;

攻撃者がソースコードにアクセスしても、機密フィールドを見つけて攻撃に利用できなくなります。また、データベースでユーザーのロールを定義する際は、isAdmin、admin、roleなど、推測されやすい用語の使用も避けましょう。攻撃者に推測され、攻撃に利用される可能性があります。

ユーザー入力にスキーマ検証を使用する

underscoreを使った検証

アプリを保護するもう1つの方法は、POSTリクエストで処理可能なフィールドを指定して制限することです。これは許可リスト方式と呼ばれ、underscoreライブラリを使用します。まず、ライブラリをインストールします。

npm install underscore

routesフォルダに移動し、route.jsのコードを次の内容に置き換えます。

const express = require("express");
const userModel = require("../models/newUser");
var _ = require('underscore');

const app = express();

app.post("/add_user", async (request, response) => {
    const user = new userModel( _.pick(request.body, 'first_name', 'last_name', 'email', 'password'));

    try {
      await user.save();
      response.send(user);
    } catch (error) {
      response.status(500).send(error);
    }
});

module.exports = app;

上記のコードでは、pick関数を使ってPOSTリクエストから抽出する変数を指定しています。これにより、攻撃者が機密フィールドisAdminを使用するのを防げます。

underscoreを使う場合の課題は、データが有効かどうかを判断するために、アプリケーション側でデータを処理しなければならないことです。つまり、エラーを十分早い段階で検出できません。

Zodを使った検証

堅牢なスキーマ検証には、Zodを使用しましょう。指定した構造、パターン、データ型にデータが厳密に適合するかを検証します。Zodは不完全または不正なデータを早期に検出し、アプリケーションのエラーを防ぎます。また、エラーの原因となった入力項目やエラーの種類をユーザーに知らせるメッセージも簡単に作成できます。

次のコードスニペットはZodの動作を示しています。以下のスニペットはTypeScriptで記述されているため、追加の設定が必要です。

import { z, AnyZodObject } from "zod";
import express, { Request, Response, NextFunction } from 'express';

const app = express();

app.use(express.json());

const dataSchema = z.object({
    body: z.object({
        first_name: z.string({
            required_error: "First name is required",
            invalid_type_error: "First name must be a string",
        }),
        last_name: z.string({
            required_error: "Last name is required",
            invalid_type_error: "Last name must be a string",
        }),
        password: z.string({
            required_error: "Password is required",
        }),
        email: z
            .string({
                required_error: "Email is required",
            })
            .email("Not a valid email"),
    }),
});

const validate =
    (schema: AnyZodObject) =>
    async (req: Request, res: Response, next: NextFunction) => {
        try {
            await schema.parseAsync({
                body: req.body,
                query: req.query,
                params: req.params,
            });
            return next();
        } catch (error) {
            return res.status(400).json(error);
        }
    };

app.post("/create",
    validate(dataSchema),
    (req: Request, res: Response): Response => {
        return res.json({
            ...req.body
        });
    }
);

スキーマに一致しないデータを入力すると、invalid_type_errorが発生したことを示すエラーが表示されます。フィールドを空欄のままにすると、required_errorが表示されます。

ORMにおけるMass Assignment脆弱性のその他の例

一般的なORMソリューションにおけるMass Assignment脆弱性を、さらに3つの例で見ていきましょう。

SequelizeコードにおけるMass Assignment脆弱性

Mass Assignmentに対して脆弱なSequelizeコードの例を見てみましょう。このコードはユーザーモデルを定義し、ユーザーを作成して、ユーザー情報をデータベースに保存します。

const { Sequelize } = require("sequelize");

const User = sequelize.define("users", {
    username: {
        type: DataTypes.STRING,
        allowNull: false
    },

    email: {
        type: DataTypes.STRING,
        allowNull: false
    },

    password: {
        type: DataTypes.STRING,
        allowNull: false
    },

    is_verified: {
        type: DataTypes.BOOLEAN,
        allowNull: false,
        default: false
    },

 });

新しいユーザーを作成するとき、コントローラーは以下のように上記のモデルを呼び出します。

// Create a User
const user = {
    username: req.body.username,
    email: req.body.email,
    password: req.body.password,
    is_verified: false
  };

//Save User in the database
User.create(user)
.then(data => {
    res.send(data);
})
.catch(err => {
    res.status(500).send({
    message:
        err.message || "Error occurred. User not created!"
    });
});

上記のコードは、機密フィールドis_verifiedを公開しているため脆弱です。攻撃者はis_verifiedフィールドをtrueに設定したPOSTリクエストを作成し、検証ステップを回避できます。

ユーザー作成時にis_verifiedフィールドを除外すれば、このコードのMass Assignment脆弱性を取り除けます。安全なコードは次のようになります。

// Create a User
const user = {
    username: req.body.username,
    email: req.body.email,
    password: req.body.password
  };

これにより、受け付けるPOSTリクエストの変数をusername、email、passwordに制限できます。

PrismaコードにおけるMass Assignment脆弱性

以下は、機密フィールドRoleを公開してしまう、Mass Assignmentに対して脆弱なPrismaコードの例です。

model User {
    id      		Int    @id @default(autoincrement())
    username        String
    email       	String
    Role    		Boolean    @default(USER)
  }

enum Role {
    USER
    ADMIN
}

ユーザーを追加するには、add_userルートを作成します。

const { PrismaClient } = require("@prisma/client");
const express = require("express");
const prisma = new PrismaClient();

const app = express();

app.use(express.json());

app.post('/add_user', async (req, res) => {
    const user = await prisma.user.create({ data: req.body });
    res.json(user);
  });

このPOSTリクエストでは、ユーザーオブジェクトを含むボディが想定されています。上記のコードはMass Assignment攻撃に対して脆弱です。攻撃者がroleフィールドにADMINを設定すると、管理者権限を取得できるためです。

上記のモデルから機密フィールドRoleを除外すれば、このコードの脆弱性を取り除けます。

model User {
    id      		Int    @id @default(autoincrement())
    username        String
    email       	String
  }

MySQLコードにおけるMass Assignment脆弱性

以下は、機密フィールドisAdminを公開する、Mass Assignmentに対して脆弱なMySQLコードの例です。

// Defining the user data
let user = {
    username: req.body.username,
    email: req.body.email,
    password: req.body.password,
    isAdmin: req.body.role
};

このコードは、上記で定義したユーザーを追加します。

// Saving user details
app.post('/add_user',(req, res) => {

let myQuery = "INSERT INTO users SET ?";

    let query = connection.query(myQuery, user,(err, results) => {
        if(err) throw err;
        res.send(apiResponse(results));
    });

});

上記のコードは、機密フィールドisAdminを公開し、POSTリクエストに含められるため、Mass Assignment脆弱性があります。攻撃者はisAdminフィールドをtrueに設定したPOSTリクエストを作成し、管理者ユーザーを作成できます。

userオブジェクトの作成時に機密フィールドを除外すれば、この脆弱性を修正できます。安全なコードは次のとおりです。

// Defining the user data
let user = {
    username: req.body.username,
    email: req.body.email,
    password: req.body.password
};

次のコマンドでテーブルを変更すると、isAdminフィールドのデフォルト値をfalseに設定できます。

ALTER TABLE users ALTER isAdmin SET DEFAULT ‘false’;

Node.jsのコードを安全に保つ

Node.js自体は安全ですが、Webアプリケーションの構築に必要なパッケージが安全とは限りません。データベースにリクエストを送信するNode.jsアプリケーションでは、Mass Assignment脆弱性が最も一般的な攻撃ベクトルの1つです。悪意あるリクエストをデータベースに送信するSQLインジェクション攻撃を可能にします。

ただし、入力値をサニタイズすれば、Node.jsのMass Assignmentは簡単に防止できます。機密フィールドを公開しない、受け付けるフィールドを制限する、または一部のフィールドの編集を禁止することで対策できます。

堅牢なユーザー入力検証を実装しないと、システムはプロトタイプ汚染などの攻撃にさらされます。このような攻撃は、underscoreのような脆弱な検証メカニズムを回避できます。そのため、Zodのような堅牢なソリューションを必ず使用してください。

GitHubリポジトリでソースコードの完全な例を確認し、Node.jsでMass Assignment脆弱性を防ぐ方法をローカル環境で試してみましょう。

SnykのNode.jsセキュリティに関するベストプラクティス トップ10を参考に、Node.jsアプリケーションのセキュリティを強化しましょう。

Capture the Flagを始めよう

オンデマンドのバーチャル入門ワークショップを見て、Capture the Flagの課題の解き方を学びましょう。