安全最佳实践

意图

本文档旨在扩展当前的威胁模型,并提供有关如何保护 Node.js 应用程序的广泛指南。

文档内容

  • 最佳实践:一种查看最佳实践的简化凝练方式。我们可以使用此议题此指南作为起点。需要注意的是,本文档特定于 Node.js,如果您正在寻找更广泛的内容,请考虑 OSSF 最佳实践
  • 攻击解释:用通俗易懂的英语和一些代码示例(如果可能)来说明和记录我们在威胁模型中提到的攻击。
  • 第三方库:定义威胁(拼写错误攻击、恶意软件包等)以及有关 node 模块依赖项等的最佳实践。

威胁列表

HTTP 服务器的拒绝服务攻击 (CWE-400)

这是一种攻击,由于应用程序处理传入 HTTP 请求的方式,导致其无法实现设计目的。这些请求不一定由恶意行为者蓄意构造:配置错误或有缺陷的客户端也可能向服务器发送请求模式,从而导致拒绝服务。

HTTP 请求由 Node.js HTTP 服务器接收,并通过注册的请求处理程序移交给应用程序代码。服务器不解析请求正文的内容。因此,任何由请求正文内容移交给请求处理程序后引起的 DoS 都不属于 Node.js 本身的漏洞,因为正确处理它是应用程序代码的责任。

确保 WebServer 正确处理套接字错误,例如,当服务器创建时没有错误处理程序,它将容易受到 DoS 攻击。

const  = ('node:net');

const  = .(function () {
  // socket.on('error', console.error) // this prevents the server to crash
  .('Echo server\r\n');
  .();
});

.(5000, '0.0.0.0');

如果执行了恶意请求,服务器可能会崩溃。

一个非由请求内容引起的 DoS 攻击示例是慢速攻击 (Slowloris)。在这种攻击中,HTTP 请求被缓慢且分片地发送,一次一个片段。在完整请求送达之前,服务器将为正在进行的请求保留资源。如果同时发送足够多的此类请求,并发连接数很快就会达到最大值,从而导致拒绝服务。这就是攻击不依赖于请求内容,而依赖于发送到服务器的请求的时机和模式的原因。

缓解措施

  • 使用反向代理来接收请求并将其转发到 Node.js 应用程序。反向代理可以提供缓存、负载均衡、IP 黑名单等功能,从而降低 DoS 攻击成功的可能性。
  • 正确配置服务器超时,以便可以丢弃空闲或请求到达过慢的连接。请参阅 http.Server 中的不同超时设置,特别是 headersTimeoutrequestTimeouttimeoutkeepAliveTimeout
  • 限制每个主机和总的开放套接字数量。请参阅 http 文档,特别是 agent.maxSocketsagent.maxTotalSocketsagent.maxFreeSocketsserver.maxRequestsPerSocket

DNS 重绑定攻击 (CWE-346)

这是一种可以针对使用 --inspect 开关启用调试检查器的 Node.js 应用程序的攻击。

由于在 Web 浏览器中打开的网站可以发出 WebSocket 和 HTTP 请求,因此它们可以瞄准本地运行的调试检查器。这通常由现代浏览器实施的同源策略来防止,该策略禁止脚本访问来自不同源的资源(意味着恶意网站无法读取从本地 IP 地址请求的数据)。

然而,通过 DNS 重绑定,攻击者可以暂时控制其请求的源,使其看起来源自本地 IP 地址。这是通过同时控制一个网站和用于解析其 IP 地址的 DNS 服务器来完成的。更多详情请参阅DNS 重绑定维基百科

缓解措施

  • 通过附加一个 process.on(‘SIGUSR1’, …) 监听器,在 SIGUSR1 信号上禁用检查器。
  • 不要在生产环境中运行检查器协议。

向未经授权的行为者泄露敏感信息 (CWE-552)

在包发布期间,当前目录中包含的所有文件和文件夹都会被推送到 npm 注册表。

有一些机制可以通过使用 .npmignore.gitignore 定义黑名单或在 package.json 中定义白名单来控制此行为。

缓解措施

  • 使用 npm publish --dry-run 列出所有要发布的文件。确保在发布包之前审查内容。
  • 创建和维护忽略文件(如 .gitignore.npmignore)也很重要。通过这些文件,您可以指定哪些文件/文件夹不应被发布。package.json 中的 files 属性允许反向操作——即白名单。
  • 如果发生泄露,请确保取消发布该包

HTTP 请求走私 (CWE-444)

这是一种涉及两个 HTTP 服务器(通常是一个代理和一个 Node.js 应用程序)的攻击。客户端发送一个 HTTP 请求,该请求首先通过前端服务器(代理),然后重定向到后端服务器(应用程序)。当前端和后端以不同方式解释模糊的 HTTP 请求时,攻击者就有可能发送一个前端看不到但后端会看到恶意消息,从而有效地“走私”过代理服务器。

有关更详细的描述和示例,请参阅 CWE-444

由于此攻击取决于 Node.js 对 HTTP 请求的解释与(任意)HTTP 服务器不同,因此一次成功的攻击可能是由于 Node.js、前端服务器或两者都存在漏洞。如果 Node.js 解释请求的方式与 HTTP 规范(见 RFC7230)一致,则不将其视为 Node.js 的漏洞。

缓解措施

  • 创建 HTTP 服务器时不要使用 insecureHTTPParser 选项。
  • 配置前端服务器以规范化模糊请求。
  • 持续监控 Node.js 和所选前端服务器中的新 HTTP 请求走私漏洞。
  • 尽可能端到端使用 HTTP/2 并禁用 HTTP 降级。

通过时序攻击泄露信息 (CWE-208)

这是一种攻击,允许攻击者通过例如测量应用程序响应请求所需的时间来获取潜在的敏感信息。这种攻击并非特定于 Node.js,几乎可以针对所有运行时。

只要应用程序在时间敏感的操作(例如分支)中使用密钥,这种攻击就可能发生。考虑一个典型应用程序中的身份验证处理。在这里,基本的身份验证方法包括电子邮件和密码作为凭据。用户信息从用户提供的输入中检索,理想情况下是从 DBMS 中检索。检索到用户信息后,将密码与从数据库检索到的用户信息进行比较。使用内置的字符串比较对于相同长度的值会花费更长的时间。这种比较在运行可接受的次数时,会不情愿地增加请求的响应时间。通过比较请求响应时间,攻击者可以在大量请求中猜测密码的长度和值。

缓解措施

  • crypto API 公开了一个函数 timingSafeEqual,用于使用恒定时间算法比较实际和预期的敏感值。

  • 对于密码比较,您可以使用原生 crypto 模块中也提供的 scrypt

  • 更一般地说,避免在可变时间操作中使用密钥。这包括对密钥进行分支,以及当攻击者可能位于相同基础设施上(例如,同一台云主机)时,使用密钥作为内存索引。用 JavaScript 编写恒定时间的代码很困难(部分原因是 JIT)。对于加密应用,请使用内置的加密 API 或 WebAssembly(对于未在原生中实现的算法)。

恶意第三方模块 (CWE-1357)

目前,在 Node.js 中,任何包都可以访问强大的资源,例如网络访问。此外,由于它们也可以访问文件系统,它们可以向任何地方发送任何数据。

在 node 进程中运行的所有代码都能够通过使用 eval()(或其等效项)来加载和运行额外的任意代码。所有具有文件系统写访问权限的代码都可以通过写入将被加载的新文件或现有文件来实现相同的目的。

Node.js 有一个实验性的¹ 策略机制,用于将加载的资源声明为不受信任或受信任。但是,此策略默认未启用。请确保锁定依赖项版本,并使用常见的工作流或 npm 脚本运行自动漏洞检查。在安装包之前,请确保该包是受维护的,并包含您期望的所有内容。请注意,GitHub 源代码并不总是与已发布的相同,请在 node_modules 中进行验证。

供应链攻击

Node.js 应用程序的供应链攻击发生在其某个依赖项(直接或间接)被攻破时。这可能是由于应用程序对依赖项的规范过于宽松(允许不必要的更新)和/或规范中的常见拼写错误(易受域名抢注攻击)所致。

控制上游包的攻击者可以发布一个包含恶意代码的新版本。如果 Node.js 应用程序依赖于该包,而没有严格规定哪个版本是安全的,那么该包可能会自动更新到最新的恶意版本,从而危及应用程序。

package.json 文件中指定的依赖项可以有确切的版本号或一个范围。然而,当将依赖项固定到确切版本时,其间接依赖项本身并未被固定。这仍然使应用程序容易受到不必要/意外更新的攻击。

可能的攻击向量

  • 域名抢注攻击
  • 锁文件投毒
  • 被攻破的维护者
  • 恶意软件包
  • 依赖混淆

缓解措施

  • 使用 --ignore-scripts 防止 npm 执行任意脚本。
    • 此外,您可以使用 npm config set ignore-scripts true 在全局范围内禁用它。
  • 将依赖项版本固定到特定的不可变版本,而不是一个范围或来自可变源的版本。
  • 使用锁文件,它会固定每个依赖项(直接和间接)。
  • 使用 CI 自动化新漏洞的检查,使用诸如 npm-audit 之类的工具。
    • 诸如 Socket 之类的工具可用于通过静态分析来分析包,以发现诸如网络或文件系统访问之类的风险行为。
  • 使用 npm ci 而不是 npm install。这会强制执行锁文件,因此锁文件和 package.json 文件之间的不一致会导致错误(而不是静默地忽略锁文件而偏向 package.json)。
  • 仔细检查 package.json 文件中依赖项名称的错误/拼写错误。

内存访问冲突 (CWE-284)

基于内存或基于堆的攻击依赖于内存管理错误和可利用的内存分配器的组合。与所有运行时一样,如果您的项目在共享机器上运行,Node.js 也容易受到这些攻击。使用安全堆有助于防止由于指针上溢和下溢而导致的敏感信息泄漏。

不幸的是,安全堆在 Windows 上不可用。更多信息可以在 Node.js secure-heap 文档中找到。

缓解措施

  • 根据您的应用程序使用 --secure-heap=n,其中 n 是分配的最大字节大小。
  • 不要在共享机器上运行您的生产应用。

猴子补丁 (CWE-349)

猴子补丁是指在运行时修改属性以改变现有行为。示例

.. = function () {
  // overriding the global [].push
};

缓解措施

--frozen-intrinsics 标志启用了实验性的¹ 冻结内在函数,这意味着所有内置的 JavaScript 对象和函数都被递归冻结。因此,以下代码片段不会覆盖 Array.prototype.push 的默认行为

.. = function () {
  // overriding the global [].push
};

// Uncaught:
// TypeError <Object <Object <[Object: null prototype] {}>>>:
// Cannot assign to read only property 'push' of object ''

然而,需要注意的是,您仍然可以使用 globalThis 定义新的全局变量并替换现有的全局变量。

> globalThis.foo = 3; foo; // you can still define new globals
3
> globalThis.Array = 4; Array; // However, you can also replace existing globals
4

因此,可以使用 Object.freeze(globalThis) 来保证不会有全局变量被替换。

原型污染攻击 (CWE-1321)

原型污染是指通过滥用 __proto_、_constructorprototype 以及从内置原型继承的其他属性,来修改或注入属性到 Javascript 语言项中的可能性。

const  = { : 1, : 2 };
const  = .('{"__proto__": { "polluted": true}}');

const  = .({}, , );
.(.polluted); // true

// Potential DoS
const  = .('{"__proto__": null}');
const  = .(, );
.hasOwnProperty('b'); // Uncaught TypeError: d.hasOwnProperty is not a function

这是从 JavaScript 语言继承的潜在漏洞。

示例:

缓解措施

  • 避免不安全的递归合并,请参阅 CVE-2018-16487
  • 对外部/不受信任的请求实施 JSON Schema 验证。
  • 使用 Object.create(null) 创建没有原型的对象。
  • 冻结原型:Object.freeze(MyObject.prototype)
  • 使用 --disable-proto 标志禁用 Object.prototype.__proto__ 属性。
  • 使用 Object.hasOwn(obj, keyFromObj) 检查属性是否直接存在于对象上,而不是从原型继承。
  • 避免使用来自 Object.prototype 的方法。

不受控制的搜索路径元素 (CWE-427)

Node.js 遵循模块解析算法来加载模块。因此,它假定请求(require)模块所在的目录是受信任的。

这意味着以下应用程序行为是预期的。假设有以下目录结构

  • app/
    • server.js
    • auth.js
    • auth

如果 server.js 使用 require('./auth'),它将遵循模块解析算法并加载 auth 目录而不是 auth.js 文件。

缓解措施

使用带有完整性检查的实验性¹ 策略机制可以避免上述威胁。对于上述目录,可以使用以下 policy.json

{
  "resources": {
    "./app/auth.js": {
      "integrity": "sha256-iuGZ6SFVFpMuHUcJciQTIKpIyaQVigMZlvg9Lx66HV8="
    },
    "./app/server.js": {
      "dependencies": {
        "./auth": "./app/auth.js"
      },
      "integrity": "sha256-NPtLCQ0ntPPWgfVEgX46ryTNpdvTWdQPoZO3kHo0bKI="
    }
  }
}

因此,当请求 auth 模块时,系统将验证其完整性,如果不匹配预期的完整性,则会抛出错误。

» node --experimental-policy=policy.json app/server.js
node:internal/policy/sri:65
      throw new ERR_SRI_PARSE(str, str[prevIndex], prevIndex);
      ^

SyntaxError [ERR_SRI_PARSE]: Subresource Integrity string "sha256-iuGZ6SFVFpMuHUcJciQTIKpIyaQVigMZlvg9Lx66HV8=%" had an unexpected "%" at position 51
    at new NodeError (node:internal/errors:393:5)
    at Object.parse (node:internal/policy/sri:65:13)
    at processEntry (node:internal/policy/manifest:581:38)
    at Manifest.assertIntegrity (node:internal/policy/manifest:588:32)
    at Module._compile (node:internal/modules/cjs/loader:1119:21)
    at Module._extensions..js (node:internal/modules/cjs/loader:1213:10)
    at Module.load (node:internal/modules/cjs/loader:1037:32)
    at Module._load (node:internal/modules/cjs/loader:878:12)
    at Module.require (node:internal/modules/cjs/loader:1061:19)
    at require (node:internal/modules/cjs/helpers:99:18) {
  code: 'ERR_SRI_PARSE'
}

注意,始终建议使用 --policy-integrity 来避免策略突变。

生产环境中的实验性功能

不建议在生产环境中使用实验性功能。实验性功能可能会在需要时发生重大更改,并且其功能不够安全稳定。尽管如此,我们非常欢迎反馈。

OpenSSF 工具

OpenSSF 正在领导几项非常有用的倡议,特别是如果您计划发布 npm 包。这些倡议包括

  • OpenSSF Scorecard Scorecard 使用一系列自动化的安全风险检查来评估开源项目。您可以使用它来主动评估代码库中的漏洞和依赖项,并就接受漏洞做出明智的决定。
  • OpenSSF 最佳实践徽章计划 项目可以通过描述它们如何遵守每个最佳实践来自愿进行自我认证。这将生成一个可以添加到项目中的徽章。