强化代码以抵御漏洞。适用于处理用户输入、身份验证、数据存储或外部集成时。适用于构建任何接受不可信数据、管理用户会话或与第三方服务交互的功能。
安全与加固
概述
Web 应用的安全优先开发实践。将每个外部输入视为恶意,每个秘密视为神圣,每个授权检查视为强制。安全不是一个阶段——它是每一行涉及用户数据、身份验证或外部系统的代码的约束。
何时使用
- 构建任何接受用户输入的功能
- 实现身份验证或授权
- 存储或传输敏感数据
- 与外部 API 或服务集成
- 添加文件上传、Webhook 或回调
- 处理支付或 PII 数据
流程:先进行威胁建模
没有威胁模型就添加控制措施只是猜测。在加固之前,花五分钟像攻击者一样思考:
- 映射信任边界。 不可信数据在哪里进入你的系统?HTTP 请求、表单字段、文件上传、Webhook、第三方 API、消息队列以及 LLM 输出。每个边界都是攻击面。
- 命名资产。 什么值得窃取或破坏?凭据、PII、支付数据、管理员操作、资金转移。
- 对每个边界运行 STRIDE——快速审视,而非仪式:
| 威胁 | 问题 | 典型缓解措施 |
|---|---|---|
| Spoofing(欺骗) | 能否有人冒充用户/服务? | 身份验证、签名验证 |
| Tampering(篡改) | 数据在传输或存储中能否被篡改? | 完整性检查、参数化查询、HTTPS |
| Repudiation(抵赖) | 能否事后否认某个操作? | 安全事件审计日志 |
| Information disclosure(信息泄露) | 数据是否会泄露? | 加密、字段白名单、通用错误信息 |
| Denial of service(拒绝服务) | 系统能否被压垮? | 速率限制、输入大小上限、超时 |
| Elevation of privilege(权限提升) | 用户能否获得不应有的权限? | 授权检查、最小权限原则 |
- 在用例旁边编写滥用案例。 对于每个功能,问自己“我该如何滥用它?”——然后将其作为你的第一个测试。
如果你无法命名某个功能的信任边界,你就还没有准备好保护它。这是 OWASP A04: 不安全设计——大多数漏洞始于设计,而非代码。
三层边界系统
始终执行(无例外)
- 在系统边界验证所有外部输入(API 路由、表单处理器)
- 参数化所有数据库查询——永远不要将用户输入拼接到 SQL 中
- 编码输出以防止 XSS(使用框架自动转义,不要绕过它)
- 对所有外部通信使用 HTTPS
- 使用 bcrypt/scrypt/argon2 哈希密码(绝不存储明文)
- 设置安全头(CSP、HSTS、X-Frame-Options、X-Content-Type-Options)
- 对会话使用 httpOnly、secure、sameSite cookie
- 在每次发布前运行
npm audit(或等效工具)
先询问(需要人工批准)
- 添加新的身份验证流程或更改身份验证逻辑
- 存储新类别的敏感数据(PII、支付信息)
- 添加新的外部服务集成
- 更改 CORS 配置
- 添加文件上传处理器
- 修改速率限制或节流
- 授予提升的权限或角色
绝不执行
- 绝不将秘密提交到版本控制(API 密钥、密码、令牌)
- 绝不记录敏感数据(密码、令牌、完整信用卡号)
- 绝不信任客户端验证作为安全边界
- 绝不为了方便而禁用安全头
- 绝不使用
eval()或innerHTML处理用户提供的数据 - 绝不将会话存储在客户端可访问的存储中(localStorage 中的认证令牌)
- 绝不向用户暴露堆栈跟踪或内部错误详情
OWASP Top 10 预防模式
以下是预防模式,并非排名。有关 2021 年顺序,请参阅 references/security-checklist.md 中的快速参考表。
注入(SQL、NoSQL、OS 命令)
// 错误:通过字符串拼接导致的 SQL 注入
const query = `SELECT * FROM users WHERE id = '${userId}'`;
// 正确:参数化查询
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// 正确:使用参数化输入的 ORM
const user = await prisma.user.findUnique({ where: { id: userId } });
身份验证失效
// 密码哈希
import { hash, compare } from 'bcrypt';
const SALT_ROUNDS = 12;
const hashedPassword = await hash(plaintext, SALT_ROUNDS);
const isValid = await compare(plaintext, hashedPassword);
// 会话管理
app.use(session({
secret: process.env.SESSION_SECRET, // 来自环境变量,而非代码
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // 不可通过 JavaScript 访问
secure: true, // 仅 HTTPS
sameSite: 'lax', // CSRF 保护
maxAge: 24 * 60 * 60 * 1000, // 24 小时
},
}));
跨站脚本攻击(XSS)
// 错误:将用户输入渲染为 HTML
element.innerHTML = userInput;
// 正确:使用框架自动转义(React 默认如此)
return <div>{userInput}</div>;
// 如果必须渲染 HTML,先进行清理
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);
访问控制失效
// 始终检查授权,而不仅仅是身份验证
app.patch('/api/tasks/:id', authenticate, async (req, res) => {
const task = await taskService.findById(req.params.id);
// 检查认证用户是否拥有此资源
if (task.ownerId !== req.user.id) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: '未授权修改此任务' }
});
}
// 继续更新
const updated = await taskService.update(req.params.id, req.body);
return res.json(updated);
});
安全配置错误
// 安全头(Express 使用 helmet)
import helmet from 'helmet';
app.use(helmet());
// 内容安全策略
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // 尽可能收紧
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
},
}));
// CORS — 限制为已知来源
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true,
}));
敏感数据泄露
// 绝不在 API 响应中返回敏感字段
function sanitizeUser(user: UserRecord): PublicUser {
const { passwordHash, resetToken, ...publicFields } = user;
return publicFields;
}
// 使用环境变量存储秘密
const API_KEY = process.env.STRIPE_API_KEY;
if (!API_KEY) throw new Error('STRIPE_API_KEY 未配置');
服务器端请求伪造(SSRF)
每当服务器获取用户影响的 URL 时——Webhook、“从 URL 导入”、图片代理、链接预览——攻击者可以将其指向内部服务(云元数据、localhost、私有 IP)。
// 错误:直接获取用户提供的任何内容
await fetch(req.body.webhookUrl);
// 正确:白名单方案 + 主机,如果任何解析的 IP 是私有的则拒绝,禁止重定向
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
async function assertSafeUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (url.protocol !== 'https:') throw new Error('仅支持 https');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('主机不允许');
// 解析所有记录;单个私有/保留地址即导致检查失败。
const addrs = await lookup(url.hostname, { all: true });
if (addrs.some((a) => ipaddr.parse(a.address).range() !== 'unicast')) {
throw new Error('私有/保留 IP');
}
return url;
}
await fetch(await assertSafeUrl(req.body.webhookUrl), { redirect: 'error' });
range() !== 'unicast' 检查覆盖了回环、链路本地 169.254.169.254(云元数据,SSRF 头号目标)、私有和唯一本地地址范围(IPv4 和 IPv6)。
注意——这仍然存在 TOCTOU 间隙。 fetch 在检查后再次解析 DNS,因此使用短 TTL 记录的攻击者可以在验证和连接之间将域名重新绑定到内部 IP。对于高风险面,解析一次并连接到固定的 IP,或者在前端放置过滤代理(request-filtering-agent / ssrf-req-filter)。
输入验证模式
边界处的模式验证
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200).trim(),
description: z.string().max(2000).optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
dueDate: z.string().datetime().optional(),
});
// 在路由处理器中验证
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: '无效输入',
details: result.error.flatten(),
},
});
}
// result.data 现在具有类型且已验证
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
文件上传安全
// 限制文件类型和大小
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
function validateUpload(file: UploadedFile) {
if (!ALLOWED_TYPES.includes(file.mimetype)) {
throw new ValidationError('不允许的文件类型');
}
if (file.size > MAX_SIZE) {
throw new ValidationError('文件过大(最大 5MB)');
}
// 不要信任文件扩展名——如果关键,检查魔数
}
处理 npm audit 结果
并非所有审计发现都需要立即处理。使用此决策树:
npm audit 报告漏洞
├── 严重性:严重或高
│ ├── 易受攻击的代码在你的应用中是否可达?
│ │ ├── 是 --> 立即修复(更新、修补或替换依赖)
│ │ └── 否(仅开发依赖、未使用的代码路径)--> 尽快修复,但不阻塞
│ └── 是否有可用修复?
│ ├── 是 --> 更新到已修补版本
│ └── 否 --> 检查变通方案,考虑替换依赖,或添加到白名单并设置审查日期
├── 严重性:中等
│ ├── 生产环境中可达?--> 在下一个发布周期修复
│ └── 仅开发?--> 方便时修复,在待办事项中跟踪
└── 严重性:低
└── 在常规依赖更新期间跟踪并修复
关键问题:
- 易受攻击的函数是否实际在你的代码路径中被调用?
- 依赖是运行时依赖还是仅开发依赖?
- 根据你的部署上下文,漏洞是否可利用(例如,仅客户端应用中的服务器端漏洞)?
当你推迟修复时,记录原因并设置审查日期。
供应链卫生
npm audit 捕获已知的 CVE;它不会捕获恶意或拼写错误的包。此外:
- 提交 lockfile 并在 CI 中使用
npm ci(而非npm install)安装——可重现构建,无静默版本漂移。 - 在添加新依赖之前进行审查——维护情况、下载量以及它们是否真正值得加入。每个依赖都是攻击面(OWASP A06: 易受攻击的组件、LLM03: 供应链)。
- 警惕不熟悉包中的
postinstall脚本——它们在安装时运行任意代码。 - 注意拼写错误——
cross-envvscrossenv、react-domvsreactdom。
速率限制
import rateLimit from 'express-rate-limit';
// 通用 API 速率限制
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100, // 每个窗口 100 个请求
standardHeaders: true,
legacyHeaders: false,
}));
// 认证端点更严格的限制
app.use('/api/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 每 15 分钟 10 次尝试
}));
秘密管理
.env 文件:
├── .env.example → 提交(包含占位值的模板)
├── .env → 不提交(包含真实秘密)
└── .env.local → 不提交(本地覆盖)
.gitignore 必须包含:
.env
.env.local
.env.*.local
*.pem
*.key
提交前始终检查:
# 检查意外暂存的秘密
git diff --cached | grep -i "password\|secret\|api_key\|token"
如果秘密曾经被提交,立即轮换它。 删除行或重写历史是不够的——一旦到达远程,就假设它已被泄露。首先撤销并重新颁发密钥,然后从历史中清除。
保护 AI / LLM 功能
如果你的应用调用 LLM——聊天机器人、摘要器、代理、RAG——它会继承一个新的攻击面。将其映射到 OWASP Top 10 for LLM Applications (2025):
- 将所有模型输出视为不可信输入(LLM05: 输出处理不当)。 切勿将 LLM 输出直接传入
eval、SQL、shell、innerHTML或文件路径。像处理原始用户输入一样验证和编码。 - 假设提示可能被劫持(LLM01: 提示注入)。 上下文窗口中的不可信文本——用户消息、获取的网页、PDF——可能携带指令。系统提示不是安全边界;在代码中强制执行权限,而非在提示中。
- 将秘密和其他用户的数据排除在提示之外(LLM02 / LLM07)。 上下文中的任何内容都可能被回显。不要将 API 密钥、跨租户数据或完整系统提示放在模型可能重复的位置。
- 限制工具和代理权限(LLM06: 过度代理)。 将工具范围限定为最小,对破坏性或不可逆操作要求确认,并验证每个工具参数。
- 限制消耗(LLM10: 无限制消耗)。 限制令牌、请求速率以及循环/递归深度,以防止精心构造的输入导致成本飙升或系统挂起。
- 隔离检索数据(LLM08: 向量和嵌入弱点)。 在 RAG 中,将向量存储视为信任边界:按租户分区嵌入,以便一个用户无法检索另一个用户的数据,并在索引前验证文档,以防止有毒内容引导答案。
// 错误:信任模型输出作为命令或标记
const sql = await llm.generate(`Write SQL for: ${userQuestion}`);
await db.query(sql); // 任意查询执行
container.innerHTML = await llm.reply(userMessage); // 通过模型的存储型 XSS
// 正确:模型输出是数据——防御性解析,然后验证,然后编码
let intent;
try {
intent = CommandSchema.parse(JSON.parse(await llm.replyJson(userMessage)));
} catch {
throw new ValidationError('意外的模型输出'); // JSON.parse 或模式失败
}
await runAllowlistedAction(intent.action, intent.params);
container.textContent = await llm.reply(userMessage);
安全审查清单
### 身份验证
- [ ] 密码使用 bcrypt/scrypt/argon2 哈希(盐轮数 ≥ 12)
- [ ] 会话令牌为 httpOnly、secure、sameSite
- [ ] 登录有速率限制
- [ ] 密码重置令牌会过期
### 授权
- [ ] 每个端点检查用户权限
- [ ] 用户只能访问自己的资源
- [ ] 管理员操作需要管理员角色验证
### 输入
- [ ] 所有用户输入在边界处验证
- [ ] SQL 查询已参数化
- [ ] HTML 输出已编码/转义
- [ ] 服务器端 URL 获取已白名单化(无 SSRF 到内部服务)
### 数据
- [ ] 代码或版本控制中没有秘密
- [ ] 敏感字段已从 API 响应中排除
- [ ] PII 在静态时加密(如适用)
### 基础设施
- [ ] 安全头已配置(CSP、HSTS 等)
- [ ] CORS 限制为已知来源
- [ ] 依赖项已审计漏洞
- [ ] 错误消息不暴露内部信息
### 供应链
- [ ] Lockfile 已提交;CI 使用 `npm ci` 安装
- [ ] 新依赖已审查(维护、下载量、postinstall 脚本)
### AI / LLM(如果使用)
- [ ] 模型输出视为不可信(无 eval/SQL/innerHTML/shell)
- [ ] 秘密和其他用户的数据排除在提示之外
- [ ] 工具/代理权限已限定;破坏性操作需要确认
另请参阅
有关详细的安全清单和预提交验证步骤,请参阅 references/security-checklist.md。
常见合理化借口
| 合理化借口 | 现实 |
|---|---|
| “这是内部工具,安全不重要” | 内部工具也会被攻破。攻击者瞄准最薄弱的环节。 |
| “我们以后再加安全” | 安全改造比一开始就构建难 10 倍。现在就加上。 |
| “没人会试图利用这个” | 自动化扫描器会发现它。安全通过模糊不是安全。 |
| “框架处理安全” | 框架提供工具,而非保证。你仍然需要正确使用它们。 |
| “这只是原型” | 原型会变成生产环境。从第一天起就养成安全习惯。 |
| “威胁建模在这里是过度设计” | 五分钟的“我该如何攻击它?”可以防止后续任何控制措施都无法修补的设计缺陷。 |
| “这只是 LLM 输出,只是文本” | 那个“文本”可以是 SQL 语句、脚本标签或 shell 命令。像对待任何不可信输入一样对待它。 |
危险信号
- 用户输入直接传递给数据库查询、shell 命令或 HTML 渲染
- 源代码或提交历史中存在秘密
- API 端点没有身份验证或授权检查
- 缺少 CORS 配置或通配符(
*)来源 - 认证端点没有速率限制
- 向用户暴露堆栈跟踪或内部错误
- 依赖项存在已知的严重漏洞
- 服务器获取用户提供的 URL 而没有白名单(SSRF)
- LLM/模型输出传入查询、DOM、shell 或
eval - 秘密、PII 或完整系统提示放入 LLM 上下文窗口
验证
在实现安全相关代码后:
- [ ]
npm audit显示没有严重或高漏洞 - [ ] 源代码或 git 历史中没有秘密
- [ ] 所有用户输入在系统边界处验证
- [ ] 每个受保护端点检查身份验证和授权
- [ ] 响应中存在安全头(使用浏览器 DevTools 检查)
- [ ] 错误响应不暴露内部细节
- [ ] 认证端点已启用速率限制
- [ ] 服务器端 URL 获取已根据白名单验证(无 SSRF)
- [ ] LLM/模型输出在使用前已验证和编码(如果存在 AI 功能)






