关于 api-hardening
按 OWASP API Security Top 10:2023 组织 API 防护模式的安全技能,要求先调研当前威胁,再应用限流、输入验证等方案。
主要功能
- 01映射 OWASP API Top 10:2023
- 02先调研再实施的安全流程
- 03限流与账户配额
- 04输入验证指导
- 05结合安全事件说明威胁
适用场景
- 01规划新 API 的防护措施
- 02限制滥用或高资源请求
- 03验证 API 信任边界处的数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jamditis/ claude-skills-journalism --skill api-hardening安装后在 Claude Code 中输入/api-hardening确认已加载。
附带文件agents/openai.yaml
详细介绍
API 加固
用于保护 API 免遭滥用、注入攻击和数据泄露的纵深防御模式。本指南围绕 OWASP API Security Top 10:2023 编写,最后验证于 2026-05-08。
步骤 0:研究当前安全态势(请先执行)
安全知识的半衰期为 6-12 个月。以下指南最后验证于 2026-05-08;您阅读时内容可能已经过时。在此技能中应用任何模式之前,请针对即将添加的 API 接口或 Web 防御展开多角度研究,确保根据当前权威来源解读指南,而不是依赖本文件中的快照。
默认执行,可记录理由后跳过
默认执行以下 4 个角度的研究。仅当以下条件全部满足时才可跳过:
- (a) 您在当前会话的最近 4 小时内,针对同一原语运行过此技能;
- (b) 此前的研究未发现针对即将添加的 API 接口或 Web 防御的紧急安全公告;
- (c) 您在回复中记录一行
Research skipped because <reason>注释。
“我觉得自己懂”/“赶时间”/“用户希望尽快完成”/“已经很熟悉”都不是有效的跳过理由。本段的核心目的,是提醒未来的您:在检查当前情况之前,不要相信本技能正文中的默认设置。
并行分派 4 个子 Agent
每个子 Agent 返回不超过 300 个单词的要点,并附上引用。请在一条消息中分派全部 4 个,以便它们并行运行。
角度 1:权威标准。 NIST/OWASP/IETF(RFC 和 Internet-Drafts)/W3C/CISA 在过去 6-12 个月内,是否发布了有关即将添加的 API 接口或 Web 防御的新内容?查找:规范定稿、弃用声明、替代规范、RFC 发布、草案修订、NIST SP 更新、OWASP 项目版本升级。请引用文档编号和发布日期。
角度 2:活跃利用。 当前有哪些漏洞正被积极利用,且目标是即将添加的 API 接口或 Web 防御?从以下来源查找:CISA 已知遭利用漏洞(KEV)目录(筛选过去 6-12 个月的条目)、近期 CVSS 评分较高或已在实际攻击中利用的 CVE/GHSA 条目、数据泄露事件复盘和事故报告(CSRB、供应商 RCA、安全供应商研究)。指出近期 KEV 新增条目中占主导地位的 CWE 模式。请引用 CVE 编号和公告 URL。
角度 3:工具和库的状态。 本技能推荐的库是否仍是最新的?相关软件包注册表(npm/PyPI/RubyGems/crates.io)中的最新主要版本是什么?其中是否有库被弃用、替换或合并到其他项目中?是否有库更改了安全默认设置?请在以下平台查询当前版本:registry.npmjs.org、pypi.org、rubygems.org、crates.io、pkg.go.dev。请引用软件包名称、版本和发布日期。
角度 4:从业者讨论。 过去 6 个月,从业者和安全团队在讨论什么?请参考:OWASP Cheat Sheet Series(请留意最后修改日期)、GitHub Security Lab 文章、供应商安全博客(Cloudflare、Fastly、Snyk、Datadog、Wiz、GitGuardian)、会议演讲(Black Hat、DEF CON、OWASP Global AppSec、USENIX Security)、SANS ISC、Krebs、近期 OWASP 项目重新发布的内容。指出正在采用的模式和受到批评的反模式。请引用文章 URL、作者和日期。
在应用配方前先综合研判
收到 4 份结果后,撰写一段话,概述“截至 <today's date>,正在添加的 API 面或 Web 防御的当前状态”,并指出:
- 当前的规范上限(规范指出 2026 年默认情况下 SHOULD 采用什么)。
- 过去 6–12 个月内,针对正在添加的 API 面或 Web 防御的 1–2 种活跃威胁。
- 工具链的变化(已弃用的库、框架中的新默认设置、合并或替换的软件包)。
- 从近期速查表/博客更新中可见的从业者共识变化。
如果综合研判发现此技能正文中的配方已经过时(例如,某项规范在 2026-05-08 之后定稿、某个库现已弃用、默认设置发生变化),请在回复中明确指出,并在两者冲突时以综合研判为准。综合研判优先。技能正文只是脚手架,而非金科玉律。
无法运行子 Agent 时
如果你的运行环境不支持子 Agent,则在行内按相同方式执行:连续进行 4 次有针对性的搜索(搜索标准、查询 KEV 目录、检查软件包注册表版本、对比近期速查表的差异)。形成同样的一段综合研判。成本会增加,但防护不会减弱。
OWASP API Security Top 10:2023 对照
API 专项威胁建模当前采用的版本是 OWASP API Security Top 10:2023(https://owasp.org/API-Security/editions/2023/en/0x00-header/)。更广泛的 OWASP Top 10:2025(于 2025 年末发布,接替 2021 版)涵盖的 Web 应用范围更广;对于 API 面,应以 2023 年的 API 专项列表为框架。
2023 年的类别,以及本技能各部分对它们的说明:
- API1:2023 对象级授权失效,强制执行针对每个对象的访问检查;在 API 密钥管理和每用户速率限制部分一并说明。
- API2:2023 身份验证失效,身份验证机制本身参见
secure-auth技能;本技能涵盖身份验证端点的配套防护措施,包括速率限制、请求大小和超时保护。 - API3:2023 对象属性级授权失效,包括输入验证(Zod / Pydantic)以及对可写字段的显式允许列表。
- API4:2023 不受限制的资源消耗,包括速率限制、请求大小限制、超时保护和文件上传限制。
- API5:2023 功能级授权失效,不在此处的范围内;路由级身份验证由你的框架负责。
- API6:2023 对敏感业务流程的不受限制访问,包括图遍历配额、每个账户的读取额度,以及用于检测凭证填充的行为信号。
- API7:2023 服务器端请求伪造,对出站 URL 设置允许列表;在超时保护和外部 API 调用部分略作说明。
- API8:2023 安全配置错误,包括安全标头、TLS 配置和 CORS 配置。
- API9:2023 不当的资产管理,不在此处的范围内;这是 API 网关/目录问题。
- API10:2023 不安全的 API 消费,包括对从上游获取的数据进行输入验证,以及反序列化安全。
对于更广泛的 Web 安全背景,OWASP Top 10:2025 重新排列了 2021 年的列表。值得注意的变化:A03:2025“软件供应链失效”吸收了旧版 2021 A06“易受攻击和过时的组件”,2021 年的这一类别并入供应链类别,不再单独存在。A09:2025 是“安全日志记录和告警失效”(此前为“日志记录和监控”)。2025 年的顺序为:A01 访问控制失效/A02 安全配置错误/A03 软件供应链失效/A04 加密失效/A05 注入/A06 不安全的设计/A07 身份验证失效/A08 软件或数据完整性失效/A09 安全日志记录和告警失效/A10 异常情况处理不当。
威胁案例
2023 至 2024 年的真实事件,为本技能中的模式提供依据。向利益相关方解释“原因”时,请引用这些案例。
- Polyfill.io 供应链攻击(2024-06-25)。 Funnull 于 2024 年 2 月收购 polyfill.io 域名,并向大约 110,000 个加载该脚本的网站注入恶意软件(据 https://sansec.io/research/polyfill-supply-chain-attack)。教训:每个第三方
<script>和<link rel="stylesheet">都需要 Subresource Integrity 哈希和严格的 CSP;“可信 CDN”并非保证。 - 23andMe 凭据填充攻击(披露于 2023-10)。 攻击者使用泄露的凭据登录 23andMe 账户,随后利用 DNA Relatives 功能,从最初遭入侵的少量账户入手,枚举出大约 6.9 million 名用户(据 https://blog.23andme.com/articles/addressing-data-security-concerns)。教训:按 IP 限制速率无法阻止分布式凭据填充攻击;图谱或关系端点还需要按账户设置读取配额,以限制影响范围。
- MOVEit Transfer CVE-2023-34362(披露于 2023-05-31)。 Progress MOVEit Transfer 中的一个未经身份验证即可利用的 SQL 注入漏洞(据 https://nvd.nist.gov/vuln/detail/CVE-2023-34362)被 Cl0p 用于从数千家组织窃取数据,该攻击链随后被纳入 CISA KEV 目录。教训:参数化查询不可或缺,即使是在看起来不像“面向用户的 API”的文件传输路径上也不例外。
- Ivanti Connect Secure 零日漏洞链(披露于 2024-01)。 CVE-2023-46805(身份验证绕过)和 CVE-2024-21887(命令注入)被串联利用,实现了无需身份验证的远程代码执行(据 https://www.cisa.gov/news-events/cybersecurity-advisories/aa24-060b)。教训:命令注入(CWE-78)加上身份验证绕过,是当前未经身份验证即可实现远程代码执行的典型手法;两方面都需要防护。
速率限制
对应 API4:2023(不受限制的资源消耗)和 API6:2023(敏感业务流程)。
为何重要
没有速率限制:
- 暴力破解攻击会得逞
- API 会遭到意外或蓄意的 DDoS 攻击
- 一个恶意行为者会影响所有用户
- 你会收到云服务提供商的意外账单
分布式凭据填充攻击可以绕过按 IP 限制:攻击者通过住宅代理网络轮换 IP,而单个 IP 很少会触及阈值。应将按 IP 限制与按账户配额、行为信号(不可能的旅行、设备指纹异常)结合使用,并在前端部署 WAF 来应对僵尸网络模式。
使用 express-rate-limit 的 Express.js
截至 2026-05-08 的库版本:express-rate-limit 和 rate-limit-redis,固定版本前请在 registry.npmjs.org 上核实。
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();
// General API rate limit
const apiLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
skip: (req) => {
// Skip rate limiting for health checks
return req.path === '/health';
}
});
// Strict limit for auth endpoints
const authLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts, please try again in 15 minutes' },
keyGenerator: (req) => {
// Rate limit by IP + email to prevent distributed attacks
return `${req.ip}-${req.body?.email || 'unknown'}`;
}
});
// Very strict limit for password reset
const passwordResetLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 requests per hour
message: { error: 'Too many password reset requests' }
});
// Apply limiters
app.use('/api/', apiLimiter);
app.use('/auth/login', authLimiter);
app.use('/auth/forgot-password', passwordResetLimiter);
滑动窗口实现(自定义)
// Redis-based sliding window rate limiter
class SlidingWindowRateLimiter {
constructor(redisClient, options = {}) {
this.redis = redisClient;
this.windowMs = options.windowMs || 60000; // 1 minute default
this.maxRequests = options.maxRequests || 100;
this.keyPrefix = options.keyPrefix || 'ratelimit';
}
async isAllowed(identifier) {
const now = Date.now();
const windowStart = now - this.windowMs;
const key = `${this.keyPrefix}:${identifier}`;
// Remove old entries and count recent ones
const multi = this.redis.multi();
multi.zRemRangeByScore(key, 0, windowStart);
multi.zCard(key);
multi.zAdd(key, { score: now, value: `${now}-${Math.random()}` });
multi.expire(key, Math.ceil(this.windowMs / 1000));
const results = await multi.exec();
const requestCount = results[1];
return {
allowed: requestCount < this.maxRequests,
remaining: Math.max(0, this.maxRequests - requestCount - 1),
resetAt: now + this.windowMs
};
}
}
// Express middleware
function createRateLimitMiddleware(limiter) {
return async (req, res, next) => {
const identifier = req.ip;
const result = await limiter.isAllowed(identifier);
res.setHeader('X-RateLimit-Limit', limiter.maxRequests);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', result.resetAt);
if (!result.allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
};
}
使用 API 密钥进行按用户速率限制
// Different limits based on tier
const tierLimits = {
free: { windowMs: 60000, max: 10 },
pro: { windowMs: 60000, max: 100 },
enterprise: { windowMs: 60000, max: 1000 }
};
async function apiKeyRateLimiter(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Look up API key
const keyData = await db.query(
'SELECT user_id, tier, revoked FROM api_keys WHERE key_hash = $1',
[hashApiKey(apiKey)]
);
if (keyData.rows.length === 0 || keyData.rows[0].revoked) {
return res.status(401).json({ error: 'Invalid API key' });
}
const { user_id, tier } = keyData.rows[0];
const limits = tierLimits[tier] || tierLimits.free;
// Rate limit by user, not by key (prevents key rotation abuse)
const limiter = new SlidingWindowRateLimiter(redisClient, {
...limits,
keyPrefix: 'apikey'
});
const result = await limiter.isAllowed(user_id);
res.setHeader('X-RateLimit-Limit', limits.max);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', result.resetAt);
if (!result.allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
req.userId = user_id;
next();
}
按账户设置图谱遍历配额(23andMe 案例的教训)
对于一次扩展一层关系或图谱的端点(DNA relatives、联系人网络、关注者扩展、组织成员查询),除了按 IP 限制外,还应设置按账户配额,这是典型做法。在按 IP 设置的额度内,一个遭入侵的账户仍可能遍历图谱,并窃取所有其他关联账户的数据,这正是 23andMe 凭据填充攻击造成的泄露事件影响扩大的原因。
// Per-account daily budget for graph-expansion endpoints
const graphLimiter = new SlidingWindowRateLimiter(redisClient, {
windowMs: 24 * 60 * 60 * 1000, // 24 hours
maxRequests: 500, // tune to product norms
keyPrefix: 'graph'
});
async function graphTraversalQuota(req, res, next) {
const result = await graphLimiter.isAllowed(req.userId);
if (!result.allowed) {
return res.status(429).json({ error: 'Daily graph quota exceeded' });
}
next();
}
app.get('/api/relatives', requireAuth, graphTraversalQuota, listRelatives);
app.get('/api/contacts/expand', requireAuth, graphTraversalQuota, expandContacts);
输入验证
对应 API3:2023(对象属性级授权失效)和 API10:2023(不安全地使用 API)。
使用 Zod 进行验证(TypeScript/JavaScript)
截至 2026-05-08,zod 属于 4.x 系列(当前版本为 4.4.3;固定版本前请核实)。以下模式适用于 v4;如果仍在使用 3.x,safeParse 和下方的 schema 构建器均未改变。
不可信输入也涉及反序列化问题,请参阅下方的专门章节。CWE-502(反序列化不可信数据)在 2024-2025 年的 CISA KEV 目录中占主导地位,因此在信任边界处仅接受 JSON 并进行 schema 验证,是基线要求。
const { z } = require('zod');
// Define schemas
const createUserSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(12).max(128),
name: z.string().min(1).max(100).optional()
});
const updateProfileSchema = z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
website: z.string().url().optional().or(z.literal(''))
});
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20)
});
// Middleware factory
function validate(schema) {
return (req, res, next) => {
const result = schema.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
}))
});
}
req.validated = result.data;
next();
};
}
// Usage
app.post('/users', validate(createUserSchema), async (req, res) => {
const { email, password, name } = req.validated;
// Data is validated and typed
});
清理
Trusted Types 是一种相关的浏览器端防御机制,它强制危险的 DOM 接收点使用经过策略验证的对象,而非字符串,从而消除整类基于 DOM 的 XSS。截至 2026-05-08,Trusted Types 已实现跨浏览器支持(Chromium 自 2020 年起支持,Firefox 148、Safari 26.0;caniuse 全球使用率约为 89%),因此 require-trusted-types-for 'script' 现在已是切实可行的 CSP 指令,而不再只是 Chrome 专属的可选项。请参阅安全标头部分。
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const validator = require('validator');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
// HTML sanitization (when you MUST allow some HTML)
function sanitizeHtml(dirty) {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false
});
}
// String sanitization
function sanitizeString(str) {
if (typeof str !== 'string') return '';
return str
.trim()
.slice(0, 10000) // Max length
.replace(/[\x00-\x1F\x7F]/g, ''); // Remove control characters
}
// SQL-safe identifier (for dynamic column names)
function sanitizeIdentifier(str) {
// Only allow alphanumeric and underscores
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(str)) {
throw new Error('Invalid identifier');
}
return str;
}
// Filename sanitization
function sanitizeFilename(filename) {
return filename
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/\.{2,}/g, '.')
.slice(0, 255);
}
防止 SQL 注入
对应 OWASP Top 10:2025 A05(注入)。MOVEit CVE-2023-34362 是 2023 年的典型案例:文件传输产品中存在一个无需身份验证即可利用的 SQLi 漏洞,Cl0p 利用该漏洞攻击了数千个组织(参见 https://nvd.nist.gov/vuln/detail/CVE-2023-34362)。不要认为“内部”或“非面向用户”的端点可以跳过参数化处理。
// BAD: String interpolation
const query = `SELECT * FROM users WHERE id = ${userId}`;
// BAD: String concatenation
const query = 'SELECT * FROM users WHERE id = ' + userId;
// BAD: Template literals with user input
const query = `SELECT * FROM users WHERE name = '${name}'`;
// GOOD: Parameterized queries (PostgreSQL)
const result = await db.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
// GOOD: Parameterized queries (MySQL)
const result = await db.query(
'SELECT * FROM users WHERE id = ?',
[userId]
);
// GOOD: Query builders (Knex)
const users = await knex('users')
.where('id', userId)
.first();
// GOOD: ORMs (Prisma)
const user = await prisma.user.findUnique({
where: { id: userId }
});
// When you need dynamic column names (rare)
const allowedColumns = ['name', 'email', 'created_at'];
const sortColumn = allowedColumns.includes(req.query.sort)
? req.query.sort
: 'created_at';
const query = `SELECT * FROM users ORDER BY ${sortColumn}`; // Safe because allowlisted
命令注入(CWE-78)
CWE-78(OS 命令注入)在 2024 至 2025 年间的 CISA KEV 目录中占主导地位,2024 年有 14 条,2025 年有 18 条,超过了经典 SQL 注入。Ivanti Connect Secure 攻击链(CVE-2024-21887)是典型案例。规则很简单:切勿使用不可信输入拼接 shell 命令。使用语言提供的 argv 列表式进程启动原语,并禁用 shell;对传入进程的任何路径或文件名使用允许列表。
Node.js
只要任何参数可能受用户输入影响,就应避免使用 Node 子进程模块中会执行 shell 的原语。它们会通过 /bin/sh -c 传递完整字符串,任何元字符(;、&、|、反引号、$())都可能造成注入。优先使用 argv 列表式的 spawn 系列,并禁用 shell(默认设置)。
const { exec, execFile, spawn } = require('child_process');
// DO NOT USE: shell metacharacters in `filename` execute as commands
exec(`convert ${filename} out.png`, (err, stdout) => { /* ... */ });
// DO NOT USE: same problem with execSync
require('child_process').execSync(`convert ${filename} out.png`);
// GOOD: argv-list with the shell disabled (the default for execFile/spawn)
execFile('convert', [filename, 'out.png'], (err, stdout) => { /* ... */ });
// GOOD: spawn with explicit shell:false plus an allowlist on the filename
const safeFilename = /^[A-Za-z0-9_.-]+\.(png|jpg|jpeg)$/.test(filename)
? filename
: null;
if (!safeFilename) throw new Error('Invalid filename');
const child = spawn('convert', [safeFilename, 'out.png'], { shell: false });
Python
只要任何参数可能受用户输入影响,就应避免使用 OS shell 执行原语:os.system 调用、shell=True 的 subprocess.run、shell=True 的 subprocess.Popen,以及 os.popen。使用接收 argv 列表且 shell=False(默认设置)的 subprocess.run,并对跨越信任边界的任何文件名或路径使用允许列表。
import subprocess
import re
from pathlib import Path
# DO NOT USE: os.system passes the full string through the shell
import os
os.system(f"convert {filename} out.png")
# DO NOT USE: shell=True is the same vulnerability
subprocess.run(f"convert {filename} out.png", shell=True, check=True)
# DO NOT USE: os.popen also goes through the shell
os.popen(f"convert {filename} out.png").read()
# GOOD: argv list, shell disabled (the default), with input allowlisting
if not re.fullmatch(r"[A-Za-z0-9_.-]+\.(png|jpg|jpeg)", filename):
raise ValueError("Invalid filename")
subprocess.run(
["convert", filename, "out.png"],
check=True,
shell=False, # explicit; this is also the default
timeout=30,
)
# GOOD: when the input is a path, resolve and confirm it's inside an allowed root
allowed_root = Path("/var/app/uploads").resolve()
candidate = (allowed_root / filename).resolve()
if not candidate.is_relative_to(allowed_root):
raise ValueError("Path traversal attempt")
反序列化(CWE-502)
CWE-502(不可信数据反序列化)是 2024 至 2025 年间 KEV 中第二常见的 CWE,2024 年有 11 条,2025 年有 14 条。原生二进制序列化器和不受限制的 YAML 加载器会将输入视为程序:任意代码会在解析时运行,早于任何验证逻辑。规则是:在信任边界只使用 JSON,并在使用解析后的 JSON 之前通过架构(Zod / Pydantic)进行验证。
Python
对于任何跨越信任边界的输入,都应避免使用原生二进制反序列化原语(pickle 加载器)和不安全的 YAML 加载器(未指定 Loader=SafeLoader 的裸 yaml.load 调用),因为两者都会在解析时执行任意对象。同样的警告也适用于 marshal 加载器。
import json
import pickle
import yaml
from pydantic import BaseModel
# DO NOT USE: pickle.loads on untrusted input executes arbitrary code
obj = pickle.loads(request.body)
# DO NOT USE: yaml.load with no SafeLoader is a code-execution sink
config = yaml.load(request.body) # equivalent to yaml.Loader
# DO NOT USE: marshal.loads has the same untrusted-input hazard as pickle
import marshal
obj = marshal.loads(request.body)
# GOOD: JSON parsing returns plain data (dict / list / str / number / bool / None)
class CreateUserRequest(BaseModel):
email: str
password: str
data = json.loads(request.body) # safe parse
user = CreateUserRequest.model_validate(data) # schema-validated
# GOOD: when YAML is genuinely required, use SafeLoader (or yaml.safe_load)
config = yaml.safe_load(request.body)
Node.js / Java / .NET
在 Node 中,任何接受“这是一个序列化对象”负载的库都可能带来同等风险(node-serialize 包、较旧的 funcster 风格包、基于 eval 的 JSON5 分支)。请坚持使用 JSON.parse,并通过 Zod 进行验证。
在 Java 中,应避免对不可信输入使用二进制反序列化原语(ObjectInputStream.readObject);Jackson、GSON 或其他 JSON 库是安全的选择。在 .NET 中,应避免使用旧版二进制格式化程序(BinaryFormatter、NetDataContractSerializer、LosFormatter、SoapFormatter)。Microsoft 明确指出要移除它,正是因为 CWE-502(参见 https://learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-security-guide)。对于跨越信任边界的输入,请使用 System.Text.Json。
// DO NOT USE: a third-party deserializer on untrusted input
const obj = require('node-serialize').unserialize(request.body);
// GOOD: JSON.parse + Zod schema validation
const data = JSON.parse(request.body);
const parsed = createUserSchema.safeParse(data);
if (!parsed.success) {
return res.status(400).json({ error: 'Validation failed' });
}
CORS 配置
对应 API8:2023(安全配置错误)。
Express.js
const cors = require('cors');
// Development: Allow localhost
const developmentOrigins = [
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1:3000'
];
// Production: Specific domains only
const productionOrigins = [
'https://yourapp.com',
'https://www.yourapp.com',
'https://app.yourapp.com'
];
const allowedOrigins = process.env.NODE_ENV === 'production'
? productionOrigins
: [...productionOrigins, ...developmentOrigins];
const corsOptions = {
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin) {
return callback(null, true);
}
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],
credentials: true, // Allow cookies
maxAge: 86400 // Cache preflight for 24 hours
};
app.use(cors(corsOptions));
// Handle CORS errors
app.use((err, req, res, next) => {
if (err.message === 'Not allowed by CORS') {
return res.status(403).json({ error: 'CORS not allowed' });
}
next(err);
});
常见的 CORS 错误
// BAD: Allow all origins
app.use(cors()); // Defaults to '*'
// BAD: Allow all origins with credentials
app.use(cors({ origin: '*', credentials: true })); // Browsers will reject this
// BAD: Reflecting Origin header (allows any origin)
app.use(cors({
origin: (origin, cb) => cb(null, origin) // Never do this
}));
// BAD: Regex that's too permissive
const origin = /yourapp\.com/; // Matches evilyourapp.com too!
// GOOD: Exact match or strict regex
const origin = /^https:\/\/(www\.)?yourapp\.com$/;
私有网络访问(草案)
私有网络访问(PNA)是 WICG 的一份草案(https://wicg.github.io/private-network-access/),当公共来源尝试访问私有网络资源(LAN IP、localhost、内网主机名)时,它会添加类似 CORS 的预检请求。截至 2026-05-08,只有 Chromium 会强制执行这项机制,而且即便如此,随着规范不断演进,强制执行也已逐步回退为警告。请将其视为草案:不要依赖 PNA 作为主要防御手段,但要为它全面启用的那一天做好准备,并确保私有网络 API 受到真正的身份验证保护,而不只是依赖网络位置。
API 密钥管理
当密钥限定到每位用户的数据时,对应 API1:2023(对象级授权失效);凭据生命周期管理对应 API2:2023(身份验证失效)。
安全地生成和存储密钥
const crypto = require('crypto');
// Generate API key
function generateApiKey() {
// Format: prefix_randomBytes
// Prefix helps identify key type and makes it recognizable
const prefix = 'sk_live';
const randomPart = crypto.randomBytes(24).toString('base64url');
return `${prefix}_${randomPart}`;
}
// Hash for storage (never store plain keys)
function hashApiKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
// Create new API key
app.post('/api-keys', requireAuth, async (req, res) => {
const { name } = req.body;
// Generate key
const plainKey = generateApiKey();
const keyHash = hashApiKey(plainKey);
// Store only the hash
await db.query(
`INSERT INTO api_keys (user_id, key_hash, name, created_at)
VALUES ($1, $2, $3, NOW())`,
[req.userId, keyHash, name]
);
// Return plain key ONCE - user must save it
res.json({
key: plainKey,
message: 'Save this key now. It will not be shown again.'
});
});
// Verify API key
async function verifyApiKey(key) {
const keyHash = hashApiKey(key);
const result = await db.query(
`SELECT id, user_id, revoked, last_used_at
FROM api_keys WHERE key_hash = $1`,
[keyHash]
);
if (result.rows.length === 0) {
return null;
}
const keyData = result.rows[0];
if (keyData.revoked) {
return null;
}
// Update last used timestamp
await db.query(
'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
[keyData.id]
);
return keyData;
}
// Revoke API key
app.delete('/api-keys/:id', requireAuth, async (req, res) => {
// Users can only revoke their own keys
await db.query(
'UPDATE api_keys SET revoked = true, revoked_at = NOW() WHERE id = $1 AND user_id = $2',
[req.params.id, req.userId]
);
res.json({ success: true });
});
按计划轮换,而不只是发生泄露时才轮换
即使没有泄露迹象,也应按计划轮换 API 密钥(90 天是常见的基准;高权限密钥应缩短周期)。长期有效的密钥会不断累积风险:日志行泄露、过期的 CI 密钥、已离职的员工、被遗忘的测试脚本。轮换流程应支持重叠使用,即在轮换窗口内让“下一个”密钥与“当前”密钥同时生效,这样调用方可以切换密钥而不会造成服务中断。
// Add columns: rotated_from, rotation_due_at
// On rotation:
// 1. Generate new key
// 2. Insert with rotated_from = old key id, expires_at = old key expiry + window
// 3. Notify the key owner with the new value (out-of-band)
// 4. Mark old key revoked = true at the end of the overlap window
// Background job: flag keys past their rotation_due_at
const stale = await db.query(
`SELECT id, user_id, name FROM api_keys
WHERE revoked = false
AND rotation_due_at < NOW()`
);
// Email each owner; auto-revoke after a grace period.
API 密钥中间件
async function apiKeyAuth(req, res, next) {
// Accept the key from headers ONLY, never query strings.
// Query strings are routinely logged by web servers, reverse proxies, CDN
// edge nodes, and analytics tooling, so a key in `?api_key=...` becomes a
// credential leak by way of access logs. OWASP API Top 10 (API2:2023) and
// RFC 6750 §2.3 both call this out.
const apiKey = req.headers['x-api-key']
|| req.headers['authorization']?.replace('Bearer ', '');
if (!apiKey) {
return res.status(401).json({
error: 'API key required',
hint: 'Pass API key in X-API-Key header'
});
}
const keyData = await verifyApiKey(apiKey);
if (!keyData) {
// Don't reveal if key exists but is revoked
return res.status(401).json({ error: 'Invalid API key' });
}
req.apiKeyId = keyData.id;
req.userId = keyData.user_id;
next();
}
请求大小限制
对应 API4:2023(不受限制的资源消耗)。
const express = require('express');
// Global body size limit
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ limit: '100kb', extended: true }));
// Per-route limits
app.post('/api/upload', express.json({ limit: '10mb' }), (req, res) => {
// Handle large upload
});
// File upload limits
const multer = require('multer');
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5 // Max 5 files
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
}
});
app.post('/upload', upload.single('file'), (req, res) => {
// Handle upload
});
响应安全
对应 API8:2023(安全配置错误)。
不要泄露信息
// BAD: Leaking stack traces
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack // Never in production!
});
});
// GOOD: Generic error in production
app.use((err, req, res, next) => {
console.error(err); // Log full error server-side
if (process.env.NODE_ENV === 'production') {
res.status(500).json({ error: 'Internal server error' });
} else {
res.status(500).json({ error: err.message, stack: err.stack });
}
});
// BAD: Revealing database structure
res.status(400).json({
error: 'duplicate key value violates unique constraint "users_email_key"'
});
// GOOD: User-friendly error
res.status(400).json({
error: 'An account with this email already exists'
});
防止 XSS
对应 OWASP Top 10:2025 A05(注入)。2026 年的 XSS 防御方案按优先级排列如下:
- 使用 nonce 和
'strict-dynamic'的 CSP3,即现代严格 CSP。白名单(script-src https://cdn.foo.com …)已不再受推荐;据 Weichselbaum 等人的研究,这类白名单通常可以被绕过。nonce 或哈希配合'strict-dynamic',是 W3C 认可的严格 CSP。 - Trusted Types(
require-trusted-types-for 'script'),通过要求危险的接收点(innerHTML、eval、setTimeout(string))使用经过策略验证的对象,杜绝基于 DOM 的 XSS。截至 2026 年已支持跨浏览器(Chromium 自 2020 年起支持,Firefox 148、Safari 26.0;据 https://caniuse.com/trusted-types,全球使用率约为 89%)。 - 框架自动转义,React、Vue、Angular、Svelte 和现代模板引擎默认都会转义插值字符串。除非配合 DOMPurify,否则不要使用绕过 API(
dangerouslySetInnerHTML、v-html、[innerHTML]、{@html})。 - 在服务端最后一道边界手动转义,当确实需要将值注入非模板化的 HTML 响应时,请使用经过验证的转义工具。
// BAD: Directly inserting user content
res.send(`<h1>Hello ${userName}</h1>`);
// GOOD: Use a template engine with auto-escaping
// EJS (auto-escapes by default with <%= %>)
res.render('greeting', { name: userName });
// GOOD: Escape manually when needed
const escapeHtml = require('escape-html');
res.send(`<h1>Hello ${escapeHtml(userName)}</h1>`);
// GOOD: Set Content-Type for JSON responses
res.json({ name: userName }); // Express sets correct headers
// In React/Vue/Angular/Svelte: framework handles escaping by default.
// Don't use dangerouslySetInnerHTML / v-html / [innerHTML] / {@html} on user input.
关于 'unsafe-inline' 和内联脚本的处理方式,请参阅安全标头部分;当 nonce 已贯穿模板后,严格 CSP3 就不再需要考虑内联脚本问题。
安全标头
对应 API8:2023(安全配置错误)。截至 2026-05-08,helmet 已进入 8.x 系列(当前版本为 8.1.0;固定版本前请先确认)。2026 年的严格 CSP 基线取代了较早的“设置所有 X- 标头”建议。
const helmet = require('helmet');
const crypto = require('crypto');
// Per-request CSP nonce middleware (wire this into your template engine)
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
// Strict CSP3: nonce + 'strict-dynamic' + downgrade fallbacks
app.use(helmet.contentSecurityPolicy({
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
// Strict CSP per https://www.w3.org/TR/CSP3/ and MDN strict-CSP guide.
// 'strict-dynamic' lets nonced scripts load further scripts;
// https: + 'unsafe-inline' are downgrade fallbacks for old browsers
// that ignore 'strict-dynamic' (modern browsers ignore the fallbacks).
scriptSrc: [
(req, res) => `'nonce-${res.locals.cspNonce}'`,
"'strict-dynamic'",
'https:',
"'unsafe-inline'"
],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.yourapp.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"], // replaces X-Frame-Options
formAction: ["'self'"],
upgradeInsecureRequests: [],
// Trusted Types: kills DOM-based XSS at the sink.
// Cross-browser as of 2026 per https://caniuse.com/trusted-types
requireTrustedTypesFor: ["'script'"],
// Reporting via the Reporting API (W3C WD)
reportTo: ['default']
}
}));
// HSTS: 2 years + includeSubDomains + preload (per https://hstspreload.org)
// Operationally irreversible once preload-listed, verify subdomains first.
app.use(helmet.hsts({
maxAge: 63072000,
includeSubDomains: true,
preload: true
}));
// Cross-origin isolation (COOP / COEP / CORP)
// Required for crossOriginIsolated === true (SharedArrayBuffer, high-resolution timers).
// Per WHATWG HTML Origin chapter: https://html.spec.whatwg.org/multipage/origin.html
app.use((req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); // or 'credentialless'
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
next();
});
// Permissions-Policy: structured-fields syntax, opt-out of dangerous features.
// Per https://www.w3.org/TR/permissions-policy/ (W3C WD 2025-10-06; per-directive
// caniuse lookup required, this is "Limited Availability", not Baseline).
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()'
);
next();
});
// Reporting API: Reporting-Endpoints + report-to wiring.
// Per https://www.w3.org/TR/reporting-1/
app.use((req, res, next) => {
res.setHeader(
'Reporting-Endpoints',
'default="https://reports.yourapp.com/csp"'
);
next();
});
// Keep nosniff
app.use(helmet.noSniff());
// Note: X-XSS-Protection is deprecated and ignored by modern browsers, do not set it.
// X-Frame-Options is replaced by CSP's frame-ancestors directive (set above).
渲染 HTML 时,模板需要读取 res.locals.cspNonce,并将其添加到每个内联 <script> 和 <style> 标签上(<script nonce="<%= cspNonce %>">…)。严格动态策略正是通过这种方式决定信任哪些脚本。
子资源完整性
对应 OWASP Top 10:2025 A03(软件供应链故障)。2024 年 Polyfill.io 事件是典型案例:Funnull 于 2024 年 2 月买下 polyfill.io 域名,并向约 11 万个加载该脚本的网站注入恶意软件(据 https://sansec.io/research/polyfill-supply-chain-attack)。CSP 'strict-dynamic' 策略本可阻止未经授权的脚本加载脚本链;即使策略允许加载,SRI 哈希也会导致加载失败。
每个第三方 <script src> 和 <link rel="stylesheet"> 都 SHOULD 带有 integrity 属性。SRI 是 W3C 推荐标准(https://www.w3.org/TR/SRI/)。
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
<link
rel="stylesheet"
href="https://cdn.example.com/lib.min.css"
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
crossorigin="anonymous">
从获取的资源生成 SRI 哈希:
curl -fsSL https://cdn.example.com/lib.min.js \
| openssl dgst -sha384 -binary \
| openssl base64 -A
cdnjs 和 jsdelivr 的复制粘贴代码片段中包含 integrity 属性;unpkg 提供“SRI-on”模式。请使用这些代码。固定到特定版本,绝不要使用 @latest。
TLS 安全态势
对应 OWASP Top 10:2025 A04(密码学故障)和 API8:2023(安全配置错误)。
- 默认使用 TLS 1.3,RFC 8446(https://datatracker.ietf.org/doc/rfc8446/),于 2018 年 8 月发布。
- 最低版本为 TLS 1.2,更早的版本已弃用。
- 禁用 TLS 1.0 和 TLS 1.1,RFC 8996 / BCP 195(https://datatracker.ietf.org/doc/rfc8996/)于 2021 年 3 月正式弃用这两个版本。
- **自 v2009r(2025 年 5 月 16 日)起,SSL Labs 对非 TLS 1.3 服务器的评级最高为 A-。**若要获得 A 或 A+ 评级,必须支持 TLS 1.3。
- **发展方向:后量子混合密钥交换。**Cloudflare 报告称,2025 年末,其超过一半的 HTTPS 流量通过后量子混合密钥交换(X25519+Kyber / X25519MLKEM768)完成协商。这方面发展迅速;请查看 CDN / 负载均衡器当前的支持情况。
如果你通过托管边缘服务(Cloudflare、Fastly、AWS CloudFront、Azure Front Door)提供服务,通常只需调整一个“最低 TLS 版本”设置:至少设为 1.2,优先使用 1.3,并让边缘服务处理密码套件协商。如果由你自行终止 TLS,请参阅 Mozilla SSL Configuration Generator(https://ssl-config.mozilla.org/),获取当前的“intermediate”或“modern”配置。
超时防护
对应 API4:2023(不受限制的资源消耗)和 API7:2023(服务器端请求伪造;超时设置应与出站 URL 允许列表配合使用)。
// Request timeout middleware
function timeout(ms) {
return (req, res, next) => {
res.setTimeout(ms, () => {
res.status(408).json({ error: 'Request timeout' });
});
next();
};
}
app.use(timeout(30000)); // 30 second default
// External API call timeout
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
// Database query timeout
const result = await db.query({
text: 'SELECT * FROM large_table WHERE condition = $1',
values: [value],
timeout: 5000 // 5 second query timeout
});
FastAPI(Python)对应实现
库版本截至 2026-05-08 为最新(固定版本前请在 PyPI 上核实):
fastapi处于 0.x 版本系列(当前为 0.136.1;尚未达到 1.0,因此应按次版本固定;次版本之间可能出现破坏性变更)。slowapi处于 0.1.x 版本系列(尚未达到 1.0;应按次版本固定)。pydantic处于 2.x 版本系列(当前为 2.13.4)。
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter
from slowapi.util import get_remote_address
from pydantic import BaseModel, EmailStr, Field
import hashlib
import secrets
app = FastAPI()
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourapp.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.post("/api/login")
@limiter.limit("5/minute")
async def login(request: Request, credentials: LoginRequest):
# Handle login
pass
# Input validation with Pydantic 2.x
class CreateUserRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=12, max_length=128)
name: str = Field(max_length=100, default=None)
@app.post("/users")
async def create_user(user: CreateUserRequest):
# Data is already validated
pass
# API key generation
def generate_api_key() -> str:
return f"sk_live_{secrets.token_urlsafe(24)}"
def hash_api_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
API 安全检查清单
按 OWASP API Security Top 10:2023 类别整理。可跳过不适用的类别(此处不涉及 API5 / API9)。
API1:2023 对象级授权失效
- 每次获取 / 更新 / 删除时都检查对象级访问权限
- API 密钥应限定给单个用户使用,不应由多个租户共享
- 在有必要时,对象 ID 不应可预测或可枚举
API2:2023 身份验证失效
- 身份验证端点的速率限制应比常规 API 端点更严格
- 密码重置端点应设置非常严格的账户级限制
- 所有身份验证流程只能通过 HTTPS 进行
- 身份验证原语本身请参阅
secure-auth技能
API3:2023 对象属性级授权失效
- 根据架构验证输入(Zod / Pydantic)
- 明确允许可写字段,不要接受“请求正文中的所有内容”
- 避免使用批量赋值路径(
Object.assign、model.update(req.body))
API4:2023 不受限制的资源消耗
- 对所有端点实施速率限制
- 配置请求正文大小限制
- 配置文件上传大小和数量限制
- 对所有数据库查询和外部调用设置超时
- 为列表端点设置分页上限
API6:2023 对敏感业务流程的无限制访问
- 对图关系、关系和扇出端点设置每账户配额(23andMe 的教训)
- 对易受凭据填充攻击的端点使用行为信号(不可能的旅行、设备指纹)
- 在前端部署 WAF 或机器人管理系统,以应对分布式滥用模式
API7:2023 服务端请求伪造
- 出站 HTTP 调用仅访问允许列表中的主机
- 为每个出站调用设置超时
- 在网络层阻止云元数据端点
API8:2023 安全配置错误
- 严格的 CSP3:nonce +
'strict-dynamic'+ Trusted Types - 设置 HSTS:
max-age=63072000; includeSubDomains; preload - 设置 COOP / COEP / CORP
- 使用 Permissions-Policy 限制危险功能
- 设置
X-Content-Type-Options: nosniff - 不设置
X-XSS-Protection(已弃用) - 使用 CSP
frame-ancestors取代X-Frame-Options - 将 CORS 配置为仅允许特定来源,携带凭据时绝不使用
* - 默认使用 TLS 1.3,最低使用 TLS 1.2,并禁用 TLS 1.0/1.1
- 为每个第三方
<script>和<link rel="stylesheet">设置 SRI 哈希 - 错误消息不泄露系统信息或数据库结构
API10:2023 不安全地使用 API
- 使用与验证用户输入同等严格的方式验证上游响应
- 不在跨信任边界的输入上使用原生二进制反序列化器或不受限制的 YAML 加载器
- 不使用由上游控制的值构造 Shell 执行原语
跨领域
- 存储前对 API 密钥进行哈希;按计划轮换,而不只在密钥泄露时轮换
- 对破坏性变更进行 API 版本管理