PAYATHON 2026

将 mongo-db ObjectId 暴露给前端是否存在安全风险?

支付阿杰

结论

把 MongoDB ObjectId 暴露给前端通常不会造成安全漏洞,也没有必要仅仅为了隐藏数据库 ID,再设计一套标识符。

应当把 ObjectId 当作公开标识符,而不是密码、访问令牌或权限凭证。安全校验必须由后端完成:前端提交商品 ID 和数量,后端重新查询商品、检查状态并计算最终价格,不能信任 localStorage 中保存的价格、折扣或总金额。

可能存在的风险

越权访问

攻击者可以修改 localStorage、请求体和商品 ID。如果接口只按 ID 查询数据,没有检查访问权限,就可能发生越权访问。

例如,下面的逻辑并不安全:

const order = await orders.findOne({
  _id: new ObjectId(req.params.id)
});

如果订单属于特定用户,查询条件还必须包含用户身份:

const order = await orders.findOne({
  _id: new ObjectId(req.params.id),
  userId: req.user.id
});

公开销售的商品可以公开商品 ID。订单、地址、优惠券等私有资源则需要逐项检查,确认当前用户确实有权访问。

暴露少量元数据

MongoDB 的标准 ObjectId 包含与生成时间有关的信息,别人可能据此推测记录的大致创建时间。它不像递增整数那样容易遍历,但也不能当作无法猜测的秘密。

如果业务不能接受这类信息泄露,可以增加 UUID、随机 public ID 或 SKU。这属于隐私和接口设计方面的需求,并不是核验商品价格所必需的。

非法输入和数据库查询滥用

攻击者可能提交:

  • 格式错误的 ID
  • 不存在或已经下架的商品
  • 重复 ID
  • 极大的商品数组
  • 负数、小数或异常大的数量
  • 不属于当前商户或销售区域的商品

后端应限制请求规模、验证 ID 格式,并明确构造查询条件。不要把客户端提交的对象直接拼进 MongoDB 查询。

推荐的购物车数据结构

购物车只需要保存商品标识和数量:

[
  {
    "productId": "66c7421eaf98c716fc217a31",
    "quantity": 2
  },
  {
    "productId": "66c74265af98c716fc217a32",
    "quantity": 1
  }
]

不要依赖客户端保存的以下字段:

{
  "price": 0.01,
  "discount": 99,
  "total": 0.01,
  "currency": "CNY"
}

客户端可以保存价格供页面展示,但结算时必须忽略这些值。商品之间是否同价不影响处理,因为后端会根据唯一的 productId 查询对应商品。

后端核验和计价示例

下面以 Express 和 MongoDB Node.js Driver 为例:

import { ObjectId } from "mongodb";

app.post("/api/checkout/quote", async (req, res) => {
  const cart = req.body.items;

  if (!Array.isArray(cart) || cart.length === 0 || cart.length > 100) {
    return res.status(400).json({ error: "Invalid cart" });
  }

  const normalizedItems = new Map();

  for (const item of cart) {
    if (
      typeof item?.productId !== "string" ||
      !ObjectId.isValid(item.productId) ||
      !Number.isSafeInteger(item.quantity) ||
      item.quantity < 1 ||
      item.quantity > 99
    ) {
      return res.status(400).json({ error: "Invalid cart item" });
    }

    const currentQuantity = normalizedItems.get(item.productId) ?? 0;
    const nextQuantity = currentQuantity + item.quantity;

    if (nextQuantity > 99) {
      return res.status(400).json({ error: "Quantity exceeds limit" });
    }

    normalizedItems.set(item.productId, nextQuantity);
  }

  const productIds = [...normalizedItems.keys()].map(
    id => new ObjectId(id)
  );

  const products = await db.collection("products")
    .find({
      _id: { $in: productIds },
      active: true
    })
    .project({
      name: 1,
      priceInCents: 1,
      currency: 1,
      stock: 1
    })
    .toArray();

  if (products.length !== productIds.length) {
    return res.status(400).json({
      error: "Some products are unavailable"
    });
  }

  let totalInCents = 0;
  const verifiedItems = [];

  for (const product of products) {
    const quantity = normalizedItems.get(product._id.toString());

    if (product.stock < quantity) {
      return res.status(409).json({
        error: `Insufficient stock for product ${product._id}`
      });
    }

    const lineTotalInCents = product.priceInCents * quantity;
    totalInCents += lineTotalInCents;

    verifiedItems.push({
      productId: product._id.toString(),
      name: product.name,
      quantity,
      unitPriceInCents: product.priceInCents,
      lineTotalInCents
    });
  }

  res.json({
    currency: "CNY",
    items: verifiedItems,
    totalInCents
  });
});

金额最好用最小货币单位的整数表示。例如,用 1999 表示 19.99 元,以免受到二进制浮点数精度问题的影响。

创建支付请求时再核验一次

报价接口返回的金额只能用于展示。真正创建支付订单时,后端仍要重新核验商品价格、库存、优惠条件和运费,再由后端调用支付平台:

app.post("/api/checkout/pay", async (req, res) => {
  const verifiedOrder = await calculateOrderOnServer({
    userId: req.user.id,
    items: req.body.items,
    shippingAddressId: req.body.shippingAddressId,
    couponCode: req.body.couponCode
  });

  const payment = await paymentProvider.createPayment({
    amount: verifiedOrder.totalInCents,
    currency: verifiedOrder.currency,
    reference: verifiedOrder.orderId
  });

  res.json({
    orderId: verifiedOrder.orderId,
    paymentUrl: payment.url
  });
});

不要让浏览器决定传给支付平台的最终金额。即使支付平台的页面无法被篡改,攻击者仍可能修改应用发起支付时使用的请求参数。

是否需要改用其他 ID

可以换用其他公开标识符,但无论使用哪一种,都不能省略后端校验。

常见选择包括:

  • ObjectId:可以直接查询,简单实用,适合大多数场景。
  • SKU:方便运营和库存管理,但必须保证唯一,也不宜频繁修改。
  • UUID:不会暴露 ObjectId 中的时间信息,但通常需要新增字段和唯一索引。
  • 随机 public ID:可以将公开 API 标识与数据库主键分开,但需要额外维护字段、索引和映射逻辑。
  • slug:适合商品详情页 URL,但未必稳定,还要处理名称重复的问题。

如果没有隐藏创建时间、迁移数据库或长期保持公开 ID 稳定等明确需求,直接使用 ObjectId 通常最简单。

还需要注意的事项

  • localStorage 完全受用户控制,只适合保存购物车状态,不能作为可信的价格来源。
  • 需要防范 XSS,因为页面中的恶意脚本可以读取和修改 localStorage。
  • 商品 ID 存在,不等于商品可以购买。后端还要检查上下架状态、库存、销售区域和用户资格。
  • 优惠券、会员价、税费、运费和货币种类都应由后端确定。
  • 创建订单时应保存成交价格快照,避免商品后续改价影响历史订单金额。
  • 对结算接口设置商品数量、请求体大小和调用频率限制。
  • 支付完成后,通过支付平台的服务端通知核对订单号、币种和实际支付金额,不能只相信浏览器的跳转结果。

商品的 ObjectId 可以直接放入购物车并发送给后端。安全与否不取决于是否隐藏 ID,而取决于后端是否把客户端提交的数据视为不可信输入,并完成授权检查、商品状态核验和最终金额计算。

备注:内容仅供参考。