跳至内容
·16 分钟阅读

API 安全最佳实践:我每个项目必跑的检查清单

认证、授权、输入验证、速率限制、CORS、密钥管理以及 OWASP API Top 10。每次生产部署前我都会检查的内容。

分享:X / TwitterLinkedIn

我发布过完全不设防的 API。不是恶意的,也不是偷懒——我只是不知道自己不知道什么。一个会返回用户对象所有字段(包括哈希密码)的接口,一个只检查 IP 地址的速率限制器(意味着代理后面的任何人都能疯狂请求 API),一个忘记验证 iss 声明的 JWT 实现(导致来自完全不同服务的 token 也能通过验证)。

这些错误每一个都上了生产环境。每一个都被发现了——有的被我自己发现,有的被用户发现,还有一个是被一位安全研究员发现的,好在他选择发邮件通知我而不是发到 Twitter 上。

这篇文章就是我从这些错误中总结出来的检查清单。我在每次生产部署前都会过一遍。不是因为我偏执,而是因为我学到了安全 Bug 造成的伤害最大。一个坏掉的按钮让用户烦躁,一个坏掉的认证流程则会泄露他们的数据。

认证与授权#

这两个词在会议中、文档中甚至代码注释中经常被混用。但它们不是一回事。

认证回答的是:「你是谁?」这是登录步骤。用户名和密码、OAuth 流程、Magic Link——任何能证明你身份的方式。

授权回答的是:「你被允许做什么?」这是权限步骤。这个用户能删除这个资源吗?能访问这个管理员接口吗?能读取另一个用户的数据吗?

我在生产 API 中见过的最常见的安全 Bug 不是登录流程出了问题,而是缺少授权检查。用户已经通过了认证——他们有有效的 token——但 API 从不检查他们是否有权执行所请求的操作。

JWT:结构与常见的关键错误#

JWT 无处不在,但也到处被误解。JWT 由三部分组成,用点号分隔:

header.payload.signature

header 声明使用了哪种算法。payload 包含声明(用户 ID、角色、过期时间)。signature 证明前两部分没有被篡改。

以下是 Node.js 中正确的 JWT 验证方式:

typescript
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
 
interface TokenPayload {
  sub: string;
  role: "user" | "admin";
  iss: string;
  aud: string;
  exp: number;
  iat: number;
  jti: string;
}
 
function verifyToken(token: string): TokenPayload {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ["HS256"], // Never allow "none"
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 seconds leeway for clock skew
    }) as TokenPayload;
 
    return payload;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, "Token expired");
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new ApiError(401, "Invalid token");
    }
    throw new ApiError(401, "Authentication failed");
  }
}

有几个点需要注意:

  1. algorithms: ["HS256"] ——这至关重要。如果你不指定算法,攻击者可以在 header 中发送 "alg": "none" 来完全跳过验证。这就是 alg: none 攻击,它已经影响过真实的生产系统。

  2. issueraudience ——没有这些,为服务 A 签发的 token 在服务 B 上也能用。如果你运行多个共享同一密钥的服务(你不应该这样做,但确实有人这么干),这就是跨服务 token 滥用的方式。

  3. 具体的错误处理 ——不要对每种失败都返回「无效 token」。区分过期和无效有助于客户端知道应该刷新还是重新认证。

刷新令牌轮换#

访问令牌应该是短期的——15 分钟是标准做法。但你不希望用户每 15 分钟重新输入密码。这就是刷新令牌的用途。

在生产中真正可行的模式:

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Token family for rotation detection
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Token not found — either expired or already used.
    // If already used, this is a potential replay attack.
    // Invalidate the entire token family.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Delete the old token immediately — single use only
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Generate new tokens
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Store the new refresh token with the same family
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 days
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Scan for all tokens in this family and delete them.
  // This is the nuclear option — if someone replays a refresh token,
  // we kill every token in the family, forcing re-authentication.
  const keys = await redis.keys(`refresh:*`);
  for (const key of keys) {
    const data = await redis.get(key);
    if (data) {
      const parsed = JSON.parse(data) as RefreshTokenData;
      if (parsed.family === family) {
        await redis.del(key);
      }
    }
  }
}

令牌族的概念是安全性的关键。每个刷新令牌都属于一个族(在登录时创建)。轮换时,新令牌继承该族。如果攻击者重放旧的刷新令牌,你检测到重用后会销毁整个族。合法用户会被登出,但攻击者也进不去。

这个争论已经持续多年了,答案是明确的:刷新令牌始终使用 httpOnly Cookie。

localStorage 对页面上运行的任何 JavaScript 都可访问。如果你有一个 XSS 漏洞——大规模使用下迟早会有——攻击者就能读取 token 并窃取它。完了。

httpOnly Cookie 对 JavaScript 不可访问。就是这样。XSS 漏洞仍然可以代表用户发出请求(因为 Cookie 会自动发送),但攻击者无法窃取 token 本身。这是一个有意义的区别。

typescript
// Setting a secure refresh token cookie
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Not accessible via JavaScript
    secure: true,       // HTTPS only
    sameSite: "strict", // No cross-site requests
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
    path: "/api/auth",  // Only sent to auth endpoints
  });
}

path: "/api/auth" 是大多数人忽略的细节。默认情况下,Cookie 会发送到你域名上的每个接口。你的刷新令牌不需要发送到 /api/users/api/products。限制路径,减少攻击面。

对于访问令牌,我把它保存在内存中(一个 JavaScript 变量)。不是 localStorage,不是 sessionStorage,不是 Cookie,而是内存。它们是短期的(15 分钟),页面刷新时客户端会静默请求刷新接口获取新的。没错,这意味着页面加载时多一个请求,但值得。

输入验证:永远不要信任客户端#

客户端不是你的朋友。客户端是一个走进你家门说「我有权进来」的陌生人。你还是得查他的证件。

来自服务器外部的每一条数据——请求体、查询参数、URL 参数、请求头——都是不可信的输入。你的 React 表单有验证也没用,总有人会用 curl 绕过它。

使用 Zod 进行类型安全的验证#

Zod 是 Node.js 输入验证领域最好的事情。它提供了运行时验证并自动生成 TypeScript 类型:

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Invalid email format")
    .max(254, "Email too long")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "Password must be at least 12 characters")
    .max(128, "Password too long") // Prevent bcrypt DoS
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "Password must contain uppercase, lowercase, and a number"
    ),
 
  name: z
    .string()
    .min(1, "Name is required")
    .max(100, "Name too long")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // Note: "admin" is intentionally not an option here.
  // Admin role assignment goes through a separate, privileged endpoint.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Usage in an Express handler
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data is fully typed as CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

一些与安全相关的细节:

  • 密码 max(128) ——bcrypt 有 72 字节的输入限制,有些实现会静默截断。但更重要的是,如果你允许 10MB 的密码,bcrypt 会花大量时间来做哈希。这是一个 DoS 攻击向量。
  • 邮箱 max(254) ——RFC 5321 将邮箱地址限制为 254 个字符。更长的不是有效邮箱。
  • 角色枚举不包含 admin ——批量赋值是最古老的 API 漏洞之一。如果你不验证就接受请求体中的 role,总有人会发送 "role": "admin" 碰碰运气。

SQL 注入并没有被解决#

「用 ORM 就行了」并不能在你为了性能写原始查询时保护你。而每个人最终都会为了性能写原始查询。

typescript
// VULNERABLE — string concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// SAFE — parameterized query
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

使用 Prisma 时基本安全——但 $queryRaw 仍然可能出问题:

typescript
// VULNERABLE — template literal in $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// SAFE — using Prisma.sql for parameterization
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

NoSQL 注入#

MongoDB 不使用 SQL,但也不能免疫注入。如果你将未经处理的用户输入作为查询对象传入,就会出问题:

typescript
// VULNERABLE — if req.body.username is { "$gt": "" }
// this returns the first user in the collection
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// SAFE — explicitly coerce to string
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// BETTER — validate with Zod first
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

修复方法很简单:在输入到达数据库驱动之前验证类型。如果 username 应该是字符串,就断言它是字符串。

路径遍历#

如果你的 API 提供文件服务或从包含用户输入的路径读取,路径遍历会毁掉你的一周:

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normalize and resolve to an absolute path
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Critical: verify the resolved path is still within the allowed directory
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Access denied");
  }
 
  // Verify the file actually exists
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Without this check:
// GET /api/files?name=../../../etc/passwd
// resolves to /etc/passwd

path.resolve + startsWith 模式是正确的方法。不要尝试手动去除 ../——有太多编码技巧(..%2F..%252F....//)会绕过你的正则。

速率限制#

没有速率限制,你的 API 就是机器人的自助餐。暴力破解、撞库攻击、资源耗尽——速率限制是对所有这些的第一道防线。

令牌桶 vs 滑动窗口#

令牌桶:你有一个可以容纳 N 个令牌的桶。每个请求消耗一个令牌。令牌以固定速率补充。桶空了,请求就被拒绝。这允许突发——如果桶是满的,你可以立即发出 N 个请求。

滑动窗口:在移动的时间窗口内计算请求数。更可预测,更难突破。

大多数场景下我使用滑动窗口,因为行为更容易理解和向团队解释:

typescript
import { Redis } from "ioredis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function slidingWindowRateLimit(
  redis: Redis,
  key: string,
  limit: number,
  windowMs: number
): Promise<RateLimitResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const multi = redis.multi();
 
  // Remove entries outside the window
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Count entries in the window
  multi.zcard(key);
 
  // Add the current request (we'll remove it if over limit)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Set expiry on the key
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Redis transaction failed");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // Over limit — remove the entry we just added
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

分层速率限制#

一个全局速率限制是不够的。不同的接口有不同的风险级别:

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Auth endpoints — tight limits, brute force target
  "POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
  "POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
  "POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
 
  // Data reads — more generous
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Data writes — moderate
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Global fallback
  "*": { window: 60 * 1000, max: 60 },
};
 
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
  const identifier = req.user?.id ?? getClientIp(req);
  const endpoint = `${req.method}:${req.path}`;
  return `ratelimit:${identifier}:${endpoint}`;
}

注意:已认证的用户按用户 ID 而非 IP 进行速率限制。这很重要,因为很多合法用户共享 IP(企业网络、VPN、移动运营商)。如果你只按 IP 限制,你会封掉整个办公室。

速率限制响应头#

始终告诉客户端发生了什么:

typescript
function setRateLimitHeaders(
  res: Response,
  result: RateLimitResult,
  limit: number
): void {
  res.set({
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": result.remaining.toString(),
    "X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
    "Retry-After": result.allowed
      ? undefined
      : Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
  });
 
  if (!result.allowed) {
    res.status(429).json({
      error: "Too many requests",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

CORS 配置#

CORS 可能是 Web 开发中最被误解的安全机制。Stack Overflow 上一半关于 CORS 的回答是「设置 Access-Control-Allow-Origin: * 就行了」。技术上没错,但这也是你向互联网上每个恶意网站敞开 API 大门的方式。

CORS 到底做什么(和不做什么)#

CORS 是一个浏览器机制。它告诉浏览器来自源 A 的 JavaScript 是否被允许读取源 B 的响应。仅此而已。

CORS 不做的事情:

  • 不保护你的 API 免受 curl、Postman 或服务端到服务端请求的影响
  • 不认证请求
  • 不加密任何东西
  • 不单独防止 CSRF(虽然与其他机制结合时有帮助)

CORS 做的事情:

  • 阻止 malicious-website.com 向 your-api.com 发起 fetch 请求并在用户浏览器中读取响应
  • 阻止攻击者的 JavaScript 通过受害者的已认证会话窃取数据

通配符陷阱#

typescript
// DANGEROUS — allows any website to read your API responses
app.use(cors({ origin: "*" }));
 
// ALSO DANGEROUS — this is a common "dynamic" approach that's just * with extra steps
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Allows everything
    },
  })
);

* 的问题在于它让你的 API 响应可被任何页面上的任何 JavaScript 读取。如果你的 API 返回用户数据且用户通过 Cookie 认证,用户访问的任何网站都能读取那些数据。

更糟糕的是:Access-Control-Allow-Origin: * 无法与 credentials: true 一起使用。所以如果你需要 Cookie(用于认证),你字面上就不能用通配符。但我见过有人试图通过回显 Origin 头来绕过——这相当于带 credentials 的 *,两个世界最坏的结合。

正确的配置#

typescript
import cors from "cors";
 
const ALLOWED_ORIGINS = new Set([
  "https://yourapp.com",
  "https://www.yourapp.com",
  "https://admin.yourapp.com",
]);
 
if (process.env.NODE_ENV === "development") {
  ALLOWED_ORIGINS.add("http://localhost:3000");
  ALLOWED_ORIGINS.add("http://localhost:5173");
}
 
app.use(
  cors({
    origin: (origin, callback) => {
      // Allow requests with no origin (mobile apps, curl, server-to-server)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    },
    credentials: true, // Allow cookies
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Cache preflight for 24 hours
  })
);

关键决策:

  • 明确的源集合,而非正则表达式。正则很棘手——yourapp.com 如果你的正则没有正确锚定可能会匹配 evilyourapp.com
  • credentials: true 因为我们用 httpOnly Cookie 存放刷新令牌。
  • maxAge: 86400 ——预检请求(OPTIONS)会增加延迟。告诉浏览器缓存 CORS 结果 24 小时可以减少不必要的往返。
  • exposedHeaders ——默认情况下浏览器只向 JavaScript 暴露少数「简单」响应头。如果你想让客户端读取速率限制头,必须明确暴露。

预检请求#

当请求不是「简单」的(使用了非标准头、非标准方法或非标准内容类型),浏览器会先发送一个 OPTIONS 请求请求许可。这就是预检。

如果你的 CORS 配置不处理 OPTIONS,预检请求会失败,实际请求永远不会被发送。大多数 CORS 库会自动处理,但如果你的框架不处理,你需要手动处理:

typescript
// Manual preflight handling (most frameworks do this for you)
app.options("*", (req, res) => {
  res.set({
    "Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
  });
  res.status(204).end();
});

安全响应头#

安全响应头是你能做的最划算的安全改进。它们是告诉浏览器启用安全功能的响应头。大多数只需一行配置,就能防护整类攻击。

真正重要的响应头#

typescript
import helmet from "helmet";
 
// One line. This is the fastest security win in any Express app.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Needed for many CSS-in-JS solutions
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 year
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

每个头的作用:

Content-Security-Policy(CSP) ——最强大的安全头。它告诉浏览器脚本、样式、图片、字体等允许哪些来源。如果攻击者注入了一个从 evil.com 加载的 <script> 标签,CSP 会阻止它。这是对抗 XSS 最有效的单一防御。

Strict-Transport-Security(HSTS) ——告诉浏览器始终使用 HTTPS,即使用户输入了 http://preload 指令可以让你把域名提交到浏览器内置的 HSTS 列表中,这样连第一个请求都会强制使用 HTTPS。

X-Frame-Options ——防止你的网站被嵌入 iframe 中。这阻止了点击劫持攻击,攻击者会在你的页面上覆盖不可见的元素。Helmet 默认将其设为 SAMEORIGIN。现代替代方案是 CSP 中的 frame-ancestors

X-Content-Type-Options: nosniff ——防止浏览器猜测(嗅探)响应的 MIME 类型。没有这个,如果你用错误的 Content-Type 提供文件,浏览器可能会将其作为 JavaScript 执行。

Referrer-Policy ——控制 Referer 头中发送多少 URL 信息。strict-origin-when-cross-origin 对同源请求发送完整 URL,对跨域请求只发送源。这防止向第三方泄露敏感的 URL 参数。

测试你的响应头#

部署后,在 securityheaders.com 检查你的分数。目标是 A+ 评级。大约五分钟的配置就能达到。

你也可以用代码来验证响应头:

typescript
import { describe, it, expect } from "vitest";
 
describe("Security headers", () => {
  it("should include all required security headers", async () => {
    const response = await fetch("https://api.yourapp.com/health");
 
    expect(response.headers.get("strict-transport-security")).toBeTruthy();
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
    expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
    expect(response.headers.get("content-security-policy")).toBeTruthy();
    expect(response.headers.get("referrer-policy")).toBeTruthy();
    expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet removes this
  });
});

x-powered-by 检查很微妙但很重要。Express 默认设置 X-Powered-By: Express,告诉攻击者你使用的确切框架。Helmet 会移除它。

密钥管理#

这本该是显而易见的,但我在 PR 中仍然看到:API 密钥、数据库密码和 JWT 密钥硬编码在源文件中。或者提交在没有加入 .gitignore.env 文件中。一旦进入 git 历史,它就永远在那里了,即使你在下一次提交中删除了文件。

规则#

  1. 永远不要把密钥提交到 git。 不在代码中,不在 .env 中,不在配置文件中,不在 Docker Compose 文件中,不在「仅供测试」的注释中。

  2. 使用 .env.example 作为模板。 它记录需要哪些环境变量,但不包含实际值:

bash
# .env.example — commit this
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
 
# .env — NEVER commit this
# Listed in .gitignore
  1. 在启动时验证环境变量。 不要等到请求命中需要数据库 URL 的接口时才发现。快速失败:
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
 
export type Env = z.infer<typeof envSchema>;
 
function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error("Invalid environment variables:");
    console.error(result.error.format());
    process.exit(1); // Don't start with bad config
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. 在生产环境使用密钥管理器。 环境变量适合简单场景,但它们有局限性:在进程列表中可见、在内存中持久存在、可能通过错误日志泄露。

对于生产系统,使用正规的密钥管理器:

  • AWS Secrets ManagerSSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler(如果你想要跨所有云的方案)

模式都是一样的:应用在启动时从密钥管理器获取密钥,而不是从环境变量。

  1. 定期轮换密钥。 如果你的 JWT 密钥用了两年,该轮换了。实施密钥轮换:同时支持多个有效的签名密钥,用新密钥签署新 token,用新旧密钥都验证,在所有现有 token 过期后淘汰旧密钥。
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Only the active key signs new tokens
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Returns all valid keys
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Try the next key
    }
  }
 
  throw new ApiError(401, "Invalid token");
}
 
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  const activeKey = getActiveSigningKey();
  return jwt.sign(payload, activeKey.secret, {
    algorithm: "HS256",
    expiresIn: "15m",
    keyid: activeKey.id, // Include key ID in the header
  });
}

OWASP API 安全 Top 10#

OWASP API 安全 Top 10 是行业标准的 API 漏洞列表。它定期更新,列表上的每一项都是我在真实代码库中见过的。让我逐一讲解。

API1:对象级授权失效(BOLA)#

最常见的 API 漏洞。用户已通过认证,但 API 不检查他们是否有权访问所请求的特定对象。

typescript
// VULNERABLE — any authenticated user can access any user's data
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// FIXED — verify the user is accessing their own data (or is an admin)
app.get("/api/users/:id", authenticate, async (req, res) => {
  if (req.user.id !== req.params.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Access denied" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

这个漏洞的版本到处都是。它通过了每一个认证检查——用户有有效 token——但它不验证他们是否有权访问这个特定资源。改变 URL 中的 ID,你就能获取别人的数据。

API2:认证失效#

弱登录机制、缺少多因素认证、永不过期的 token、明文存储的密码。这涵盖了认证层本身的问题。

修复方法就是我们在认证部分讨论的所有内容:强密码要求、足够轮次的 bcrypt、短期访问令牌、刷新令牌轮换、失败尝试后的账户锁定。

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
 
async function handleLogin(email: string, password: string): Promise<AuthResult> {
  const lockoutKey = `lockout:${email}`;
  const attempts = await redis.get(lockoutKey);
 
  if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
    const ttl = await redis.pttl(lockoutKey);
    throw new ApiError(
      429,
      `Account locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // Increment failed attempts
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Same error message for both cases — don't reveal whether the email exists
    throw new ApiError(401, "Invalid email or password");
  }
 
  // Reset failed attempts on successful login
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

关于「相同错误消息」的注释很重要。如果你的 API 对无效邮箱返回「用户未找到」而对有效邮箱的错误密码返回「密码错误」,你就是在告诉攻击者你系统中哪些邮箱是存在的。

API3:对象属性级授权失效#

返回多余的数据,或允许用户修改不该修改的属性。

typescript
// VULNERABLE — returns the entire user object, including internal fields
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // Response includes: passwordHash, internalNotes, billingId, ...
});
 
// FIXED — explicit allowlist of returned fields
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    createdAt: user.createdAt,
  });
});

永远不要返回整个数据库对象。始终选择你想要暴露的字段。写入操作也是如此——不要把整个请求体展开到更新查询中:

typescript
// VULNERABLE — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // Attacker sends: { "role": "admin", "verified": true }
});
 
// FIXED — pick allowed fields
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  avatar: z.string().url().optional(),
});
 
app.put("/api/users/:id", authenticate, async (req, res) => {
  const data = UpdateUserSchema.parse(req.body);
  await db.users.update(req.params.id, data);
});

API4:无限制的资源消耗#

你的 API 是一种资源。CPU、内存、带宽、数据库连接——它们都是有限的。没有限制的话,单个客户端就能耗尽所有资源。

这不仅仅是速率限制。还包括:

typescript
// Limit request body size
app.use(express.json({ limit: "1mb" }));
 
// Limit query complexity
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
 
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_PAGE_SIZE)
    .default(DEFAULT_PAGE_SIZE),
});
 
// Limit file upload size
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Invalid file type"));
    }
  },
});
 
// Timeout long-running requests
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Request timeout" });
  });
  next();
});

API5:功能级授权失效#

与 BOLA 不同。这是关于访问你不该访问的功能(接口),而不是对象。经典例子:普通用户发现了管理员接口。

typescript
// Middleware that checks role-based access
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Not authenticated" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // Log the attempt — this might be an attack
      logger.warn("Unauthorized access attempt", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "Insufficient permissions" });
    }
 
    next();
  };
}
 
// Apply to routes
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);

不要依赖隐藏接口。「通过模糊来保障安全」不是安全。即使管理面板的 URL 没有被任何地方链接,总有人会通过模糊测试找到 /api/admin/users

API6:对敏感业务流程的无限制访问#

对合法业务功能的自动化滥用。比如:机器人抢购限量商品、自动注册账号发送垃圾信息、爬取产品价格。

缓解措施是针对具体场景的:验证码、设备指纹、行为分析、敏感操作的加强认证。没有万能的代码片段。

API7:服务端请求伪造(SSRF)#

如果你的 API 获取用户提供的 URL(Webhook、头像 URL、链接预览),攻击者可以让你的服务器请求内部资源:

typescript
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
 
async function safeFetch(userProvidedUrl: string): Promise<Response> {
  let parsed: URL;
 
  try {
    parsed = new URL(userProvidedUrl);
  } catch {
    throw new ApiError(400, "Invalid URL");
  }
 
  // Only allow HTTP(S)
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Only HTTP(S) URLs are allowed");
  }
 
  // Resolve the hostname and check if it's a private IP
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Internal addresses are not allowed");
    }
  }
 
  // Now fetch with a timeout and size limit
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Don't follow redirects (they could redirect to internal IPs)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

关键细节:先解析 DNS 并在发出请求之前检查 IP。阻止重定向——攻击者可以部署一个重定向到 http://169.254.169.254/(AWS 元数据接口)的 URL 来绕过你的 URL 级检查。

API8:安全配置错误#

默认凭据未更改、不必要的 HTTP 方法启用、生产环境中的详细错误消息、目录列表启用、CORS 配置错误。这是「你忘了锁门」的类别。

typescript
// Don't leak stack traces in production
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("Unhandled error", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // Generic error message — don't reveal internals
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // Include a request ID for debugging
    });
  } else {
    // In development, show the full error
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Disable unnecessary HTTP methods
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "Method not allowed" });
  }
  next();
});

API9:资产管理不当#

你部署了 API v2 但忘了关掉 v1。或者有一个开发期间有用的 /debug/ 接口仍然在生产中运行。或者一个公开可访问的、带有生产数据的预发布服务器。

这不是代码修复——而是运维纪律。维护所有 API 接口、所有部署版本和所有环境的列表。使用自动扫描来发现暴露的服务。杀掉你不需要的。

API10:不安全的 API 消费#

你的 API 消费第三方 API。你验证它们的响应了吗?如果来自 Stripe 的 Webhook 载荷实际上来自攻击者怎么办?

typescript
import crypto from "crypto";
 
// Verify Stripe webhook signatures
function verifyStripeWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
  const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
 
  if (!timestamp || !expectedSig) return false;
 
  // Reject old timestamps (prevent replay attacks)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // 5 minute tolerance
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

始终验证 Webhook 签名。始终验证第三方 API 响应的结构。始终为外发请求设置超时。永远不要仅因为数据来自「可信合作伙伴」就信任它。

审计日志#

当事情出错时——而且一定会——审计日志就是你弄清发生了什么的方式。但日志是一把双刃剑。记得太少你就是盲的,记得太多则会造成隐私责任。

该记什么#

typescript
interface AuditLogEntry {
  timestamp: string;
  action: string;           // "user.login", "post.delete", "admin.role_change"
  actor: {
    id: string;
    ip: string;
    userAgent: string;
  };
  target: {
    type: string;           // "user", "post", "setting"
    id: string;
  };
  result: "success" | "failure";
  metadata: Record<string, unknown>; // Additional context
  requestId: string;        // For correlating with application logs
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Write to a separate, append-only data store
  // This should NOT be the same database your application uses
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // For critical actions, also write to an immutable external log
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

记录以下事件:

  • 认证:登录、登出、失败尝试、令牌刷新
  • 授权:访问被拒绝的事件(这些通常是攻击指标)
  • 数据修改:创建、更新、删除——谁改了什么,什么时候
  • 管理员操作:角色变更、用户管理、配置变更
  • 安全事件:速率限制触发、CORS 违规、格式错误的请求

不该记什么#

永远不要记录:

  • 密码(即使是哈希过的——哈希就是凭据)
  • 完整的信用卡号(只记录后 4 位)
  • 社会保障号码或政府 ID
  • API 密钥或令牌(最多记录前缀:sk_live_...abc
  • 个人健康信息
  • 可能包含个人信息的完整请求体
typescript
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys = new Set([
    "password",
    "passwordHash",
    "token",
    "secret",
    "apiKey",
    "creditCard",
    "ssn",
    "authorization",
  ]);
 
  const sanitized: Record<string, unknown> = {};
 
  for (const [key, value] of Object.entries(data)) {
    if (sensitiveKeys.has(key.toLowerCase())) {
      sanitized[key] = "[REDACTED]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

防篡改日志#

如果攻击者获得了系统访问权限,他们最先做的事情之一就是修改日志来掩盖踪迹。防篡改日志使这变得可检测:

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // The initial hash in the chain
 
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
  const content = JSON.stringify(entry) + previousHash;
  const hash = crypto.createHash("sha256").update(content).digest("hex");
 
  previousHash = hash;
 
  return { ...entry, hash };
}
 
// To verify the chain integrity:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
  let expectedPreviousHash = "GENESIS";
 
  for (const entry of entries) {
    const { hash, ...rest } = entry;
    const content = JSON.stringify(rest) + expectedPreviousHash;
    const computedHash = crypto.createHash("sha256").update(content).digest("hex");
 
    if (computedHash !== hash) {
      return false; // Chain is broken — logs have been tampered with
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

这和区块链是同一个概念——每条日志条目的哈希取决于前一条。如果有人修改或删除了一条,链就断了。

依赖安全#

你的代码可能是安全的。但你 node_modules 中的那 847 个 npm 包呢?供应链问题是真实的,而且这些年来越来越严重。

npm audit 是最低要求#

bash
# Run this in CI, fail the build on high/critical vulnerabilities
npm audit --audit-level=high
 
# Fix what can be auto-fixed
npm audit fix
 
# See what you're actually pulling in
npm ls --all

npm audit 有局限性。它只检查 npm 咨询数据库,其严重性评级并不总是准确的。叠加额外的工具:

自动依赖扫描#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Group minor and patch updates to reduce PR noise
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

锁文件是安全工具#

始终提交你的 package-lock.json(或 pnpm-lock.yamlyarn.lock)。锁文件固定了每个依赖的确切版本,包括传递依赖。没有它,npm install 可能拉取与你测试时不同的版本——而那个不同的版本可能已经被入侵了。

bash
# In CI, use ci instead of install — it respects the lockfile strictly
npm ci

npm ci 如果锁文件与 package.json 不匹配会失败,而不是静默更新它。这能捕获有人修改了 package.json 但忘记更新锁文件的情况。

安装前先评估#

在添加依赖之前,问自己:

  1. 我真的需要这个吗? 我能用 20 行代码代替添加一个包吗?
  2. 下载量有多少? 低下载量不一定是坏事,但意味着更少的人在审查代码。
  3. 上次更新是什么时候? 3 年没更新的包可能有未修补的漏洞。
  4. 它拉入了多少依赖? is-odd 依赖 is-number,后者依赖 kind-of。这是三个包来做一行代码就能做的事。
  5. 谁维护它? 单个维护者就是单点攻击目标。
typescript
// You don't need a package for this:
const isEven = (n: number): boolean => n % 2 === 0;
 
// Or this:
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Or this:
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

部署前检查清单#

这是我每次生产部署前实际使用的检查清单。它不是穷举的——安全永远没有「完成」——但它能捕获最重要的错误。

#检查项通过标准优先级
1认证JWT 使用明确的算法、签发者和受众验证。没有 alg: none关键
2令牌过期访问令牌 15 分钟内过期。刷新令牌使用时轮换。关键
3令牌存储刷新令牌在 httpOnly 安全 Cookie 中。localStorage 中没有令牌。关键
4每个接口的授权每个数据访问接口检查对象级权限。已测试 BOLA。关键
5输入验证所有用户输入用 Zod 或等效工具验证。查询中没有原始 req.body。关键
6SQL/NoSQL 注入所有数据库查询使用参数化查询或 ORM 方法。没有字符串拼接。关键
7速率限制认证接口:5 次/15 分钟。通用 API:60 次/分钟。返回速率限制头。
8CORS明确的源允许列表。通配符不与 credentials 一起使用。预检已缓存。
9安全头CSP、HSTS、X-Frame-Options、X-Content-Type-Options、Referrer-Policy 全部存在。
10错误处理生产错误返回通用消息。没有暴露堆栈跟踪、SQL 错误。
11密钥代码或 git 历史中没有密钥。.env.gitignore 中。启动时已验证。关键
12依赖npm audit 清洁(无高/关键漏洞)。锁文件已提交。CI 中使用 npm ci
13仅 HTTPSHSTS 启用并预加载。HTTP 重定向到 HTTPS。设置了安全 Cookie 标志。关键
14日志认证事件、访问拒绝和数据变更已记录。日志中没有个人信息。
15请求大小限制Body 解析器受限(默认 1MB)。文件上传有上限。查询分页已强制执行。
16SSRF 防护用户提供的 URL 已验证。私有 IP 已阻止。重定向已禁用或验证。
17账户锁定5 次失败登录后触发锁定。锁定已记录。
18Webhook 验证所有传入 Webhook 用签名验证。通过时间戳防止重放。
19管理员接口所有管理路由有基于角色的访问控制。尝试已记录。关键
20批量赋值更新接口使用允许字段的 Zod schema。没有原始 body 展开。

我把这个作为 GitHub issue 模板。在标记发布之前,团队里有人必须检查每一行并签字确认。不炫酷,但管用。

思维转变#

安全不是你最后再添加的功能。不是你一年做一次的冲刺。它是你写每一行代码时的思维方式。

当你写一个接口时,想想:「如果有人发送我不期望的数据会怎样?」当你添加一个参数时,想想:「如果有人把这个改成别人的 ID 会怎样?」当你添加一个依赖时,想想:「如果这个包下周二被入侵了会怎样?」

你不可能抓住一切。没有人能。但在每次部署前系统地过一遍这个清单——能抓住最重要的东西。简单的改进。明显的漏洞。那些把糟糕的一天变成数据泄露的错误。

养成习惯。跑清单。自信地发布。

相关文章