tva
← 洞察

多阶段安全审计:加固 Next.js 应用

无论采用什么技术栈,Web 应用安全审计往往都会发现同几类问题:授权漏洞、缺少输入验证、HTTP 安全响应头不足,以及身份验证流程中的时序漏洞。具体表现因框架而异,但底层模式一致。差别在于审计是否有系统地组织:缺乏结构的审查容易发现明显问题,却漏掉细微隐患。

我们对一个使用 Supabase 作为后端的生产 Next.js 应用进行了多阶段审计。该应用负责用户身份验证,存储与用户关联的记录,并提供若干供前端和外部 webhook 使用的 API 路由。发现的问题没有造成灾难性后果,但其中几项若被恶意人员发现,就可能演变为严重事件。本文介绍审计范围、发现的问题,以及由此作出的改动。

第一阶段:身份验证与授权

第一阶段聚焦身份验证层,以及控制数据访问的行级安全(RLS)策略。这是任何 Supabase 应用中最关键的控制措施:如果身份验证失效或 RLS 配置错误,再多输入验证或速率限制也无法弥补。

审计首先全面盘点 Supabase 表及其 RLS 状态。对外开放的数据模式中的每张表都应启用 RLS。在 Supabase 的默认授权配置下,public 等对外开放的数据模式中,任何未启用 RLS 的表,持有 anon key 的人都可以读取和写入;这个密钥按设计就是公开的,并包含在客户端构建产物中。该应用有一张表关闭了 RLS。它是一张作为参考数据使用的查找表,创建时只包含静态值。后来陆续添加的部分行含有与用户关联、应受保护的元数据。修复很直接:启用 RLS 并添加策略。不过,背后的流程缺陷值得重视:新表应默认启用 RLS,而不是事后补上。

随后,我们检查受保护表的 RLS 策略是否正确。一个常见错误是用户 ID 检查本身正确,却检查了错误的列。例如,documents 表上的 auth.uid() = created_by 策略可将访问限制为创建者;但如果应用还支持共享文档,document_collaborators 关联表中的协作者记录也需要自己的策略。主表的检查不会自动延伸到关联数据。

其中一项策略存在一个代码审查未发现的逻辑错误:本应同时使用 USING 和 WITH CHECK,却只用了 USING。USING 子句决定命令可以查看、更新或删除哪些现有行;WITH CHECK 决定可以写入哪些新增或修改后的行。当涵盖写入的策略(FOR ALL 或 FOR UPDATE)省略 WITH CHECK 时,PostgreSQL 会将 USING 表达式复用于写入。因此,一条按读取规则编写的 USING 子句让任何已通过身份验证的用户都能写入其可读取的行,而这并非预期行为。这是 PostgreSQL RLS 中容易忽视的细节。

第二阶段:输入验证

第二阶段审查用户提供的数据进入系统的所有入口:app/api/ 中的 API 路由处理函数、服务器操作和表单提交。目标是确认所有输入在使用前都经过验证,且验证失败时返回清晰的错误,而不是未处理的异常。

应用部分使用了 Zod,但用法不一致。有些路由定义了完整的数据模式,并在处理函数开头调用 schema.parse();另一些则直接读取 req.body 字段而不验证,依赖无法提供运行时安全保障的 TypeScript 类型断言:

// Unsafe: TypeScript type assertion, no runtime validation
const { userId, documentId } = req.body as { userId: string; documentId: string };

// Safe: Zod parse, throws on invalid input
const schema = z.object({
  userId: z.string().uuid(),
  documentId: z.string().uuid(),
});
const { userId, documentId } = schema.parse(req.body);

修复措施是为所有尚未验证的 API 路由补上 Zod 数据模式,并建立所有路由都要经过的共享验证中间件。采用中间件模式很重要:即使新增代码路径的开发者不了解验证要求,也无法绕过验证层。

一条路由接收 redirectUrl 参数,并将它用于 res.redirect() 调用。这会造成开放重定向漏洞:攻击者可以构造指向应用的链接,在看似正常的交互后将用户重定向到钓鱼网站。修复方法是依据允许的路径列表验证重定向目标:

const ALLOWED_REDIRECT_PATHS = ['/dashboard', '/settings', '/profile'];

const redirectPath = schema.parse(req.query).redirectUrl;
if (!ALLOWED_REDIRECT_PATHS.includes(redirectPath)) {
  return res.redirect('/dashboard');
}
return res.redirect(redirectPath);

第三阶段:HTTP 安全响应头与 CSP

应用完全没有设置内容安全策略(CSP)响应头。缺少 CSP 时,无论通过用户生成内容还是被入侵的第三方脚本,只要 XSS 注入成功,就能在应用上下文中执行任意 JavaScript,并访问身份验证令牌和用户数据。

为 Next.js 应用添加 CSP 需要谨慎,因为 Next.js 默认会内联部分脚本。推荐使用 nonce:为每个请求生成一个加密安全的随机值,将其加入 CSP 响应头,并应用到每个内联脚本。Next.js 通过读取请求头中的策略,将 nonce 应用于自己的脚本,因此生产环境中的中间件还必须通过 NextResponse.next({ request: { headers } }) 传递 CSP(通常还包括 x-nonce 请求头),具体做法见 Next.js 的 CSP 指南;以下代码片段只设置了响应头。此外,它导入了 Node 的 crypto 模块,而 Edge 运行时不提供该模块;在 Edge 环境中应使用全局 Web Crypto API(例如 crypto.randomUUID()):

// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export function middleware(request: Request) {
  const nonce = crypto.randomBytes(16).toString('base64');
  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  return response;
}

首次部署 CSP 时,我们使用 Content-Security-Policy-Report-Only 模式,只报告违规而不阻止执行。这样发现了若干合法内联脚本和第三方来源,需要先加入允许列表,再切换到强制执行模式。先以仅报告模式运行一周,再强制执行,是一种常见做法;跳过这一步直接强制执行,通常会破坏测试中未发现的功能。

审计还发现,负载均衡器设置了 Strict-Transport-Security 响应头,但应用没有设置。如果负载均衡器是唯一入口,这可以接受,但仍应明确确认。纵深防御意味着,即使基础设施已提供安全响应头,应用也应自行设置。

第四阶段:速率限制

身份验证端点,包括登录、注册和密码重置,都没有速率限制。这些端点会接受无限量的请求,让攻击者可以暴力破解密码,并利用响应行为的差异枚举有效电子邮件地址。

Next.js API 路由的速率限制可以用 Redis 支持的计数器实现;以下示例使用固定窗口,比滑动窗口更简单。由于它分别调用 INCR 和 EXPIRE,两次调用之间发生故障可能会留下没有过期时间的键,因此生产代码应以原子方式执行这两项操作(例如使用事务或 Lua 脚本)。键通常是 IP 地址;对于已通过身份验证的端点,也可以使用用户 ID。身份验证端点适合按 IP 限制,因为用户尚未通过身份验证:

// lib/rateLimit.ts
import { Redis } from '@upstash/redis';

const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! });

export async function checkRateLimit(identifier: string, limit: number, windowSeconds: number) {
  const key = `rate_limit:${identifier}`;
  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, windowSeconds);
  }
  return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}

我们采用了保守的限制:登录尝试按每个 IP、每十五分钟窗口限制,密码重置请求按每个电子邮件地址、每小时限制。限制足够宽松,正常使用的合法用户应该很少触及,又足够严格,使自动化暴力破解攻击在实践中难以实施。

第五阶段:时序攻击防护

攻击者可以对身份验证端点发起时序攻击,通过测量“找不到电子邮件地址”与“找到电子邮件地址,但密码错误”之间的响应时间差,推断某个地址是否已注册。找到用户的查询会稍慢,因为只有找到用户后才会执行密码比较(使用 bcrypt 或 Argon2)。这种差异通常为 50–200ms,请求次数足够多时就可以测出。

标准缓解方法是无论用户是否存在,都执行密码哈希比较;用户不存在时,使用一个占位哈希:

const DUMMY_HASH = '$2b$12$dummyhashvaluethatisvalidbutnevermatchesanypassword';

export async function verifyLogin(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } });

  // Always run bcrypt comparison to normalize response time
  const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
  const isValid = await bcrypt.compare(password, hashToCompare);

  if (!user || !isValid) {
    return null;  // Same code path for both failure cases
  }
  return user;
}

上面的 DUMMY_HASH 是一个占位值:它不是有效的 60 字符 bcrypt 哈希,bcryptjs 等库遇到格式错误的哈希时会立即返回 false,这会使响应时间归一化失效。实际使用时,应在启动时一次性生成一个真正的哈希(例如对随机字符串调用 bcrypt.hash()),并使用与已存储哈希相同的成本因子。本次问题不涉及 Supabase 内置的登录端点,而是出现在应用为兼容旧版集成而添加的自定义身份验证路由中。任何未使用 Supabase 身份验证函数的自定义逻辑,都需要明确实现响应时间归一化。

改了什么,哪些方面没有改变

五个阶段发现的所有问题都处理完毕后,审计才被认定为完成。优先级很明确:RLS 配置错误和开放重定向被视为关键问题,立即修复;CSP 部署和速率限制为高优先级,在同一个迭代中上线;旧版路由的时序攻击归一化为中优先级,在下一个迭代中发布。

审计也推动了一系列流程改进:现在,新表的 RLS 策略必须经过审查,拉取请求才能合并;API 路由采用共享的 Zod 验证包装器,并将其纳入路由模板;CSP 响应头改为从集中配置生成,不再使用内联字符串。

最重要的成果不是某一项修复,而是完整盘点。明确每张表包含什么数据、由哪项 RLS 策略控制、哪些 API 路由会访问它,是应用演进过程中作出正确安全决策的基础。没有这份清单,安全工作只能被动响应,发现什么就修什么;有了清单,就能系统地推理和评估安全性。

相关洞察

延伸阅读