安全最佳实践
意图
本文档旨在扩展当前的 威胁模型,并提供关于如何保护 Node.js 应用程序的广泛指南。
文档内容
- 最佳实践:一种简化的、浓缩的方式来查看最佳实践。我们可以使用 这个问题 或 这个指南 作为起点。重要的是要注意,本文档专门针对 Node.js。如果您正在寻找更广泛的内容,请考虑 OSSF 最佳实践。
- 攻击解释:用简单的英语,以及一些代码示例(如果可能),来说明和记录我们在威胁模型中提到的攻击。
- 第三方库:定义有关节点模块依赖项的威胁(拼写错误攻击、恶意软件包...)和最佳实践,等等...
威胁列表
HTTP 服务器拒绝服务 (CWE-400)
这是一种攻击,由于应用程序处理传入 HTTP 请求的方式,导致应用程序无法用于其设计的目的。这些请求不需要由恶意行为者故意制作:配置错误或有缺陷的客户端也可能向服务器发送导致拒绝服务的请求模式。
Node.js HTTP 服务器接收 HTTP 请求,并通过注册的请求处理程序将其传递给应用程序代码。服务器不解析请求主体的内容。因此,由于主体的内容在传递给请求处理程序后引起的任何 DoS 都不是 Node.js 本身的漏洞,因为应用程序代码有责任正确处理它。
确保 WebServer 正确处理套接字错误,例如,当创建一个没有错误处理程序的服务器时,它将容易受到 DoS 攻击
const net = require('node:net');
const server = net.createServer(function (socket) {
// socket.on('error', console.error) // this prevents the server to crash
socket.write('Echo server\r\n');
socket.pipe(socket);
});
server.listen(5000, '0.0.0.0');
如果执行错误请求,服务器可能会崩溃。
一个并非由请求内容引起的 DoS 攻击示例是 Slowloris。在这种攻击中,HTTP 请求发送缓慢且碎片化,一次发送一个片段。在完整请求传递之前,服务器将保持专用于正在进行的请求的资源。如果同时发送足够多的此类请求,并发连接的数量将很快达到其最大值,从而导致拒绝服务。这就是攻击如何不依赖于请求的内容,而是依赖于发送到服务器的请求的定时和模式。
缓解措施
- 使用反向代理来接收请求并将其转发到 Node.js 应用程序。反向代理可以提供缓存、负载平衡、IP 黑名单等,从而降低 DoS 攻击有效的可能性。
- 正确配置服务器超时,以便可以丢弃空闲或请求到达速度太慢的连接。请参阅
http.Server
中的不同超时,特别是headersTimeout
、requestTimeout
、timeout
和keepAliveTimeout
。 - 限制每个主机和总共打开的套接字数。请参阅 http 文档,特别是
agent.maxSockets
、agent.maxTotalSockets
、agent.maxFreeSockets
和server.maxRequestsPerSocket
。
DNS 重绑定 (CWE-346)
这是一种可以针对使用 --inspect 开关 启用调试检查器的 Node.js 应用程序的攻击。
由于在 Web 浏览器中打开的网站可以发出 WebSocket 和 HTTP 请求,因此它们可以针对本地运行的调试检查器。这通常受到现代浏览器实现的 同源策略 的阻止,该策略禁止脚本访问来自不同来源的资源(这意味着恶意网站无法读取从本地 IP 地址请求的数据)。
但是,通过 DNS 重绑定,攻击者可以暂时控制其请求的来源,以便它们看起来源自本地 IP 地址。这是通过控制网站和用于解析其 IP 地址的 DNS 服务器来完成的。有关更多详细信息,请参阅 DNS 重绑定 wiki。
缓解措施
- 通过附加一个
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
,用于使用恒定时间算法比较实际和预期的敏感值。 -
对于密码比较,您可以使用 scrypt,它也可以在原生 crypto 模块上使用。
-
更普遍地说,应避免在可变时间操作中使用密钥。这包括基于密钥进行分支,以及当攻击者可能位于同一基础设施上(例如,同一云机器)时,使用密钥作为内存索引。在 JavaScript 中编写恒定时间代码很困难(部分原因是 JIT)。对于加密应用程序,请使用内置的加密 API 或 WebAssembly(对于本地未实现的算法)。
恶意第三方模块 (CWE-1357)
目前,在 Node.js 中,任何包都可以访问强大的资源,例如网络访问。此外,由于它们也可以访问文件系统,因此可以将任何数据发送到任何地方。
运行在 node 进程中的所有代码都可以通过使用 eval()
(或其等效项)来加载和运行其他任意代码。所有具有文件系统写入权限的代码都可以通过写入新文件或现有文件(这些文件会被加载)来实现相同的目的。
Node.js 具有一个实验性的¹ 策略机制,可以将加载的资源声明为不受信任或受信任。但是,此策略默认情况下未启用。请务必锁定依赖项版本,并使用常见的工作流程或 npm 脚本运行自动漏洞检查。在安装软件包之前,请确保该软件包已维护并且包含您期望的所有内容。请注意,GitHub 源代码并不总是与已发布的源代码相同,请在 *node_modules* 中验证它。
供应链攻击
当 Node.js 应用程序的依赖项(直接或传递)之一受到攻击时,就会发生供应链攻击。这可能是由于应用程序对依赖项的规范过于宽松(允许不必要的更新)和/或规范中的常见拼写错误(容易受到 域名抢注 攻击)。
控制上游包的攻击者可以发布包含恶意代码的新版本。如果 Node.js 应用程序依赖于该包,但没有严格限制哪个版本可以安全使用,则该包可能会自动更新到最新的恶意版本,从而危及应用程序。
在 package.json
文件中指定的依赖项可以具有精确的版本号或范围。但是,当将依赖项锁定到精确版本时,其传递依赖项本身不会被锁定。这仍然使应用程序容易受到不必要的/意外的更新。
可能的攻击向量
- 域名抢注攻击
- Lockfile 投毒
- 维护者被攻陷
- 恶意软件包
- 依赖混淆
缓解措施
- 使用
--ignore-scripts
阻止 npm 执行任意脚本- 此外,您可以使用
npm config set ignore-scripts true
全局禁用它
- 此外,您可以使用
- 将依赖项版本锁定到特定的不可变版本,而不是范围版本或来自可变源的版本。
- 使用 lockfile,它锁定每个依赖项(直接和传递)。
- 使用 Lockfile 投毒的缓解措施。
- 使用 CI 自动检查新漏洞,使用
npm-audit
等工具。- 诸如
Socket
之类的工具可用于使用静态分析来分析包,以查找有风险的行为,例如网络或文件系统访问。
- 诸如
- 使用
npm ci
而不是npm install
。这会强制执行 lockfile,以便它与 *package.json* 文件之间的不一致会导致错误(而不是默认使用 *package.json* 而忽略 lockfile)。 - 仔细检查 *package.json* 文件中依赖项名称的错误/拼写错误。
内存访问违规 (CWE-284)
基于内存或堆的攻击依赖于内存管理错误和可利用的内存分配器的组合。与所有运行时一样,如果您的项目在共享计算机上运行,Node.js 也很容易受到这些攻击。使用安全的堆有助于防止敏感信息由于指针溢出和欠载而泄漏。
不幸的是,Windows 上没有安全的堆。有关更多信息,请参见 Node.js secure-heap 文档。
缓解措施
- 根据您的应用程序使用
--secure-heap=n
,其中 *n* 是分配的最大字节大小。 - 不要在共享计算机上运行您的生产应用程序。
Monkey Patching (CWE-349)
Monkey patching 是指在运行时修改属性,旨在更改现有行为。示例
// eslint-disable-next-line no-extend-native
Array.prototype.push = function (item) {
// overriding the global [].push
};
缓解措施
--frozen-intrinsics
标志启用实验性的¹ frozen intrinsics,这意味着所有内置的 JavaScript 对象和函数都被递归冻结。因此,以下代码段**将不会**覆盖 Array.prototype.push
的默认行为
// eslint-disable-next-line no-extend-native
Array.prototype.push = function (item) {
// 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_, _constructor, prototype 以及从内置原型继承的其他属性,将属性修改或注入到 JavaScript 语言项目中的可能性。
const a = { a: 1, b: 2 };
const data = JSON.parse('{"__proto__": { "polluted": true}}');
const c = Object.assign({}, a, data);
console.log(c.polluted); // true
// Potential DoS
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-2022-21824 (Node.js)
- CVE-2018-3721 (第三方库: Lodash)
缓解措施
- 避免 不安全的递归合并,参见 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 最佳实践徽章计划 项目可以通过描述它们如何遵守每个最佳实践来自愿自我认证。这将生成一个可以添加到项目中的徽章。