在Next.js项目里,API Routes的安全过滤问题,几乎每个开发者在构建生产级应用时都会踩坑。最常见的误区是以为把敏感逻辑放在服务端API Routes里就天然安全了,实际上,如果不做严格的输入过滤和访问控制,API Routes反而会成为整个应用最薄弱的攻击面。因为Next.js的API Routes本质上就是运行在服务端的无服务器函数,它们直接暴露在公网,接收来自任何地方的HTTP请求,参数校验缺失、SQL注入、XSS、未授权访问等风险一点都不会少。

API Routes安全过滤的核心思路

安全过滤不是简单地在某个函数里加几行正则表达式,而是一套贯穿请求生命周期的防御体系。这个体系至少包含四层:输入验证与净化、身份认证与授权、速率限制与滥用防护、以及错误信息的安全处理。每一层都有对应的实现策略和工具,而且这些策略必须从项目搭建初期就植入架构,而不是事后打补丁。

先看最基础也最容易出问题的输入验证层。Next.js的API路由接收的req.body、req.query、req.headers,每一个都是外部输入,必须默认认为它们不可信。很多开发者习惯直接使用req.body.title这样的值去拼接数据库查询,或者直接返回到前端展示,这给SQL注入和存储型XSS留下了入口。正确的做法是引入专门的验证库,比如zod或者joi,在API函数的最顶部建立一道严格的类型安全屏障。

import { z } from 'zod';

const userInputSchema = z.object({
  name: z.string().min(2).max(50).trim(),
  email: z.string().email().toLowerCase().trim(),
  age: z.number().int().min(0).max(120).optional(),
  tags: z.array(z.string().max(20)).max(10).optional(),
});

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const parsed = userInputSchema.safeParse(req.body);
  
  if (!parsed.success) {
    return res.status(400).json({ 
      error: 'Invalid input', 
      details: parsed.error.flatten().fieldErrors 
    });
  }

  const sanitizedData = parsed.data;
  // 后续使用sanitizedData进行业务处理
}

上面这段代码展示了一个完整的输入验证流程。zod不仅验证类型,还能自动完成trim、大小写转换等净化操作。关键在于使用了safeParse而不是parse,这样不会抛出异常导致服务器崩溃,而是返回结构化的错误信息。注意错误响应里我只返回了字段级别的错误描述,没有暴露具体的验证规则或堆栈信息,这是安全处理错误信息的基本原则。

针对特殊场景的深度过滤

输入验证只是第一关。有些场景需要更深度的内容过滤,比如用户提交的内容可能包含HTML标签、Markdown语法,甚至是精心构造的XSS攻击载荷。这时候需要引入专门的净化库,最常用的是DOMPurify,它在服务端同样可以运行。

import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';

const window = new JSDOM('').window;
const purify = DOMPurify(window);

export function sanitizeHtml(dirty: string): string {
  return purify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href', 'title', 'target'],
    ALLOW_DATA_ATTR: false,
  });
}

这个净化函数可以放在API路由接收到富文本内容之后、存入数据库之前执行。DOMPurify的白名单机制确保只允许安全的标签和属性通过,任何script标签、事件处理器属性如onclick都会被直接剥离。对于允许的a标签,还应该强制加上rel="noopener noreferrer"和target="_blank"的安全属性,这些可以在净化配置里通过钩子函数自动添加。

文件上传是另一个需要深度过滤的高危场景。Next.js默认不处理文件上传,但很多应用会集成第三方存储服务。即使文件直接上传到云存储,API路由接收到的文件元数据也必须严格校验。文件类型不能只依赖客户端传来的MIME type,必须通过文件内容的魔数来验证真实类型。文件大小限制要在API层做二次校验,不能只在前端限制。文件名需要过滤掉路径遍历字符,防止类似../../../etc/passwd的攻击。

身份认证与授权的中间件模式

Next.js的API Routes没有内置的中间件系统,但可以通过高阶函数模式来构建可复用的认证层。一个典型的JWT认证中间件可以这样实现:

import { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';

interface AuthenticatedRequest extends NextApiRequest {
  userId?: string;
  userRole?: string;
}

type ApiHandler = (req: AuthenticatedRequest, res: NextApiResponse) => Promise;

export function withAuth(handler: ApiHandler): ApiHandler {
  return async (req: AuthenticatedRequest, res: NextApiResponse) => {
    const authHeader = req.headers.authorization;
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Missing or invalid authorization header' });
    }

    const token = authHeader.split(' ')[1];
    
    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string; role: string };
      req.userId = decoded.userId;
      req.userRole = decoded.role;
      return handler(req, res);
    } catch (error) {
      return res.status(401).json({ error: 'Invalid or expired token' });
    }
  };
}

这个中间件包装器可以在任何需要认证的API路由上使用,只需将处理函数传入withAuth即可。更进一步的授权控制可以叠加角色检查中间件,形成多层包装。这种模式比在每个API函数里重复写认证逻辑要安全得多,因为不会出现某个开发者忘记添加认证检查的情况。

对于更复杂的权限控制场景,比如基于资源的访问控制,可以在中间件里注入权限校验函数。例如检查当前用户是否有权访问某个项目ID对应的资源,这种校验必须发生在数据库查询之前,而不是查出数据之后再判断,否则会造成越权访问的信息泄露。

速率限制与滥用防护的实战策略

API Routes如果没有速率限制,任何人都可以用脚本暴力请求,轻则消耗服务器资源,重则撞库破解或拖垮数据库。Next.js生态里可以用upstash/ratelimit配合Redis实现分布式速率限制,但对于中小型项目,内存级别的限制也能起到基本防护作用。

const rateLimitMap = new Map();

export function rateLimit(limit: number = 10, windowMs: number = 60000) {
  return (req: NextApiRequest, res: NextApiResponse, next: () => void) => {
    const ip = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || 'unknown';
    const now = Date.now();
    const record = rateLimitMap.get(ip);

    if (!record || now > record.resetTime) {
      rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs });
      return next();
    }

    if (record.count >= limit) {
      return res.status(429).json({ error: 'Too many requests, please try again later' });
    }

    record.count++;
    return next();
  };
}

这个简易实现适合单实例部署的场景。生产环境建议使用Redis存储计数,确保多实例间共享状态。速率限制的阈值需要根据API的敏感程度分级设置:登录接口可能限制为每分钟5次,普通查询接口每分钟60次,静态数据接口可以放宽到每分钟300次。关键是要在响应头里返回X-RateLimit-Remaining和X-RateLimit-Reset信息,让客户端可以优雅处理限流。

SQL注入防护与参数化查询

虽然现代ORM如Prisma已经很大程度上防止了SQL注入,但在需要执行原生查询的场景,风险依然存在。Next.js项目里如果使用了Prisma的$queryRaw或者直接集成了其他数据库驱动,必须确保所有用户输入都通过参数化方式传递,永远不要拼接字符串。

// 危险的做法,绝对禁止
const result = await prisma.$queryRawUnsafe(
  `SELECT * FROM users WHERE email = '${req.body.email}'`
);

// 安全的参数化查询
const result = await prisma.$queryRaw`
  SELECT * FROM users WHERE email = ${req.body.email}
`;

Prisma的模板字面量语法会自动将变量参数化,从根本上杜绝SQL注入。如果使用其他数据库驱动如pg或mysql2,同样要使用参数占位符而不是字符串拼接。另外,数据库账号的权限应该遵循最小权限原则,API使用的数据库连接账号只授予必要的CRUD权限,不授予DDL权限,这样即使出现注入漏洞,攻击者也无法执行DROP TABLE之类的破坏性操作。

CORS配置与CSRF防护

Next.js的API Routes默认同源策略受浏览器限制,但如果需要开放给其他域名的前端调用,就需要配置CORS。很多开发者图省事直接设置Access-Control-Allow-Origin: *,这等于完全放弃了跨域安全控制。正确的做法是维护一个允许的域名白名单,动态设置响应头。

const allowedOrigins = ['https://yourdomain.com', 'https://app.yourdomain.com'];

export function corsMiddleware(req: NextApiRequest, res: NextApiResponse) {
  const origin = req.headers.origin;
  
  if (origin && allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
  }
  
  if (req.method === 'OPTIONS') {
    res.status(200).end();
    return true;
  }
  return false;
}

CSRF防护在Next.js里同样不能忽视。对于使用Cookie进行会话管理的应用,应该采用SameSite=Strict或Lax的Cookie属性,并结合CSRF Token机制。对于纯API服务使用Bearer Token认证的场景,CSRF风险相对较小,因为浏览器不会自动在跨域请求中附加Authorization头,但仍需注意防止Token泄露到日志或URL参数中。

安全响应头的统一配置

除了业务逻辑层面的过滤,HTTP安全响应头是最后一道防线。这些头信息可以在next.config.js里统一配置,也可以在每个API路由里单独设置。推荐使用helmet库来快速添加安全头,但要注意Next.js的API Routes环境下需要适配。

export function setSecurityHeaders(res: NextApiResponse) {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-XSS-Protection', '1; mode=block');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
  res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'");
}

这个函数可以在每个API响应的入口处调用。Content-Security-Policy头对于API路由来说可能看起来多余,但如果API返回了HTML内容或者被错误地当作页面渲染,CSP能提供额外保护。Strict-Transport-Security头则应该在反向代理层面设置,确保所有连接都通过HTTPS。

日志记录与安全监控

安全过滤的最后一块拼图是日志和监控。所有的验证失败、认证失败、速率限制触发事件都应该记录到日志系统,而且日志内容本身也需要过滤,绝不能记录密码、Token、身份证号等敏感信息。可以在中间件里统一实现日志脱敏逻辑。

function sanitizeForLogging(obj: any): any {
  const sensitiveFields = ['password', 'token', 'secret', 'creditCard', 'ssn'];
  if (typeof obj !== 'object' || obj === null) return obj;
  
  const sanitized = { ...obj };
  for (const field of sensitiveFields) {
    if (sanitized[field]) {
      sanitized[field] = '[REDACTED]';
    }
  }
  return sanitized;
}

这个脱敏函数应该在记录请求体和响应体之前调用。同时要设置日志级别,将安全事件标记为WARN或ERROR级别,方便后续设置告警规则。异常频率的突然升高、来自同一IP的大量失败请求、异常时间段的访问模式,这些都是需要关注的信号。

综合来看,Next.js API Routes的安全过滤是一个系统工程,从请求进入的第一刻起,就要经过身份验证、输入校验、内容净化、权限检查、速率限制、响应头加固这一整套流程。每个环节都有成熟的开源工具可以集成,但关键在于开发者要建立起安全左移的意识,把这些过滤机制作为API开发的标准模板,而不是等到安全审计发现问题后再去修补。安全过滤做得越早、越全面,应用的整体安全水位就越高,后续的维护成本也就越低。