最近我参加了一次前端开发岗位的面试,面试中涉及的安全要求比我准备的要深入得多。虽然我对安全问题和缓解方法比较熟悉,但在相关术语和方法上的准备还是不够充分。
这让我意识到,作为前端开发者,我们常常不够重视安全,以及我们在安全上的职责——因此,如果我们做得不到位,我们也脱不了干系。
但我觉得在这里讨论一下这些攻击途径可能很有用,并探讨它们在现代JavaScript为中心的网络中是如何运作的,以及如何减轻或消除这些攻击手段。
跨站脚本漏洞(XSS)将恶意脚本注入页面并执行,导致触发了未预期的行为。
一个更具体和较长的例子可能是一个消息板或评论区,它会在页面上展示用户的评论,让其他用户看到。如果用户的评论类似以下内容,实际上会在所有用户的屏幕上弹出一个警告。
<script>alert('网站被黑了哦')</script>
这还算温和的例子。运行一些 base64 编码的无意义数据,这种行为更有可能连接到并执行恶意软件文件,后果也更严重。
解决这些问题并不难,其中贯穿以下示例的原则是:永远不要信任用户输入。这意味着永远不要信任用户填写表单的内容——要清理它,并验证它以防止明显的非法操作。另外,不要直接展示用户提交的内容。这一点听起来可能很明显,但许多公司的研讨会曾因一本“留言簿”被改成纳粹宣传或其他更糟糕的内容而受到影响。
除了不直接显示用户的输入之外,始终使用内容显示的安全版本。这始终是默认行为。例如,在React中,使用{messageContent}
是安全的,但如果使用<div dangerouslySetInnerHTML={messageContent} />
,那就是不安全的。如果你之前不知道这是不安全的,可能值得考虑是否另一个字段更适合你。Vue虽然不那么明确警告,但{{messageContent}}
是安全的,而<div v-html="messageContent">
则非常不安全。
另一个缓解方法是CSP(内容安全策略)。我们很快会详细讨论这个话题。
说实话,除非你在做一些非常奇怪的事,否则 XSS 已经是一个“已经被解决的问题”。大多数现代工作流程都会在一开始就阻止它。
跨站请求伪造攻击让使用者不知不觉地发起一个本来没有打算发起的请求。这些攻击利用了真实用户的账号和有效的登录凭证。当你在网站上操作时,浏览器会自动发送 cookie,证明你的身份是合法的。
所以你在 SuperGoodBank.com 最终完成了一个个人贷款产品的申请,用于购买两个《星际战士》模型人偶,然后离开了网站。第二天你去 PrivateerHarbour.com 下载《Bring It On: Fight To The Finish》,却没看到页面中嵌入的图片链接实际上指向了这样一个网址:https://api.supergoodbank.com/api/transactions/transfer&amount=150&destination=666-237884-123&approved=true。
你的银行的API会收到那个URL,连同你的cookie一起。所以它会根据这些信息完成相应的交易。
你可能已经注意到,这实际上并不是一个前端的风险。实际上这是一个后端的风险,解决方案包括后端的修改,前端也需要配合。最好的解决方案是使用CSRF令牌。这个令牌会在每个会话中生成,并作为任何表单负载的一部分。没有有效的令牌,就没有有效的请求。
重要的是理解,这些请求及其对应的解决方案是基于表单——application/x-form-urlencoded
或 multipart-form-data
。
基于JavaScript的表单发送application/json
负载需要遵守不同的规则,但仍可能需要在请求中带上CSRF令牌。通常CORS要求会阻止JavaScript请求携带CSRF令牌。关于这一点,我们也会详细讨论。
大多数缓解措施都是在后端实现的——使用 SameSite 仅限的 cookies,对高风险请求实施 2FA 验证等。但是,作为前端开发者,我们应准备好在请求中根据需要添加必要的头部或字段。
点击劫持点击劫持攻击与跨站请求伪造攻击类似,但涉及将目标网站嵌入iframe中,诱使用户点击他们看不见的按钮,而实际上他们认为自己是在执行其他操作。例如,我认为自己点击确认在PrivateerHarbour网站上搜索《Bring It On》电影,但实际上,我点击的按钮实际是用来“确认转账”的,我的一大笔钱。
当然,该操作被执行了……你确实有一个有效的cookie,并且你点击了“把所有钱转给骗子的按钮”。
实际的黑客手段包括强迫用户“赞”他们从未见过的 Facebook 内容,或强制确认 PayPal 支付等等。
缓解这个问题并不难。你可以设置一个名为 X-Frame-Options
的 HTTP 头,如果将其设置为 DENY
,则应用程序将无法被嵌入或框架化。(如果你有内部使用的需要,可以使用 SAMEORIGIN
。)同样的道理适用于 CSP 设置:frame-ancestors ‘none’;
。我们稍后会更详细地讨论 CSP,因为我之前提到过。
这已经不是那么常见的向量了,但不常见了,好的解决方案通常需要访问托管或服务器的配置。有一些CSS和JavaScript技巧可以尝试让嵌入的页面至少可见,但这些并不是真正解决问题的办法。
SQL注入攻击(一种常见的安全漏洞)这也是一个主要在后端的问题,但我们至少可以在前端尝试缓解一下这个问题。最终这涉及到插入一大段SQL代码,希望后端不要太聪明,会不假思索地执行。
当你在某个字段中询问用户的名字时,你可以直接将它插入用户的名字字段,使用这样的命令:UPDATE users SET name = <userData.name> WHERE userID = 123
。
如果用户把名字设为这种代码:"REKD”; DROP TABLE users;
,那么最终的查询就会变成如下所示。
更新 users 表 SET 名称 = “RKD”; 删除表 users; WHERE 用户ID = 123
最后一部分什么也不做,但第一个部分会有效查询,用来更新每个人的名字。然后,第二个部分也会有效查询,用来彻底删除用户表。
这不太好。
阻止用户这样做其实很简单。任何ORM都会阻止这种行为。甚至底层库也提供了比如说预编译语句这样的功能,这些功能也会对输入进行清理。
作为前端开发人员,你可以至少做一些基本的验证,以防止明显的垃圾信息和/或黑客攻击传入服务器。限制字段为实际会使用的字符集,例如。
CSP(这个经常提到的)也可以帮忙,通过移除那些在有效载荷中变形的向量。
前端存储客户端本质上是不安全的。这意味着你可能用于存储的任何浏览器API都不可信。这包括localStorage和sessionStorage,这两个存储选项用户都可以看到并且可以修改。一个常见的例子就是在你使用SPA登录时,从后端发送回的JWT。
你读到的任何教程都会告诉你不要将 JWT 存储在本地存储中,但,我们在这次演示中就直接这样做了。
最大的问题在这里与上面提到的向量有关。XSS攻击会检查浏览器API(如localStorage)中是否存在正确的令牌,并将它附加到请求中,从而在后端安全中打开一个巨大的漏洞。
浏览器可以访问的任何 API,恶意脚本也可以访问。例如,这包括 localStorage、sessionStorage,甚至 IndexedDB,它们的数据可能被查看、复制或修改。
所以如何减少这种情况的影响。和其他项目一样,这主要涉及后端,然后将其与前端集成。你需要使用一个HttpOnly
cookie。这意味着客户端无法查看cookie的内容,只知道cookie的存在。服务器加载这个cookie,并从中提取JWT等数据,验证它并继续执行授权流程。
中间人攻击(MITM攻击)是一类广泛的攻击,在这种攻击中,数据在任何双方之间传输时被拦截或修改——比如用户和网站之间。这可能包括窃取敏感信息,例如会话cookie、JWT令牌或登录详细信息,或篡改交易详情。
存在多种类型的攻击。被动窃听只是监听,主要目的是查看登录凭证。这种攻击在不安全的网络中尤其危险,例如公共Wi-Fi。主动的中间人攻击则会篡改交易内容,例如更改交易接收者。一些形式的中间人攻击会移除SSL安全措施,让用户误以为正在使用HTTPS,但实际上数据是通过明文HTTP传输的。其他版本的攻击会伪造免费Wi-Fi热点或冒充DNS提供商。
如之前所见,有几种方法可以缓解这些问题,但后端承担主要责任。但作为前端应用,也有一些最佳实践可参考。最重要的是,所有应用,包括前端,都应始终使用 https,应始终启用 SSL。
我们之前提到的 HttpOnly cookies 也可以防止中间人攻击(MitM)获取 cookies 及其内容。还有一个 HTTP Strict Transport Security
标头,可以启用它来强制使用 HTTPS,阻止 HTTP 请求。通过将域名提交到 Google HSTS 预加载列表 https://hstspreload.org/,可以对此进行优化,确保任何尝试通过 HTTP 访问您网站的用户在导航之前就会被重定向,从而使第一次访问时的中间人攻击成为不可能。
JavaScript生态系统高度依赖于通过NPM加载的第三方模块。但是没有东西阻止攻击者甚至合法维护者出卖良心后插入恶意代码。
一个例子是2018年发生的event-stream攻击事件,一名黑客获得了npm上的event-stream
包的访问权限。他们随后向特定应用程序注入了一个包,该应用程序包含比特币账户信息和私钥,从而导致了未知数量的比特币被盗取。
供应链中还存在其他漏洞。CDNs、CI/CD 管道和被重命名或伪造的依赖项都可能是潜在的风险。
对此的缓解措施都是一样的——谨慎处理依赖关系。一般应避免使用内容分发网络(CDNs)。使用并提交package-lock.json
或yarn.lock
等文件可以防止依赖项被隐蔽更改,同样,明确固定依赖项也能起到作用。
有时候用户无需修改或拦截请求,甚至不需要访问localStorage,就能做到一些未被预期的操作。只需点击提交按钮两次,或者多次使用同一个50%的折扣码。
这不仅关乎安全,还关乎用户体验。我认为现在已经好很多了,但在互联网的早期,用户经常误以为需要双击按钮。这样的错误不应该导致订购两份相同的超级Sonico哥特式女仆玩偶模型。一份就已经足够了。
最关键的是,提交时要禁用按钮,防止重复提交。Uis 不能单纯依赖用户的输入。这个问题没有通用解决方案,因为它完全取决于具体的 app。不过作为开发者,这一点一定要注意。
大解决方案——这些缓解策略和解决方案中有一条共同的线索,那就是对服务器的访问。有时这指的是后端API,而在其他情况下,则指的是为前端提供服务的任何基础设施。我们可以实施两种主要解决方案,它们确实很有帮助。内容安全策略(CSP) 和 跨源资源共享(CORS)。
CSP,即内容安全策略(CSP)这将是一个更大范围的讨论,完全可以独立成为一篇文章。CSP 是一个名为 Content-Security-Policy
的特定头部(Header),它规定了该网站允许和不允许的内容。浏览器在加载该域名上的 JavaScript 应用程序时会读取该头部,并根据策略阻止相关活动。
这些头部数据通常都很长。
Content-Security-Policy:
default-src ‘self’;
script-src ‘self’ ‘unsafe-inline’ https://cdnjs.cloudflare.com;
style-src ‘self’ ‘unsafe-inline’ https://cdnjs.cloudflare.com;
img-src ‘self’ data: https://cdnjs.cloudflare.com;
font-src ‘self’ https://cdnjs.cloudflare.com;
connect-src ‘self’;
frame-ancestors ‘none’;
upgrade-insecure-requests;
这个示例是一个中等安全设置,做了所有通常的事情,但允许内嵌脚本,并允许来自 Cloudflare CDN 的数据通过,以使我们的 FontAwesome 图标正常工作。其他示例可能包括针对 Google 字体、Google 标签管理器或 Google 分析的例外情况。营销工具通常需要很多这样的例外情况,同样,一些广泛的第三方集成(例如)也是如此。
以上部分内容并不理想或并不完全必要。default-src
是其他策略的后备选项,因此当有 connect-src
时,它实际上可能并不必需。想象一下,如果没有 Cloudflare 的部分,我们可以省略掉大部分规则。
值得注意的是最后两条。frame-ancestors
指令阻止了点击劫持攻击和其他相关的框架利用攻击。upgrade-insecure-requests
指令强制始终使用 https,确保所有连接都是安全和加密的。将 script-src
指令设置为 'self' 可以阻止任何 XSS 错误。我们还可以设置一些更小的指令,例如 object-src 'none'
用于阻止可能包含漏洞的老旧的小程序,以及 base-uri 'self'
用于阻止任何人通过更改基础 URL 来劫持表单提交。还有,这些指令可以增强网站的安全性。
一般来说,作为一条坚实的规则,你可能希望从一个最小化的约束满足问题(约束满足问题,CSP)开始,然后尽可能少地修改它,以满足你的功能需求。
Content-Security-Policy:
default-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
一个常见的变更是 style-src ‘self’ ‘unsafe-inline;
,这将允许内联样式存在,这可能对于你的样式设置是必要的——例如使用 Styled Components
,等等。
CSP其实有点像一门黑魔法。在复杂的真实世界应用中,正确地配置CSP可能需要一些尝试和错误。有一个玩笑是CSP实际上应该代表“完全停止页面”(Completely Stops Pages),因为过度激进的CSP策略经常会以意想不到的方式破坏页面的功能或外观。
理解这一点很重要,这些指令并不是键和值,而是Content-Security-Policy
是键,其余的部分就是策略。所以应该这么写:
Content-Security-Policy: default-src 'self'; object-src 'none'; …
CSP 并不是像 React 这样的应用中的设置。如之前所说,它必须是服务器端。这可能涉及 NextJS 或 Remix(我们稍后再详谈),也可能是 SvelteKit,或者可能是通过类似 Digital Ocean 的托管服务中的反代配置。
像 Netlify 和 Vercel 这样的平台没有提供专门的 UI 来处理这种情况,但它们确实提供了内置支持。Netlify 支持一个 _headers
文件,你可以把相关内容放到这个文件中。对于 Vercel 来说,情况相似,它允许使用一个 vercel.json
文件来定义这些头部。
说到头部信息,CSP并不是这里唯一的有用头部信息。
Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Resource-Policy, Cross-Origin-Opener-Policy,和 Cross-Origin-Embedder-Policy 都是可以用来阻止上述大多数或所有问题的头部。我强烈建议你花点时间了解一下并正确设置这些头部。
这里有一个简单的 _headers
文件,包含了一些常见的安全设置。顺便提一句,/*
有其意义——它表示这些设置将应用到所有路由。
/*
内容安全策略:默认源、对象源等配置
Content-Security-Policy: default-src 'self'; object-src 'none';
base-uri 'self'; frame-ancestors 'none'; upgrade-insecure-requests;
传输安全策略:最大有效期为31536000秒,即一年,包括子域名,并预加载
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Frame-Options: DENY
内容类型选项:禁止嗅探
X-Content-Type-Options: nosniff
引用策略:严格源当跨源
Referrer-Policy: strict-origin-when-cross-origin
权限策略:禁止地理位置、麦克风、摄像头的使用
Permissions-Policy: geolocation=(), microphone=(), camera=()
跨源资源策略:同源策略
Cross-Origin-Resource-Policy: same-origin
跨源打开者策略:同源策略
Cross-Origin-Opener-Policy: same-origin
跨源嵌入者策略:要求企业级策略
Cross-Origin-Embedder-Policy: require-corp
跨源资源共享 (CORS 策略)
你会注意到,在我们的设置中,我们花了很多时间说“自身”,而在我们的跨源资源共享策略 (CORS) 中提到的是同源。浏览器希望仅限制对本地站点的访问——同源策略 (Same-Origin Policy, 简称 SOP)。显然,这样的限制确实带来了安全上的好处,但在实际操作中,这就变成了一个小问题:什么都无法正常运行。
实践中,尤其是在单页面应用中,我们经常会请求外部资源,比如 API 数据这样的请求。请注意,像 fetch
这样的库也包括在其中。从外部 API 获取数据?真是有点大胆!
CORS的目的是让服务器(注意——这里指的是服务器,而不是客户端)来指定哪些客户端可以访问它。
看来和往常一样,这是通过一个头信息来处理的。
Access-Control-Allow-Origin: https://frontend.com
# 此标头表示允许的源为 https://frontend.com
这意味着只有https://frontend.com能访问该资源。任何其他来源的请求都会被浏览器拒绝。实际上,人们通常不太注重安全性,因此这个头部信息是完全开放的。事实上,大多数时候我们这样设置CORS只是为了默认情况下避免一些麻烦。
访问控制允许来源: * (允许所有来源访问)
从浏览器和现代应用的角度来看,你对服务器的任何请求都必须返回一个浏览器能够接受的 CORS 头。这通过发送一个预检请求来检查,通常是一个 OPTIONS 请求。也就是说,它使用 OPTIONS HTTP 方法,而不是像 POST 或 GET 这样的方法。虽然这种类型的请求不常见,但它确实是有效的。
前置检查会发送一系列头部信息,检查请求的各项属性,包括其认证头和请求方法是否会得到服务器的认可。服务器会以如下形式回复,回复内容大致如下:
HTTP/1.1 204 没有内容
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization
Access-Control-Max-Age: 3600
基本上,这些方法允许任何URL和Authorization头。最后一个意思是,一小时内无需重发预检,直接采用当前设置即可。
理解CORS确实挺重要的,不过前端开发者不必太深入地去研究它。只要服务器配置好了,一切自然就能顺畅运作了。
掌握后端:理解并控制技术后端这里有一个反复出现的话题。你可以也应该做一些事情来保证前端安全。但是,如果你对服务器几乎没有或根本没有控制权,那么这些努力的效果就会递减。你对服务器的访问越多,无论是作为基础架构还是应用程序,你就可以更好地配置安全设置。
我之前稍微提过一点,但有一个很好的解决办法。我写过关于像 NextJS 这样的后端服务前端的安全性优点,这样的深入安全讨论正是一个好例子。
你可以有 nginx 配置来存储这类设置,但这种情况不太可能存在于你的代码仓库里,这使得转移或重新实现更加困难。对于一个复杂的 CSP,这可能比你想象的更棘手。我也有这样的经验。
_headers
文件是 Netlify 提供的一个不错解决方案,而 vercel.json
也提供了相同的功能。但是这些是基于供应商的解决方案,依赖于特定的托管服务。NextJS 及其 next.config.js
可以让你以直观且安全的方式设置很多功能。它允许你在其服务器上下文中存储一个 HttpOnly 标头,从而使 JWT 更加安全。或者由于服务器本身就是同源的,所以可以省略 CORS 的设置。你也可以直接在服务器上设置 CORS。你可以使用内置的速率限制来保护 API 免于过多请求。
共同學(xué)習(xí),寫下你的評論
評論加載中...
作者其他優(yōu)質(zhì)文章