在我职业生涯中遇到的大多数安全问题其实都并不复杂。它们既不是国家层面的攻击,也不是那些巧妙的零日漏洞。这些问题都很普通:有的是某个限制条件被遗漏了,有的是超时设置被忘记了,还有的是某些字符串比较操作会偶尔泄露敏感信息。

恰恰就是这些看似平常的问题才会真正带来麻烦,因为大家都认为这些问题可以“以后再解决”,而“以后”往往永远不会到来。

我个人的最佳例子就是这样一个案例:我们内部使用的一个API通过简单的相等性判断来验证访问令牌的有效性,而且对请求数据的大小没有任何限制。这个系统运行了一年都没有出现问题,直到有人发现可以通过某些方法操纵这个验证过程,甚至发送体积巨大的数据让服务器不堪重负。

这两个漏洞其实都不复杂,如果一开始就能阻止我做出错误操作,这些问题根本就不会发生。

本教程会教你如何为自己开发的每一个HTTP API添加实用的安全防护措施,无论你使用的是哪种开发框架。你会用纯Node.js语言亲手编写这些代码,不需要依赖任何第三方库。这样你就能清楚地了解每一项安全措施的用途和作用原理。

学完本教程后,你将会拥有一个比我们大多数人职业生涯早期开发的版本更强大的服务器系统,它能够更好地应对公共互联网环境中的各种挑战。

本教程使用的是纯JavaScript语言,因此任何人都可以复制并运行这些代码。如果你使用TypeScript,之后也可以添加类型声明。你需要安装Node 22或更高版本的Node.js,具备对HTTP请求和响应的基本了解,并且需要一个终端来测试示例代码。

我们将涵盖的内容:

  1. 先决条件

  2. 你将构建什么

  3. 如何开始使用基础服务器

  4. 如何限制请求数据的大小

  5. 如何为慢速请求设置超时机制

  6. 如何安全地解析JSON数据并防止原型污染

  7. 如何在每个响应中设置安全头信息

  8. 如何在常数时间内比较敏感信息

  9. 如何将输入验证视为一种强制性的检查机制,而不仅仅是建议

  10. 如何让程序在出错时不会泄露敏感信息,并且会留下日志供你查看

  11. 如何将所有这些安全措施整合到一起使用

  12. 如何正确处理CORS请求

  13. 本教程未涵盖的内容

  14. 为什么默认设置比检查清单更有效

  15. 关于开发框架的一些坦诚看法

  16. 总结性检查清单

先决条件

  • Node.js 22或更高版本

  • 对HTTP请求与响应有基本了解

  • 需要终端以及curl、Postman或其他类似的工具

这份指南并非旨在让你的API完全无法被攻击,而是帮助你避免那些常见的攻击方式,并设置更安全的默认配置。

你将构建什么

一个简单的Node.js API,它会限制请求体的大小、设置请求超时时间、确保JSON数据的安全解析、添加安全头部信息、进行安全的秘密值比对、执行数据验证以及处理错误。

如何开始使用这个简单服务器

这是我年轻时编写的服务器代码——虽然当时我更勇敢,但也犯了很多错误。这个服务器会读取接收到的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数据格式不正确时,《JSON.parse》方法会抛出异常,这种未捕获的异常可能会导致整个进程崩溃。

  • 这个服务器没有设置请求超时机制,因此如果客户端以极慢的速度发送数据,那么连接就会一直保持开放状态,从而导致资源浪费。

  • 它没有添加任何安全头部信息,因此会暴露自己是一个Node.js服务器的事实。

  • 它会直接将未经验证的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并不需要50MB的请求体;而文件上传接口的情况则不同,在那种情况下,数据应该直接被写入磁盘或对象存储系统中,而不是先保存在内存中。真正的错误在于没有设置限制,而不是设置了错误的限制值。

如何为耗时较长的请求设置超时机制

一旦确定了请求体的最大数据量限制,攻击者就会采取另一种策略:让请求的响应速度变得非常慢,而不是增加请求体本身的大小。这类攻击方法是以“slowloris”这种行动极其缓慢的灵长类动物命名的。

攻击者的具体做法是建立大量连接,然后以极慢的速度向这些连接发送数据,永远不完成发送操作,这样服务器就会一直保持这些连接处于活跃状态。如果这种情况反复发生,连接池中的所有连接都会被耗尽,但却不会有任何看起来具有恶意性的数据被发送出去。

Node.js内置了针对这种攻击的防护机制,默认设置已经相当严格了,但对于API来说,进一步优化这些设置仍然是有必要的。

const server = http.createServer(handler);

// 允许接收整个请求所需的总时间(包括请求头和请求体)。
server.requestTimeout = 30_000; // 30秒

// 允许接收请求头所需的时间。slowloris攻击就是利用这个设置来拖延时间的。
server.headersTimeout = 10_000; // 10秒

// 空闲连接超时时间:会关闭那些长时间没有活动的连接。
server.setTimeout(60_000);

上述这三行代码用于处理网络层的相关配置。但还有一种导致请求响应速度变慢的原因,那就是你的应用程序自身处理请求的逻辑存在问题。比如数据库查询耗时过长、与第三方的调用没有得到响应,或者正则表达式的匹配过程过于复杂等。你需要为单个请求设置一个最长处理时间限制,并且要在这个时间限制被达到时能够立即取消该请求的处理。

在Node.js中,用于实现请求取消功能的工具是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 {
      clearTimeouttimer);
    }
  };
}

现在,一个请求处理函数可以通过await fetch(url, { signal })这种方式来发送请求,如果请求的处理超出了设定的截止时间,那么请求就会被立即终止,而不会一直占用系统资源。

在这里需要遵守的规则是:每当你在处理流程之外与某些内容进行交互时,都必须为这些操作设定明确的截止时间。网络故障往往以最令人沮丧的方式发生——它们会陷入“挂起”状态,而不是出现错误。通过设置超时机制,就可以将这种“挂起”状态转化为可以妥善处理的错误。

如何安全地解析JSON数据并防止原型污染

很多人会忽略这个知识点,因为它听起来很抽象;但后来在某些安全漏洞报告中,就会发现他们的代码中存在这个问题。

首先来看比较简单的一部分。`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, "无效的JSON数据");
  }
}

现在来看比较有趣的部分:那就是`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(({}).ADMIN);      // true  -- 现在所有的对象都被认为是“管理员”

第二行代码才是真正的隐患。你明明并没有修改`({})`这个对象,但通过这种机制,你还是污染了所有对象的共享原型,导致一个全新的空对象也被认定为“管理员”。如果后续你的代码中出现了`if (user.isAdmin)`这样的判断语句,而该对象的`isAdmin`属性本来并没有被设置,那么结果就是——所有人都被视为管理员了。`__proto__`这个键的作用,其实就是让合并操作触及到了所有对象共用的原型对象。

防范这种攻击的方法就是在数据被处理之前,就拒绝那些危险的键值对进入系统。在解析JSON数据时,最有效的办法就是使用`reviver`函数。`JSON.parse`方法在构建解析结果的过程中,会为每一个键调用这个函数;如果某个键对应的值应该被忽略,就可以让`reviver`返回`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 这一设置可以防止浏览器自行判断响应的内容类型,从而避免将 JSON 响应误认为是可执行文件。`X-Frame-Options` 以及 `frame-ancestors` 这些设置都可以阻止响应被嵌入到框架中,而点击劫持正是利用了这种机制。对于纯粹提供 JSON 数据的 API 来说,`default-src ‘none’` 是一个非常合适的安全策略,因为 API 并不需要加载脚本、样式表或图片。

Referrer-Policy: no-referrer 这一设置可以防止包含敏感信息的 URL 被发送到其他网站。`Strict-Transport-Security` 仅在使用 HTTPS 协议时才有效,但它能通过阻止浏览器使用普通 HTTP 协议来防止攻击行为。

X-Powered-By 这一头部信息的删除虽然看似微不足道,但实际上它可以防止攻击者轻易获取有关你服务的信息,从而避免被他们用来发起攻击。

之所以要将这一操作封装成函数,并在每次收到响应时都执行它,是因为人们往往会忘记“每次接收响应时都需要进行这样的检查”。因此,最好在请求开始时就执行这一次检查,这样就不会被其他事情分心了。

如何在常数时间内比较秘密值

下面这个代码看起来完全正常,但实际上却存在严重的问题:

if (providedApiKey === expectedApiKey) {
  // 授予访问权限
}

问题在于,对于字符串来说,===操作的执行速度本来就很快。它会逐个字符地进行比较,一旦发现不匹配的地方就会立即返回false。因此,即使猜测的第一个字符是正确的,这种判断方式也会比那些一开始就判断错误的做法花费更长的时间。

虽然这种时间差异非常微小,但在大量的请求中,这种差异还是可以被测量的。攻击者可以利用这一点,一次只恢复一个字符的内容。这是一种真实的攻击手段,它有一个专门的名称——时间差攻击,而Node.js中也已经提供了相应的修复措施。

你需要的是一种比较方法,其执行时间不应取决于第一次出现差异的位置。Node.js提供了cryptotimingSafeEqual函数,正好可以满足这个需求。不过这个函数也有一个缺点:如果两个输入缓冲区的长度不同,它就会抛出错误,而缓冲区的长度本身也有可能被攻击者利用来获取秘密信息。

要同时解决这两个问题,最简单的方法就是先对两个输入进行哈希处理,将其转换为固定长度的字符串,然后再比较这些哈希值。

import { timingSafeEqual, createHash } from "node:crypto";

function safeCompare(a, b) {
  // 哈希处理可以统一长度,并且能够隐藏真实秘密的长度,从而防止攻击者通过观察哈希值来获取秘密信息。
  const ha = createHash("sha256").update(String(a)).digest();
  const hb = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(ha, hb);
}

在任何需要将陌生人提供的值与你自己持有的秘密值进行比较的地方,都可以使用这个函数。例如API密钥、Webhook签名、密码重置令牌、会话标识符等等。一个简单的原则是:如果判断错误会导致他人获得访问权限,那就千万不要使用===操作。

还需要注意一点,不要误用这个函数。对于用户密码来说,不应该直接使用这个函数来进行比较。因为密码通常会经过专门的哈希算法处理,即使数据库被泄露,攻击者也需要花费大量的时间才能破解这些哈希值。

Node.js在node:crypto模块中提供了scrypt库来处理密码加密问题,而bcryptargon2也是常用的密码加密库。safeCompare函数适用于比较那些高熵值的秘密信息,比如令牌和密钥,而不适合用来比较用户自己选择的密码。

如何将输入数据作为一道“屏障”来验证,而不是仅仅作为一个建议

到目前为止,我们所讨论的所有内容都是关于如何在传输层抵御恶意输入的。而验证的作用,则是在这些输入数据到达业务逻辑处理环节之前,拒绝那些不符合代码预期的格式的数据。

实际上,很多“奇怪的编程行为”都是因为处理程序错误地认为某个字段应该是字符串,结果却得到了数组;或者误以为某个字段是数字,反而得到了字符串“NaN”。

对于规模较小的API来说,你可以自己编写验证代码。在使用第三方库之前,先亲自看看这样的验证逻辑是什么样子,这是很有意义的:

function expect(condition, message) {
if (!condition) throw httpError(400, message);
}

function parseCreateUser(data) {
expect(typeof data.email === "string" && data.email.includes("@"), "必须提供电子邮件地址");
expect(typeof data.password === "string", "必须提供密码");
expect(data.password.length >= 12, "密码长度必须至少为12个字符");
// 只返回你真正需要的字段,忽略其他所有内容。
return { email: data.email, password: data.password };
}

注意最后那一行代码。你只提取了所需字段来创建新的对象,而不是直接使用`data`这个对象。这样就可以避免那种“批量赋值”的漏洞——比如当客户端发送`{"email": "...", "password": "...", "role": "admin"}`这样的数据时,如果处理程序不够谨慎,就会将整个对象都存入数据库,包括那些不必要的字段。而如果你只复制你真正需要处理的字段,那么这些多余的字段就毫无意义了。

对于那些路由数量超过几个的API来说,使用规范库会很快带来好处。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, "验证失败");
const user = result.data;

`.strict()`这个方法起到了与我们手动编写验证代码相同的作用,只不过它是以声明式的方式实现的。无论你是自己编写验证逻辑还是使用第三方库,原则都是一样的:在证明输入数据符合你预先定义的结构之前,它们都被视为无效的。

如何让错误发生却不会导致信息泄露或日志记录混乱,从而便于事后分析问题

错误是不可避免的。真正重要的是,在错误发生时,你的服务器能够输出什么信息,以及你是否能够根据这些信息还原出出现问题的原因。

在处理错误时,通常会犯两种常见的错误,这两种方法其实是相互对立的:要么你把系统的内部结构直接暴露给攻击者,要么你在出现问题时故意忽略一些关键信息。

那种会导致信息泄露的处理方式是这样的:

catch (err) {
res.writeHead(500);
res.end(err.stack); // 这种做法绝对不可取
}

这样的错误堆栈跟踪中可能会包含文件路径、库版本、查询参数,甚至是一些被嵌入到错误信息中的敏感数据。对于那些试图攻击你的的人来说,这些信息简直就是一份免费的“操作指南”。

这条规则非常明确:一个编号为“500”的错误信息不应该向客户端提供任何有用的信息,而应该通过日志将所有相关信息都传递给你。

另一种错误的做法是将错误信息隐藏得过于彻底,以至于当它在凌晨2点发生在生产环境中时,你根本没有任何线索可以追踪问题所在。

解决这两种问题的方法其实都是同一个简单的思路:使用请求ID。你可以为每个请求分配一个简短且唯一的标识符,通过响应头将其返回给客户端,并将该标识符记录在与该请求相关的所有日志中。当用户报告“我遇到了错误,提示信息中提到了请求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 }));
}

客户端会收到这个请求ID,但无法获取到错误的详细信息。他们可以引用这个ID来寻求技术支持,但却无法查看服务器的堆栈跟踪信息。当来自可信上游系统的请求中包含了X-Request-Id时,你应该接受它;这样同一个请求在经过你的各个服务处理后,其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}}这条数据,确认之后({}).admins的值仍然是undefined;再尝试使用错误的API密钥进行请求,你会发现从响应时间来看,你根本无法判断自己之前的操作到底有多接近成功……最后这个设计初衷就是让这些错误行为“隐形”,而这一点正是关键所在。

如何正确处理CORS

CORS,即跨源资源共享功能,是Web开发中最为人们误解的安全机制之一,而且这种误解往往具有危险性。

很多人都搞错了:CORS并不是用来保护你的服务器的,它并不等同于防火墙。实际上,它是浏览器的一种设置,用于决定在一个网站上运行的JavaScript是否可以被允许读取另一个网站上的API返回的数据。无论是否有CORS头信息,你的服务器仍然可以通过curl、Postman或其他任何工具被正常访问。

在实际应用中,人们最常见的“解决方法”往往也是最容易犯的错误:

res.setHeader("Access-Control-Allow-Origin", "*"); // 在正式发布代码之前,请务必仔细考虑这个设置

使用通配符*意味着“任何网站的JavaScript都可以读取我的响应数据”。对于那些真正属于公共领域、仅提供只读功能且不需要用户认证的API来说,这种设置可能没有问题。但对于那些需要使用cookie或返回与登录用户相关的数据的API而言,这种设置就是错误的——因为浏览器根本不会允许将*与用户认证信息结合在一起使用。

正确的做法应该是只允许那些你真正信任的来源访问你的API:

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只是以一种受控的方式削弱了浏览器默认的安全保护机制。将Access-Control-Allow-Origin设置为*并不会让你的API更容易受到其他服务器的攻击——因为原本这些服务器就被允许访问你的API了。这种设置实际上意味着,任何用户访问的网页都有可能读取到你的数据,这是一种关于隐私和数据安全的选择,而不是为了“消除控制台错误”而做出的决定。因此,在设置CORS规则时,必须逐一考虑每一个允许访问的来源地址。

本教程未涵盖的内容

说实话,那些自称内容齐全的教程其实是在误导读者。上述提到的这些内容只是基础要求,并非最终目标。

以下是那些被刻意排除在外的内容,以及你可以去哪里继续学习:

  • 认证与授权:你只了解了一个API密钥,但实际应用中需要会话机制或令牌,同时还需要明确规定谁可以做什么。这本身就是一个独立的话题。

  • 速率限制:不应该允许某个客户端每分钟向登录接口发送上万次请求。对于单个实例来说,使用内存计数器是可以的;但在负载均衡器的环境下,就需要使用Redis这样的共享存储系统。

  • 出站请求安全(SSRF):一旦服务器开始根据用户提供的URL发起请求,就会产生新的安全风险——攻击者可能会引导服务器访问内部地址或云服务元数据端点。这个话题也值得专门撰写一篇文章来探讨。

  • TLS协议:所有与HSTS相关的内容都假定你已经在某个环节实现了HTTPS加密,无论是运行时环境、反向代理还是你的开发平台本身。

  • 日志记录与监控:如果你看不到问题所在,就无法采取相应的措施。带有请求ID的结构化日志是确保事件处理顺利进行的基础。

以上每一项内容都适合未来编写单独的教程来讲解,而这些教程都会遵循与本教程相同的理念:将安全的行为设为默认选项,而将不安全的行为设置为需要用户主动选择才会采用的做法。

为什么默认设置比检查清单更有效

你可能会疑惑,为什么我一直强调“默认设置”,而不是直接给你一份检查清单然后祝你好运。原因在于,我见过很多情况下,仅仅依靠检查清单却无法避免问题的发生。

检查清单是一份列出了人们必须每次都正确执行的任务的列表,这种要求永远有效——无论是上周新加入的初级开发人员,还是已经精疲力竭、在午夜发布热修复版本的资深开发人员,都必须遵守这些规定。

如果安全性依赖于人类完美的记忆力,那么一旦团队开始忙碌起来,这种安全性就会迅速下降,而这恰恰是攻击者所期望看到的情况。

默认设置则不同。默认设置是指在没有人采取任何行动时会发生什么结果。如果安全行为被设为默认选项,那么人们即使忘记了这些规则,应用程序依然会是安全的;但如果不安全的行为才是默认选项,那么人们一旦忘记就会导致安全漏洞的产生,而人类总是会犯错,因为他们还有许多其他事情要处理。

正因为如此,本文中提到的那些辅助措施都是需要在每次请求开始时调用一次的函数,而不是分散地嵌入到各种处理逻辑中然后期望用户能够全部执行到位。同样地,那些重视安全性的开发框架也会将保护机制设置为默认开启状态,让用户必须主动选择才能关闭它们,而不是让这些保护机制处于关闭状态、让用户必须主动选择才能启用它们。

这些措辞听起来似乎只是些细微的差别,但在一个真实的团队中、在一个真实的一年里进行测试时,结果上的差异却是巨大的。如果将“偷懒的路径”和“安全的路径”设计成相同的路径,你会惊讶地发现,“偷懒”其实也可以带来很高的安全性。

关于框架的坦诚看法

如果每次都手动配置这些设置会让你觉得繁琐,那么这种感觉正是正确的——正因如此,一些框架才会默认提供这些安全保护机制,这样你就无需再去记住它们了。

坦白说,我维护着一个这样的开源项目,名叫DaloyJS。我并不是来推销它的,而且这篇文章中提到的所有内容其实都是使用纯Node.js实现的,无论你用它来构建什么应用,这些方法都同样适用。

我之所以提到它,只是因为这个项目的诞生过程恰恰体现了本文想要传达的核心理念:框架的默认设置往往就是最终使用的配置。那些需要用户主动选择才能启用安全功能的框架,从统计数据来看,往往会被那些忘记启用这些功能的人使用。

无论你使用框架还是自己编写代码,今天就把上述这些辅助工具添加到你的项目中吧。它们不依赖于任何第三方库,却能够有效地预防一系列潜在的安全问题。

总结性检查清单

如果你们什么都不记得了,至少要记住这份清单,并把它放在团队都能看到的地方:

  • 限制数据传输量:在数据传输过程中实时计算字节数,一旦超过上限就立即拒绝接收;千万不要仅仅依赖Content-Length这个字段来判断数据长度。

  • 为所有请求设置超时机制:调整Node.js的请求超时和头部信息发送超时时间,并使用AbortController为每个出站请求设定截止时间。

  • 谨慎解析JSON数据:在解析JSON出现错误时,要返回一个清晰的400错误响应;同时使用特定工具去除__proto__constructorprototype这些字段。

  • 为所有响应设置安全头部信息:将这些操作封装在一个函数中,确保“所有响应”都真正得到相应的安全处理。

  • 以恒定时间复杂度验证敏感数据:对已哈希处理的密码数据使用cryptotimingSafeEqual进行比较,绝对不要使用===操作符;同时确保使用的密码哈希算法是可靠的。

  • 作为入口环节验证输入数据:明确指定输入数据的格式要求,拒绝不符合要求的数据,只接收你真正需要的字段。

这些措施看起来并不复杂,但恰恰正是这种简单性才让它们如此有效。那些繁琐的步骤其实能起到关键作用,所以把它们自动化处理吧,把你的智慧和精力投入到产品中真正需要创新的地方去。

Comments are closed.