Skip to content

安全最佳实践

目的

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

文档内容

  • 最佳实践:简化浓缩的最佳实践方法。我们可以使用此问题此指南作为起点。需要注意的是,本文档特定于 Node.js,如果您正在寻找更广泛的内容,请考虑OSSF 最佳实践
  • 攻击解释:用简单的英语(如果可能的话,附带一些代码示例)来说明和记录我们在威胁模型中提到的攻击。
  • 第三方库:定义威胁(错字劫持攻击、恶意软件包……)以及关于 Node 模块依赖项等的最佳实践……

威胁列表

HTTP 服务器拒绝服务 (CWE-400)

这是一种攻击,由于应用程序处理传入 HTTP 请求的方式,导致应用程序无法用于其设计目的。这些请求不必由恶意行为者故意制作:配置错误或有错误的客户端也可能向服务器发送一系列请求,导致拒绝服务。

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

确保 Web 服务器正确处理套接字错误,例如,如果在没有错误处理程序的情况下创建服务器,它将容易受到 DoS 攻击。

javascript
import net from 'node:net'
const server = net.createServer(socket => {
  // socket.on('error', console.error) // 这可以防止服务器崩溃
  socket.write('Echo server\r\n')
  socket.pipe(socket)
})
server.listen(5000, '0.0.0.0')

如果执行了错误的请求,服务器可能会崩溃。

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

缓解措施

  • 使用反向代理接收请求并将其转发到 Node.js 应用程序。反向代理可以提供缓存、负载均衡、IP 黑名单等功能,从而降低拒绝服务攻击成功的概率。
  • 正确配置服务器超时,以便可以丢弃空闲连接或请求到达速度过慢的连接。查看 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.js 进程中运行的所有代码都可以通过使用 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=n,其中 n 是分配的最大字节大小。
  • 不要在共享机器上运行您的生产应用程序。

猴子补丁 (CWE-349)

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

js
// eslint-disable-next-line no-extend-native
Array.prototype.push = function (item) {
  // 重写全局的 [].push
}

缓解措施

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

js
// eslint-disable-next-line no-extend-native
Array.prototype.push = function (item) {
  // 重写全局的 [].push
}
// 未捕获:
// TypeError <Object <Object <[Object: null prototype] {}>>>:
// Cannot assign to read only property 'push' of object '

但是,重要的是要注意,您仍然可以使用 globalThis 定义新的全局变量和替换现有的全局变量:

bash
globalThis.foo = 3; foo; // 你仍然可以定义新的全局变量 3
globalThis.Array = 4; Array; // 但是,你也可以替换现有的全局变量 4

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

原型污染攻击 (CWE-1321)

原型污染指的是通过滥用__proto___constructorprototype以及其他从内置原型继承的属性,修改或注入 JavaScript 语言项属性的可能性。

js
const a = { a: 1, b: 2 }
const data = JSON.parse('{"__proto__": { "polluted": true}}')
const c = Object.assign({}, a, data)
console.log(c.polluted) // true
// 潜在的拒绝服务攻击
const data2 = JSON.parse('{"__proto__": null}')
const d = Object.assign(a, data2)
d.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

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

因此,当需要 auth 模块时,系统将验证完整性,如果不匹配预期值则抛出错误。

bash
» 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 最佳实践徽章计划 项目可以自愿自我认证,方法是描述它们如何遵守每个最佳实践。这将生成一个可以添加到项目的徽章。