API 安全最佳实践:我每个项目必跑的检查清单
认证、授权、输入验证、速率限制、CORS、密钥管理以及 OWASP API Top 10。每次生产部署前我都会检查的内容。
我发布过完全不设防的 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 验证方式:
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");
}
}有几个点需要注意:
-
algorithms: ["HS256"]——这至关重要。如果你不指定算法,攻击者可以在 header 中发送"alg": "none"来完全跳过验证。这就是alg: none攻击,它已经影响过真实的生产系统。 -
issuer和audience——没有这些,为服务 A 签发的 token 在服务 B 上也能用。如果你运行多个共享同一密钥的服务(你不应该这样做,但确实有人这么干),这就是跨服务 token 滥用的方式。 -
具体的错误处理 ——不要对每种失败都返回「无效 token」。区分过期和无效有助于客户端知道应该刷新还是重新认证。
刷新令牌轮换#
访问令牌应该是短期的——15 分钟是标准做法。但你不希望用户每 15 分钟重新输入密码。这就是刷新令牌的用途。
在生产中真正可行的模式:
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 之争#
这个争论已经持续多年了,答案是明确的:刷新令牌始终使用 httpOnly Cookie。
localStorage 对页面上运行的任何 JavaScript 都可访问。如果你有一个 XSS 漏洞——大规模使用下迟早会有——攻击者就能读取 token 并窃取它。完了。
httpOnly Cookie 对 JavaScript 不可访问。就是这样。XSS 漏洞仍然可以代表用户发出请求(因为 Cookie 会自动发送),但攻击者无法窃取 token 本身。这是一个有意义的区别。
// 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 类型:
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 就行了」并不能在你为了性能写原始查询时保护你。而每个人最终都会为了性能写原始查询。
// 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 仍然可能出问题:
// 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,但也不能免疫注入。如果你将未经处理的用户输入作为查询对象传入,就会出问题:
// 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 提供文件服务或从包含用户输入的路径读取,路径遍历会毁掉你的一周:
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/passwdpath.resolve + startsWith 模式是正确的方法。不要尝试手动去除 ../——有太多编码技巧(..%2F、..%252F、....//)会绕过你的正则。
速率限制#
没有速率限制,你的 API 就是机器人的自助餐。暴力破解、撞库攻击、资源耗尽——速率限制是对所有这些的第一道防线。
令牌桶 vs 滑动窗口#
令牌桶:你有一个可以容纳 N 个令牌的桶。每个请求消耗一个令牌。令牌以固定速率补充。桶空了,请求就被拒绝。这允许突发——如果桶是满的,你可以立即发出 N 个请求。
滑动窗口:在移动的时间窗口内计算请求数。更可预测,更难突破。
大多数场景下我使用滑动窗口,因为行为更容易理解和向团队解释:
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,
};
}分层速率限制#
一个全局速率限制是不够的。不同的接口有不同的风险级别:
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 限制,你会封掉整个办公室。
速率限制响应头#
始终告诉客户端发生了什么:
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 通过受害者的已认证会话窃取数据
通配符陷阱#
// 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 的 *,两个世界最坏的结合。
正确的配置#
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 库会自动处理,但如果你的框架不处理,你需要手动处理:
// 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();
});安全响应头#
安全响应头是你能做的最划算的安全改进。它们是告诉浏览器启用安全功能的响应头。大多数只需一行配置,就能防护整类攻击。
真正重要的响应头#
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+ 评级。大约五分钟的配置就能达到。
你也可以用代码来验证响应头:
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 历史,它就永远在那里了,即使你在下一次提交中删除了文件。
规则#
-
永远不要把密钥提交到 git。 不在代码中,不在
.env中,不在配置文件中,不在 Docker Compose 文件中,不在「仅供测试」的注释中。 -
使用
.env.example作为模板。 它记录需要哪些环境变量,但不包含实际值:
# .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- 在启动时验证环境变量。 不要等到请求命中需要数据库 URL 的接口时才发现。快速失败:
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();- 在生产环境使用密钥管理器。 环境变量适合简单场景,但它们有局限性:在进程列表中可见、在内存中持久存在、可能通过错误日志泄露。
对于生产系统,使用正规的密钥管理器:
- AWS Secrets Manager 或 SSM Parameter Store
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
- Doppler(如果你想要跨所有云的方案)
模式都是一样的:应用在启动时从密钥管理器获取密钥,而不是从环境变量。
- 定期轮换密钥。 如果你的 JWT 密钥用了两年,该轮换了。实施密钥轮换:同时支持多个有效的签名密钥,用新密钥签署新 token,用新旧密钥都验证,在所有现有 token 过期后淘汰旧密钥。
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 不检查他们是否有权访问所请求的特定对象。
// 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、短期访问令牌、刷新令牌轮换、失败尝试后的账户锁定。
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:对象属性级授权失效#
返回多余的数据,或允许用户修改不该修改的属性。
// 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,
});
});永远不要返回整个数据库对象。始终选择你想要暴露的字段。写入操作也是如此——不要把整个请求体展开到更新查询中:
// 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、内存、带宽、数据库连接——它们都是有限的。没有限制的话,单个客户端就能耗尽所有资源。
这不仅仅是速率限制。还包括:
// 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 不同。这是关于访问你不该访问的功能(接口),而不是对象。经典例子:普通用户发现了管理员接口。
// 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、链接预览),攻击者可以让你的服务器请求内部资源:
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 配置错误。这是「你忘了锁门」的类别。
// 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 载荷实际上来自攻击者怎么办?
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 响应的结构。始终为外发请求设置超时。永远不要仅因为数据来自「可信合作伙伴」就信任它。
审计日志#
当事情出错时——而且一定会——审计日志就是你弄清发生了什么的方式。但日志是一把双刃剑。记得太少你就是盲的,记得太多则会造成隐私责任。
该记什么#
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) - 个人健康信息
- 可能包含个人信息的完整请求体
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;
}防篡改日志#
如果攻击者获得了系统访问权限,他们最先做的事情之一就是修改日志来掩盖踪迹。防篡改日志使这变得可检测:
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 是最低要求#
# 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 咨询数据库,其严重性评级并不总是准确的。叠加额外的工具:
自动依赖扫描#
# .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.yaml、yarn.lock)。锁文件固定了每个依赖的确切版本,包括传递依赖。没有它,npm install 可能拉取与你测试时不同的版本——而那个不同的版本可能已经被入侵了。
# In CI, use ci instead of install — it respects the lockfile strictly
npm cinpm ci 如果锁文件与 package.json 不匹配会失败,而不是静默更新它。这能捕获有人修改了 package.json 但忘记更新锁文件的情况。
安装前先评估#
在添加依赖之前,问自己:
- 我真的需要这个吗? 我能用 20 行代码代替添加一个包吗?
- 下载量有多少? 低下载量不一定是坏事,但意味着更少的人在审查代码。
- 上次更新是什么时候? 3 年没更新的包可能有未修补的漏洞。
- 它拉入了多少依赖?
is-odd依赖is-number,后者依赖kind-of。这是三个包来做一行代码就能做的事。 - 谁维护它? 单个维护者就是单点攻击目标。
// 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。 | 关键 |
| 6 | SQL/NoSQL 注入 | 所有数据库查询使用参数化查询或 ORM 方法。没有字符串拼接。 | 关键 |
| 7 | 速率限制 | 认证接口:5 次/15 分钟。通用 API:60 次/分钟。返回速率限制头。 | 高 |
| 8 | CORS | 明确的源允许列表。通配符不与 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 | 仅 HTTPS | HSTS 启用并预加载。HTTP 重定向到 HTTPS。设置了安全 Cookie 标志。 | 关键 |
| 14 | 日志 | 认证事件、访问拒绝和数据变更已记录。日志中没有个人信息。 | 中 |
| 15 | 请求大小限制 | Body 解析器受限(默认 1MB)。文件上传有上限。查询分页已强制执行。 | 中 |
| 16 | SSRF 防护 | 用户提供的 URL 已验证。私有 IP 已阻止。重定向已禁用或验证。 | 中 |
| 17 | 账户锁定 | 5 次失败登录后触发锁定。锁定已记录。 | 高 |
| 18 | Webhook 验证 | 所有传入 Webhook 用签名验证。通过时间戳防止重放。 | 高 |
| 19 | 管理员接口 | 所有管理路由有基于角色的访问控制。尝试已记录。 | 关键 |
| 20 | 批量赋值 | 更新接口使用允许字段的 Zod schema。没有原始 body 展开。 | 高 |
我把这个作为 GitHub issue 模板。在标记发布之前,团队里有人必须检查每一行并签字确认。不炫酷,但管用。
思维转变#
安全不是你最后再添加的功能。不是你一年做一次的冲刺。它是你写每一行代码时的思维方式。
当你写一个接口时,想想:「如果有人发送我不期望的数据会怎样?」当你添加一个参数时,想想:「如果有人把这个改成别人的 ID 会怎样?」当你添加一个依赖时,想想:「如果这个包下周二被入侵了会怎样?」
你不可能抓住一切。没有人能。但在每次部署前系统地过一遍这个清单——能抓住最重要的东西。简单的改进。明显的漏洞。那些把糟糕的一天变成数据泄露的错误。
养成习惯。跑清单。自信地发布。