想象这样一种情况:用户成功登录了你的应用程序,但在打开他们的仪表盘后,却看到了别人的数据。

为什么会出现这种问题呢?认证过程是成功的,会话也是有效的,用户身份已经得到验证,但授权环节却出现了问题。

这种具体的安全漏洞被称为IDOR(不安全的直接对象引用)。它是最常见的安全缺陷之一,在OWASP API安全十大漏洞中被归类为对象级授权失效漏洞

通过本教程,你将学习到以下内容:

  • IDOR漏洞产生的原因

  • 为什么仅靠认证是不够的

  • 对象级授权的工作原理

  • 如何在Next.js API路由中正确修复IDOR漏洞

  • 如何从一开始就设计更安全的API

目录

认证与授权的区别

在继续讲解之前,我们先明确一个关键概念。

  • 认证的作用是:确定用户身份。

  • 授权的作用是:决定用户是否有权访问某些资源。

在IDOR漏洞中,虽然认证过程成功了(用户已经登录),但授权环节却存在问题或根本没有进行授权检查。这一区别正是本文所要强调的核心内容。

什么是IDOR漏洞?

当你的API通过某个标识符(例如用户ID)来获取资源时,如果没有验证请求者是否确实拥有该资源或是否被允许访问它,就会发生IDOR漏洞。

举个例子:

GET /api/users/123

上述代码表示客户端正在向/api/users/123这个路由发送一个HTTP GET请求。使用GET方法可以向服务器请求数据,这意味着客户端想要获取ID为123的用户的资料,而服务器会以响应的形式返回这些数据(通常是以JSON格式)。

如果你的后端代码采用了类似的结构来处理请求,却没有检查发起请求的用户身份,那么即使用户已经登录,也会存在IDOR漏洞。

db.user.findUnique({ where: { id: "123" } })

这段代码的作用是从数据库中查询一条用户记录。`db.user`指的是`user`模型/表,而`findUnique()`是一个方法,它会根据某个唯一的字段返回仅一条记录。在这个方法中,`where`子句用于指定过滤条件,`{ id: “123” }`则告诉数据库去查找`id`值为“123”的用户。如果存在匹配的记录,该方法会返回该用户的对象;否则,它会返回`null`。

Next.js中的安全漏洞

来看这个Next.js应用路由代码:

// app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function GET(
  req: Request,
  { params }: { id: string }
) {
  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}

在分析这段代码的潜在问题之前,我们先来了解一下它的功能。它为`/api/users/[id]`这个路径定义了一个动态API路由。导出的`GET`函数是一个异步处理程序,当有GET请求发送到这个端点时,就会执行这个函数。它会接收请求对象以及一个`params`对象,而`params.id`中包含了URL中的动态参数`[id]`。`db.user.findUnique()`方法会从数据库中查找`id`与`params.id`相匹配的用户记录,而`select`选项则限制了返回的字段仅为`id`、`email`和`name`。最后,`NextResponse.json()`会将查询到的用户数据以JSON格式返回给客户端。

然而,这种写法存在严重的安全风险。因为这个路由直接从URL中获取用户ID,然后直接从数据库中检索相应的用户信息并返回结果,完全没有进行任何身份验证、权限检查或所有权验证。

如果已登录的用户修改了URL中的`id`值,他们就有可能访问其他用户的资料。这种情况显然属于“IDOR”安全漏洞。

如何在Next.js中防范IDOR漏洞

防范这种漏洞的第一步就是进行身份验证。我们可以使用NextAuth提供的`getServerSession`函数来进行验证(如果使用的是其他认证机制,也需要相应地进行调整)。这样就可以确保从cookie中读取会话信息,并在服务器端对其进行验证,从而确认用户具有有效的身份凭证。这样可以有效防止未经授权的访问。

// lib/auth.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/authOptions";

export async function requireSession() {
  const session = await getServerSession(authOptions);

  if (!session?.user?.id) {
    return null;
  }

  return session;
}

上述代码定义了一个名为requireSession的身份验证辅助函数。getServerSession(authOptions)函数会根据提供的身份验证配置从服务器中获取当前用户的会话信息。在后面的if语句块中,通过可选的链式操作session?.user?.id,可以安全地判断是否存在已登录的用户及其idnull,表示请求未经过身份验证;否则,它会返回完整的session对象,以便在受保护的路由或服务器逻辑中使用。

你已经确认用户和会话确实存在了,现在需要更新路由配置:

export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}

虽然目前的修改还不完整,但上述代码已经有效阻止了匿名用户的访问。GET处理函数会调用之前定义的requireSession()函数来验证请求是否经过身份验证。如果没有找到有效的会话信息,系统会立即返回一个包含错误信息的JSON响应,并设置HTTP状态码为401 Unauthorized;如果用户已经通过身份验证,系统则会继续执行db.user.findUnique()操作,以获取那些idparams.id相匹配的用户信息,并仅选择idemailname这些字段,最后通过NextResponse.json()函数将获取到的用户数据以JSON格式返回。

不过还有一点没有解决——任何已经通过身份验证的用户都可以通过更改URL路径来请求任何资源。这是为什么呢?这就引出了“对象级授权”的概念。

对象级授权

对象级授权能够确保用户只能访问自己的数据(除非有明确的允许机制)。

对代码进行改进的方法是添加所有权检查。这样,API请求在执行之前会先验证请求者是否已经通过身份验证,以及该请求者是否拥有所要访问的对象。如果其中任何一项不满足条件,系统就会拒绝访问请求。

export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  if (session.user.id !== params.id) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}

让我们来看看代码中发生了什么。首先,GET处理程序会使用requireSession()来验证请求的有效性;如果不存在有效的会话,就会返回401响应。接下来,它会将session.user.idparams.id进行比较以检查授权是否合法。如果两者不匹配,就会返回403 Forbidden响应,从而阻止用户访问其他用户的数据。如果这两项检查都通过,它就会使用db.user.findUnique()从数据库中检索指定的用户信息,并且只返回选定的字段数据。最后,它会以JSON格式将用户数据返回给客户端。通过这种方式,你就实现了对象级授权

如何设计更安全的端点(例如/api/me

在设计端点时,最安全的方法就是彻底消除风险。不要允许用户指定ID(比如/api/users/:id),而应该使用/api/me,因为服务器已经通过会话知道了用户的身份。

// app/api/me/route.ts
export async function GET() {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}

这种设计方式可以确保你的API只返回当前已认证用户的资料。首先,它会调用requireSession()来验证请求者的身份;如果不存在会话,就会返回401响应。它不会使用URL参数来获取用户ID,而是直接从session.user.id中获取该信息,这样就确保了用户只能访问自己的数据。然后,它会使用db.user.findUnique()从数据库中检索该用户的资料,并且只返回指定的字段,最后以JSON格式返回结果。

你可以放心地采用这种设计方式,因为客户端无法篡改用户ID。服务器是从可信的来源获取用户身份信息的,因此攻击面被大大降低了。这就是所谓的基于安全原则设计的API模型

现在你应该清楚地认识到:认证并不等同于授权。因此:

  • 当对象的所有权没有得到验证时,就会发生未经授权的访问。

  • 任何接受ID作为参数的API路由都必须对访问权限进行验证。

  • 更安全的API设计能够降低系统被攻击的风险。

  • 授权操作必须始终在服务器端进行。

API设计的思维模型

在编写任何API路由时,都需要思考以下这些问题:

  1. 是谁发出了这个请求?

  2. 他们想要获取的是哪个对象的信息?

  3. 当前的政策是否允许他们访问这些信息?

如果你无法清楚地回答这三个问题,那么你的API路由可能存在安全漏洞。

结论

IDOR漏洞产生的原因在于:API在未验证用户身份的真实性或是否拥有相应权限的情况下,就直接信任了用户提供的标识信息。

为防止这类漏洞在Next.js项目中发生,应對所有私密路由进行身份验证,严格执行对象级别的授权机制,将授权逻辑集中管理,并编写测试用例来检测非法访问行为。

安全防护并非仅仅意味着添加登录功能,而是要确保每一项对象访问操作都严格遵守安全政策。

Comments are closed.