How to Build Secure-by-Default Node.js APIs
TL;DR · AI 摘要
本文提供了一套构建安全默认的 Node.js API 的实用方法,涵盖请求限制、超时、JSON 解析、安全头、常时比较等关键实践。
核心要点
- 使用 Node.js 22 或更高版本构建 API,可避免常见安全漏洞。
- 设置请求体大小限制和超时机制,防止服务器因大请求或慢请求崩溃。
- 采用常时比较方法处理敏感信息,避免信息泄露。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 构建安全默认的 Node.js API
- 请求限制
- 请求体大小限制
- 请求超时机制
- 安全实践
- 安全 JSON 解析
- 常时比较敏感信息
- 设置安全头
金句 / Highlights
值得收藏与分享的关键句。
Neither bug was sophisticated. Both would have been a complete non-event if something had simply refused to let me do the wrong thing in the first place.
This tutorial shows you how to add practical guardrails around every HTTP API you build, regardless of framework.
By the end, you'll have a small server that survives a lot more contact with the public internet than the version most of us shipped early in our careers.
如何构建默认安全的 Node.js API
2026年6月24日
/
#安全
Devlin Duldulao
在我的职业生涯中,我所引入的大多数安全问题都不是什么奇特的问题。它们既不是国家攻击,也不是巧妙的零日漏洞。它们很无聊。这里缺少一个限制,那里忘记了一个超时,一个字符串比较泄露了一个秘密,一次持续一毫秒。
这些无聊的问题才是最危险的,因为这些无聊的问题是每个人都会同意“以后再解决”的问题,而“以后”往往永远不会到来。
我最喜欢的一个个人例子(以一种像伤疤一样成为你最喜欢的)是一个内部API,它用普通的等值检查比较访问令牌,而且没有请求大小的限制。它运行了一年,直到某人好奇地发现他们可以同时指纹识别令牌比较,并发布一个足够大的正文,让服务器感到“出汗”。
这两个错误都不复杂。如果一开始就能阻止我做错误的事情,这两个问题都不会发生。
本教程将向你展示如何为每个构建的HTTP API添加实际的防护措施,无论使用什么框架。你将手动用纯Node.js编写它们,没有任何依赖。这将让你清楚地看到每个防护措施的作用和原因。
到那时,你将拥有一个比我们大多数人职业生涯早期部署的版本更能经受住与公共互联网接触的服务器。
本教程使用纯JavaScript,以便任何人都可以复制并运行它。如果你使用TypeScript,可以在之后添加类型。你需要Node 22或更新版本,对HTTP请求和响应的基本理解,以及一个终端用于测试示例。
我们将涵盖的内容:
- 先决条件
- 你将构建的内容
- 如何从一个天真的服务器开始
- 如何限制请求体
- 如何超时慢请求
- 如何安全地解析JSON并阻止原型污染
- 如何在每个响应中设置安全头
- 如何以恒定时间比较秘密
- 如何将验证作为门,而不是建议
- 如何在不泄露信息的情况下失败并记录以便查看
- 如何将所有内容整合在一起
- 如何正确处理CORS
- 本教程不涵盖的内容
- 为什么默认值胜过清单
- 关于框架的诚实说明
- 总结检查清单
先决条件
- Node.js 22 或更新版本
- 对HTTP请求和响应的基本了解
- 一个终端和curl、Postman或类似的客户端
这不是一个使你的API无法被黑客攻击的指南。这是一个实际的指南,用于避免容易的攻击并构建更安全的默认设置。
你将构建的内容
一个带有请求大小限制、请求超时、安全JSON解析、安全头、时间安全的秘密比较、验证和错误处理的纯Node.js API。
如何从一个天真的服务器开始
这是我年轻时、勇敢时、错误时所写的那种服务器。它读取一个JSON正文并将其回传。假装它是真实API的起点。
import http from "node:http";
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
const data = JSON.parse(body || "{}");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ youSent: data }));
});
});
server.listen(3000, () => console.log("listening on http://localhost:3000"));它有效。你可以用curl访问它,它会回答。它也是一个小灾难,以下是不完整的解释原因列表:
- 它会将不受限制的请求体全部读入内存。发送几GB的数据,你就可以轻松实现拒绝服务攻击。
- JSON.parse 在遇到错误输入时会抛出异常,这在这里意味着一个未捕获的异常,可能会导致进程崩溃。
- 没有设置超时时间。一个每分钟只发送一个字节的客户端可以长时间占用连接。
- 它没有设置任何安全头信息,并且高兴地宣称自己是一个 Node 服务器。
- 它直接将不可信的 JSON 解析为对象,没有任何检查,这为下游的原型污染打开了大门。
你将逐一修复这些问题。这些修复措施都很小。关键是让它们成为习惯,而不是一时的英雄行为。
如何限制请求体大小
接受陌生人输入的第一条规则是提前决定你愿意接受多少输入。如果你没有设置限制,那么限制就是“服务器拥有的内存大小”,而有人会找出这一点。
这里有两层防护。第一层是 Content-Length 请求头,客户端发送它来声明请求体的大小。你可以根据它提前拒绝请求。但你绝不能只依赖它,因为客户端可以撒谎或者干脆不发送它。
真正的防御措施是边接收数据边计算字节数,一旦超过设定的限制就立即停止。
const MAX_BODY_BYTES = 100 * 1024; // 对于大多数 JSON API 来说,100 KB 已经足够
function readBody(req, limit = MAX_BODY_BYTES) {
return new Promise((resolve, reject) => {
// 如果客户端诚实地声明自己太大,可以提前拒绝。
const declared = Number(req.headers["content-length"]);
if (Number.isFinite(declared) && declared > limit) {
reject(httpError(413, "Payload too large"));
return;
}
let size = 0;
const chunks = [];
req.on("data", (chunk) => {
size += chunk.length;
if (size > limit) {
reject(httpError(413, "Payload too large"));
req.destroy(); // 停止读取;我们已经处理完这个客户端
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function httpError(statusCode, message) {
return Object.assign(new Error(message), { statusCode });
}这里有几个值得注意的地方。你累积的是 Buffer 块,并在最后才将它们合并,而不是直接拼接字符串,因为字符串拼接会强制进行早期解码,并可能破坏落在块边界上的多字节 UTF-8 字符。
你一旦超过限制,就会立即调用 req.destroy(),这样就不会继续读取你已经决定拒绝的字节。
选择一个与路由匹配的限制。创建用户的 JSON API 不需要 50 MB 的请求体。文件上传端点是另一个话题,那里你将数据流式传输到磁盘或对象存储,而不是在内存中缓冲。错误在于没有设置限制,而不是设置了错误的限制。
Node 为此内置了防御机制,默认值非常宽松,但为了 API 的安全,值得进一步收紧。
const server = http.createServer(handler);
// 接收完整请求(包括头部和正文)的总时间。
server.requestTimeout = 30_000; // 30 秒
// 仅接收头部的允许时间。Slowloris 攻击就发生在这里。
server.headersTimeout = 10_000; // 10 秒
// 空闲套接字超时:终止长时间没有活动的连接。
server.setTimeout(60_000);上述三行代码处理的是网络层。但还有一种“慢”情况:你自己的处理程序。比如数据库查询卡住、对外调用第三方服务没有响应、正则表达式陷入对人生选择的思考。你希望为单个请求占用工作线程的时长设定一个上限,并且在达到这个上限时能够取消该请求的处理。
Node 中用于取消操作的现代工具是 AbortController。下面是一个小的封装函数,为每个处理程序设置一个截止时间,并提供一个信号,可以传递给支持取消操作的任何对象,比如 fetch。
function withTimeout(handler, ms = 15_000) {
return async (req, res) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
await handler(req, res, controller.signal);
} finally {
clearTimeout(timer);
}
};
}现在,处理程序可以使用 await fetch(url, { signal }),如果请求超时,它会被中断,而不是一直占用工作线程。
这里需要学习的要点是:每次与进程外部的任何东西通信时,都要为其设定一个截止时间。网络故障最无聊的方式就是挂起,而不是报错。超时可以将挂起转换为一个干净的错误,你可以对其进行处理。
如何安全地解析 JSON 并防止原型污染
这是人们通常会跳过的一点,因为它听起来很理论化,但随后却可能出现在 CVE 中,与你的堆栈一起出现。
首先,简单的一半。JSON.parse 在输入格式错误时会抛出一个 SyntaxError。在简单的服务器中,这个错误未被捕获,可能导致进程崩溃。因此,我们对解析进行封装,将解析失败转换为一个干净的 400 错误。
function parseJson(buffer) {
if (buffer.length === 0) return {};
let text = buffer.toString("utf8");
try {
return JSON.parse(text, reviver);
} catch {
throw httpError(400, "Invalid JSON body");
}
}现在是有趣的部分:那个 reviver 参数。原型污染是一种攻击方式,攻击者通过请求负载修改 Object.prototype,这是程序中几乎所有对象继承的基对象。如果攻击者可以在那里设置一个属性,他们可以一次性将该属性设置到几乎所有对象上。
一旦你看到它,就更容易相信了。下面是一个递归合并函数,这是人们经常编写的一种函数,用于将更新应用到现有记录上:
function merge(target, source) {
for (const key in source) {
if (source[key] && typeof source[key] === "object") {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}看起来无害。现在,用攻击者控制的负载来喂它:
const evil = JSON.parse('{"__proto__": {"isAdmin": true}}');
const account = {};
merge(account, evil);
console.log(account.isAdmin); // undefined,account 本身没问题
console.log(({}).isAdmin); // true <-- 所有对象现在都变成了“管理员”第二行才是真正的恐怖之处。你从未接触过({})。你污染了共享的原型,因此一个全新的空对象现在报告了isAdmin: true。如果之后你的代码在某个对象上执行if (user.isAdmin),而该对象并未显式设置该字段,恭喜你!所有人都是管理员。__proto__键欺骗了合并操作,使其向上遍历到所有对象共享的原型。
防御方法是在这些危险的键进入你的数据之前就拒绝它们。在解析时最干净的方式是使用reviver函数,这是JSON.parse为每个键构建结果时调用的函数。如果某个键返回undefined,它就会被丢弃。
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function reviver(key, value) {
if (FORBIDDEN_KEYS.has(key)) return undefined;
return value;
}就这样。三个键名在门口就被阻止了,由于负载永远不会将__proto__传递给解析器,上面的合并操作就变得无害了。
为了进一步加强防御深度,你也可以使用Object.create(null)创建内部查找对象,这会创建一个完全没有原型的对象,或者在键由用户控制时使用Map。如果你想要一个额外的保护措施,可以在进程启动时尽早执行Object.freeze(Object.prototype),这会使整个攻击类别以明显的方式失败。
我不会只依赖冻结,因为一些库对此感到不满,但阻止这些键不会带来任何成本,应该是默认的做法。
如何在每个响应中设置安全头
浏览器会为你保护用户,但前提是你要告诉它们。这个指令以一组小的响应头的形式出现。对于返回JSON的API,列表很短,且默认值很严格,这正是你想要的方式。
function secureHeaders(res) {
// 不让浏览器猜测内容类型。阻止将JSON响应视为HTML或脚本。
res.setHeader("X-Content-Type-Options", "nosniff");
// 防止点击劫持:不允许此响应在框架中加载。
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
// 在导航时不要泄露完整的URL(其中可能包含ID或令牌)。
res.setHeader("Referrer-Policy", "no-referrer");
// 仅在HTTPS下有意义:强制使用HTTPS两年,包括子域名。
res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
// 停止泄露你正在运行的内容。为所有人提供免费的情报。
res.removeHeader("X-Powered-By");
}快速浏览一下,因为盲目复制粘贴头信息会导致你最终得到一个没有作用的内容安全策略。
X-Content-Type-Options: nosniff阻止浏览器对你Content-Type的猜测,这关闭了一条偷偷将响应重新解释为可执行内容的路径。X-Frame-Options和frame-ancestors指令都拒绝让响应被嵌入到框架中,这是点击劫持的核心。对于一个纯粹的JSON API,默认的default-src 'none'是一个强大且合适的CSP,因为API不应该加载脚本、样式或图像。
Referrer-Policy: no-referrer可以防止你的URL(其中有时包含你不想传播的ID)被发送到其他网站。Strict-Transport-Security只有在你使用HTTPS时才相关,但一旦你使用了,它会通过告诉浏览器拒绝普通HTTP来阻止降级攻击。
移除 X-Powered-By 只是一个微小的举动,它只是剥夺了攻击者一个免费的提示,告诉他们应该对你做什么。
将这个操作封装成一个函数并在每个响应中调用的原因是,“每个响应”是人类容易忘记的部分。把它变成一个在请求顶部执行的调用,这样你就不会被其他事情分散注意力。
如何以恒定时间比较密钥
这里有一个看起来完全正常但实际上完全错误的 bug:
if (providedApiKey === expectedApiKey) {
// 授予访问权限
}问题在于,字符串的 === 操作符可以很快完成。它逐个字符进行比较,一旦发现不匹配就立即返回 false。这意味着一个错误的猜测如果第一个字符正确,被拒绝的时间会稍微长一点,而如果第一个字符就错误,被拒绝的时间会更短。
这个时间差异非常小,但在足够多的请求中是可以被测量的,它允许攻击者逐个字符地恢复密钥。这是一个真实的攻击,它有一个名字(时序攻击),而修复方法已经内建在 Node 中。
你想要一个运行时间不依赖于第一个差异位置的比较方法。Node 提供了 crypto.timingSafeEqual 正是为了这个目的。它有一个需要注意的地方:如果两个缓冲区的长度不同,它会抛出异常,而长度本身就是一个泄露点。
解决这两个问题的干净方法是先将两个输入哈希到固定大小,然后再比较哈希值。
import { timingSafeEqual, createHash } from "node:crypto";
function safeCompare(a, b) {
// 哈希可以标准化长度(使 timingSafeEqual 满意),并隐藏
// 实际密钥的长度,防止时序观察者发现。
const ha = createHash("sha256").update(String(a)).digest();
const hb = createHash("sha256").update(String(b)).digest();
return timingSafeEqual(ha, hb);
}在任何需要将陌生人提供的值与你持有的密钥进行比较的地方使用这个函数:API 密钥、Webhook 签名、密码重置令牌、会话标识符。经验法则很简单:如果比较错误会让某人进入系统,就不要使用 ===。
一个重要的注意事项,以防止你误用这个函数。对于用户密码,不要将它们存储并用这个函数进行比较。密码使用一种缓慢且专门设计的算法进行哈希处理,即使数据库泄露,这些哈希值也难以破解。
Node 在 node:crypto 中提供了 scrypt,而 bcrypt 和 argon2 是流行的库。上面的 safeCompare 函数用于比较高熵密钥(如令牌和密钥),而不是用户选择的密码。
如何将输入验证作为门禁,而不是建议
到目前为止,我们一直在讨论如何在传输层抵御恶意输入。验证是在输入到达你的业务逻辑之前,拒绝那些不符合你的代码预期形状的输入。
令人惊讶的是,很多“奇怪的生产行为”只是处理程序假设一个字段是字符串却得到了一个数组,或者假设是一个数字却得到了字符串 "NaN"。
你可以手动为一个小的 API 编写验证逻辑,在使用库之前,了解它看起来是什么样子是健康的:
function expect(condition, message) {
if (!condition) throw httpError(400, message);
}function parseCreateUser(data) {
expect(typeof data.email === "string" && data.email.includes("@"), "email is required");
expect(typeof data.password === "string", "password is required");
expect(data.password.length >= 12, "password must be at least 12 characters");
// 仅返回你实际需要的字段,忽略其他所有字段。
return { email: data.email, password: data.password };
}请注意最后一行。你创建一个只包含你所需字段的新对象,而不是直接传递 data。这可以悄悄地关闭一个“批量赋值”漏洞,即客户端发送 {"email": "...", "password": "...", "role": "admin"},而粗心的处理程序将整个对象写入数据库,包括 role。如果你只复制你打算接受的字段,多余字段将永远无关紧要。
对于超过几个路由的情况,模式库可以迅速收回成本。Zod 和 Valibot 是流行的选择,两者都允许你一次描述形状,从而获得验证和推断类型。
import { z } from "zod";
const CreateUser = z
.object({
email: z.string().email(),
password: z.string().min(12),
})
.strict(); // 拒绝未知键,而不是忽略它们
const result = CreateUser.safeParse(data);
if (!result.success) throw httpError(400, "Validation failed");
const user = result.data;.strict() 调用与我们手动实现的版本一样,提供了相同的“批量赋值”防御,但它是声明式的。无论是手动实现还是使用库,原则是一样的:输入在被证明匹配你有意定义的形状之前都是有罪的。
如何在不泄露信息的情况下失败并记录以便查看
错误是不可避免的。真正的问题是当它们发生时,你的服务器会说些什么,以及你之后是否能重建出发生了什么。
有两种常见的方式会导致错误,它们是相反的。要么你向攻击者提供了一份你内部结构的地图,要么你在事件发生时让自己变得盲目。
泄露版本看起来像这样,是的,我曾经发布过:
catch (err) {
res.writeHead(500);
res.end(err.stack); // 请不要这样做
}该堆栈跟踪可能包括文件路径、库版本、查询片段,有时甚至包括被插入到错误信息中的秘密。这是对任何试图探测你的人的免费简报。
规则很简单:500 错误应该向客户端提供无任何有用信息,但通过你的日志向你提供所有信息。
相反的失败方式是将错误隐藏得如此彻底,以至于当它在凌晨 2 点的生产环境中发生时,你没有任何线索。
解决这两个问题的方法是同一个小想法:一个请求 ID。这是一个你附加到每个请求的短唯一值,返回给客户端的头部,并包含在该请求的每个日志行中。当用户报告“我遇到了一个错误,它显示请求 abc123”,你可以在几秒钟内找到该请求的确切记录。
import { randomUUID } from "node:crypto";
function withRequestId(req, res) {
const requestId = req.headers["x-request-id"] ?? randomUUID();
res.setHeader("X-Request-Id", requestId);
return requestId;
}
function log(level, requestId, message, extra = {}) {
// 结构化日志:每行一个 JSON 对象,便于搜索和传输。
console.log(
JSON.stringify({ level, requestId, message, ...extra, at: new Date().toISOString() }),
);
}function sendError(res, err, requestId) {
const status = err.statusCode ?? 500;
const message = status === 500 ? "Internal Server Error" : err.message;
if (status === 500) {
log("error", requestId, "unhandled error", { stack: err.stack });
}
if (!res.headersSent) {
res.writeHead(status, { "Content-Type": "application/json" });
}
res.end(JSON.stringify({ error: message, requestId }));
}客户端会收到 requestId,但不会收到任何详细信息。他们可以引用它联系支持团队,但无法查看你的堆栈跟踪。当可信的上游设置了一个请求 ID 时,接受传入的 X-Request-Id,这样单个请求在跨服务传递时可以保持相同的 ID,但如果缺少请求 ID,则应自行生成。结构化日志,每行一个 JSON 对象,虽然略显不美观,但它们很容易进行过滤并输入到日志聚合器中,而一堆自由格式的 console.log 调用则无法做到这一点。
关于环境的一个注意事项。在本地运行时返回更丰富的错误信息是完全可以接受的,甚至是有帮助的。只需通过显式的环境检查来控制这一点,并让生产环境成为严格的默认设置,这样配置错误的最坏结果是信息泄露过少,而不是过多。
如何将所有内容整合在一起
这些防护措施单独来看并不令人印象深刻。它们的力量在于默认情况下在每个路由上都启用所有这些措施,这样安全的路径就是最简单的路径。
这是最初那个天真的服务器,经过重新构建后,加入了我们之前讨论的所有内容。它仍然非常小巧,只是不再天真了。
import http from "node:http";
import { timingSafeEqual, createHash } from "node:crypto";
const MAX_BODY_BYTES = 100 * 1024;
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function httpError(statusCode, message) {
return Object.assign(new Error(message), { statusCode });
}
function reviver(key, value) {
return FORBIDDEN_KEYS.has(key) ? undefined : value;
}
function readBody(req, limit = MAX_BODY_BYTES) {
return new Promise((resolve, reject) => {
const declared = Number(req.headers["content-length"]);
if (Number.isFinite(declared) && declared > limit) {
return reject(httpError(413, "Payload too large"));
}
let size = 0;
const chunks = [];
req.on("data", (chunk) => {
size += chunk.length;
if (size > limit) {
reject(httpError(413, "Payload too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function parseJson(buffer) {
if (buffer.length === 0) return {};
try {
return JSON.parse(buffer.toString("utf8"), reviver);
} catch {
throw httpError(400, "Invalid JSON body");
}
}
function secureHeaders(res) {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
res.removeHeader("X-Powered-By");
}
function safeCompare(a, b) {
const ha = createHash("sha256").update(String(a)).digest();
const hb = createHash("sha256").update(String(b)).digest();
return timingSafeEqual(ha, hb);
}function sendJson(res, status, payload) {
if (!res.headersSent) {
res.writeHead(status, { "Content-Type": "application/json" });
}
res.end(JSON.stringify(payload));
}
function sendError(res, err) {
const status = err.statusCode ?? 500;
// 不要在 500 错误中泄露内部错误信息。记录它们,但不要发送它们。
const message = status === 500 ? "Internal Server Error" : err.message;
if (status === 500) console.error(err);
sendJson(res, status, { error: message });
}
const API_KEY = process.env.API_KEY ?? "dev-only-key";
async function handler(req, res) {
secureHeaders(res);
// 作为一个示例,这里只有一个受保护的路由。
if (req.method === "POST" && req.url === "/users") {
const provided = req.headers["x-api-key"] ?? "";
if (!safeCompare(provided, API_KEY)) {
throw httpError(401, "Unauthorized");
}
const data = parseJson(await readBody(req));
if (typeof data.email !== "string" || !data.email.includes("@")) {
throw httpError(400, "email is required");
}
if (typeof data.password !== "string" || data.password.length < 12) {
throw httpError(400, "password must be at least 12 characters");
}
// 仅包含我们要求的字段。不进行批量赋值。
const user = { email: data.email };
return sendJson(res, 201, { created: user });
}
throw httpError(404, "Not found");
}
const server = http.createServer((req, res) => {
handler(req, res).catch((err) => sendError(res, err));
});
server.requestTimeout = 30_000;
server.headersTimeout = 10_000;
server.setTimeout(60_000);
server.listen(3000, () => console.log("listening on http://localhost:3000"));从上到下阅读这段代码,注意安全措施并不是一个单独的“安全中间件”,在最后添加上去的。而是融入到了正常的流程中。
请求体有大小限制。JSON 是安全解析的。每次都会发送头部信息。API 密钥检查是时间安全的。验证在任何逻辑之前执行。错误处理程序拒绝泄露内部信息。而且整个代码仍然足够小,可以轻松记住,这很重要,因为你无法理解的安全措施,最终可能会被意外禁用。
尝试破坏它。发送一个巨大的请求体,看看是否会返回 413。发送 {"__proto__": {"isAdmin": true}},确认执行 ( {}).isAdmin 之后仍然为 undefined。发送一个错误的 API 密钥,注意你无法从响应时间中判断你距离正确有多近。最后一个特性是设计成不可见的,这正是其目的所在。
如何正确处理 CORS
CORS(跨域资源共享)是 Web 开发中最常被误解的安全功能之一,而这种误解是危险的。
人们常犯的错误是:CORS 并不会保护你的服务器。它不是一个防火墙。它是一个浏览器特性,决定运行在一个网站上的 JavaScript 是否被允许读取另一个网站上 API 的响应。无论是否设置 CORS 头部,你的服务器都可以从 curl、Postman 或其他任何服务器完美访问。
这意味着在实践中,最常见的“修复”方法也是最常见的错误:
res.setHeader("Access-Control-Allow-Origin", "*"); // 在部署前理解这个设置通配符表示“任何网站的 JavaScript 都可以读取我的响应。”对于一个真正公开且只读、不需要凭据的 API 来说,这可能是完全没问题的。但对于任何使用 cookie 或返回与登录用户相关数据的情况,这将是一个错误,而且浏览器无论如何都不会将 * 与凭据结合使用。
正确的做法是只允许你真正信任的来源:
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
function applyCors(req, res) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin"); // 以便缓存不会混淆来源
res.setHeader("Access-Control-Allow-Credentials", "true");
}
}请记住这个思维模型:CORS 以一种受控的方式放松了浏览器的默认保护。将其设置为 * 并不会使你的 API 更容易受到其他服务器的攻击,因为服务器本身从未受到过限制。它使你的数据可以被任何受害者访问的网页读取,这是一个隐私和数据暴露的决定,而不是一个“让控制台错误消失”的决定。请有意地、逐个来源地做出这个决定。
本教程未涵盖的内容
诚实地说,因为假装完全完整的教程实际上对你没有帮助。上述的防护措施只是起点,而不是终点。
以下是刻意不在本教程中涵盖的内容,以及你可以进一步了解的方向:
- 身份验证和授权:你只检查了一个 API 密钥。真正的应用程序需要会话或令牌,并且需要明确谁被允许做什么。这是一个独立的主题。
- 速率限制:一个客户端不应该能够每分钟向你的登录路由发送一万次请求。内存中的计数器适用于单个实例。在负载均衡器后面,你需要使用像 Redis 这样的共享存储。
- 外发请求的安全性(SSRF):一旦你的服务器向用户提供的 URL 发起请求,你就会面临新的攻击面:有人可以将你指向内部地址或云元数据端点。这值得单独撰写一篇文章。
- TLS:所有与 HSTS 相关的内容都假设你实际上在某个地方终止了 HTTPS,无论是运行时、反向代理还是你的平台。
- 日志记录和监控:你无法响应你无法看到的内容。带有请求 ID 的结构化日志是每个成功事件响应的无趣基础。
每个主题都将是未来教程的一部分,每个教程都遵循与本教程相同的哲学:让安全的选择成为默认选项,让不安全的选择成为你需要特意去做的事情。
为什么默认值胜过清单
你可能会想知道为什么我总是强调“默认情况下”而不是直接给你一个清单并希望你走运。原因是我看到很多清单在截止日期面前失败了。
清单是一份人类必须记住每次正确执行的事情列表。这包括上周刚加入的初级开发人员,以及在午夜疲惫不堪并推送热修复的高级开发人员。
依赖于完美人类记忆的安全性,一旦团队变得忙碌,就会悄然退化,而这正是攻击者所希望的时刻。
关于框架的诚实说明
如果每次都要手动连接所有这些内容听起来很繁琐,那这正是正确的直觉,这也是为什么一些框架默认启用这些保护措施,这样你就不用再记住它们了。
完全披露:我维护其中一个框架,这是一个名为 DaloyJS 的开源项目。我并不是在这里推销它,本文中提到的一切都是普通的 Node.js 代码,无论你构建什么,它们都能以相同的方式运行。
我提到它只是因为产生它的教训正是本文的主旨:默认设置就是产品本身。一个让你必须主动选择安全性的框架,从统计学上讲,很可能会被那些忘记选择的人使用。
无论你使用框架还是自己构建,今天就把上面的辅助函数复制到你的项目中。它们不需要任何依赖,而且会默默地防止一类糟糕的日子发生。
总结检查清单
如果你记不住其他任何东西,请记住这个列表,并把它放在你的团队能看到的地方:
- 限制请求体:在数据流式传输时计算字节数,拒绝超过限制的请求,永远不要只依赖 Content-Length。
- 为所有操作设置超时:收紧 Node 的请求和头信息超时时间,并为所有出站调用设置截止时间,使用 AbortController。
- 防御性解析 JSON:将解析错误捕获为干净的 400 错误,并使用 reviver 移除 __proto__、constructor 和 prototype。
- 在每个响应中设置安全头信息:将它们封装在一个函数中,这样“每个响应”实际上就代表每个响应。
- 以常量时间比较密钥:使用 crypto.timingSafeEqual 对哈希输入进行比较,永远不要使用 ===,并使用真正的密码哈希来处理密码。
- 将输入验证作为门禁:有目的地定义输入的形状,拒绝不匹配的内容,并只复制你明确要求的字段。
这些都不是聪明的做法。这正是它们最好的地方。那些无聊的事情才是最可能出问题的地方,所以让这些无聊的事情自动化,把你的聪明才智用在产品中真正需要的地方。
Devlin Duldulao 是 DaloyJS 的创建者,他是一位软件工程师、教育者和已出版的作者,拥有 10 多年构建和教授现代网页开发的经验。他来自菲律宾,自 2019 年以来一直居住在挪威,他为希望构建真正安全系统的开发人员撰写书籍和课程,并像对待他的 TypeScript 一样认真对待他的亚洲家乡烹饪。
如果你读到了这里,请感谢作者以表达你对他们的关心。说声谢谢
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发人员。立即开始学习
ADVERTISEMENT