一、踩坑前先搞懂两个基础概念
很多人做前端安全配置时,总觉得是“给代码加个规则”这么简单,结果加完发现完全不生效,甚至页面直接崩了。要解决后面的问题,得先把两个最核心的东西掰碎了说,别记术语,就按字面意思理解。
1.1 什么是安全响应头?
你可以把它理解成网站给浏览器发的“安全说明书”。比如你去餐厅吃饭,服务员会给你一张纸,上面写着“不能带明火进后厨”“餐具必须消毒”,这就是餐厅给后厨的规则;而安全响应头就是网站给浏览器的规则,比如“只能加载这个网站自己的JS”“不能随便跳去别的网站”,浏览器拿到这个说明书,就会按规则来限制页面的行为。
1.2 什么是CSP?
CSP是安全响应头里最常用的一种,专门管“资源加载”的规则。比如你页面里的JS、图片、CSS、字体,这些都是“资源”,CSP就是告诉浏览器:“只能从哪些地方加载这些资源”。比如你想让页面只加载自己服务器的JS,CSP就会写“script-src 'self'”,意思是“JS只能从自己的服务器来”。
二、为什么Next.js里自定义CSP会被覆盖?
很多人刚用Next.js做项目时,会直接在自己的服务器(比如Nginx、Node.js的Express)里加CSP规则,结果发现页面加载不了,一查浏览器的控制台,发现自己加的CSP规则没了,换成了Next.js自带的规则。这到底是怎么回事?
2.1 Next.js自带的安全策略是什么?
Next.js是个全栈框架,它自己有一套默认的安全规则,目的是让新手不用配置也能有基本的安全防护。比如它会自动加一些CSP规则,还会处理一些敏感资源的加载。但问题就在这:Next.js的这套规则,会在你自己的服务器规则之后,把它覆盖掉。 举个例子,你在Nginx里加了CSP规则“script-src 'self'”,然后Next.js自己又加了一套CSP,比如“script-src 'self' 'unsafe-inline'”,最后浏览器拿到的CSP就是Next.js的,你的规则直接被顶没了。
2.2 核心原因:Next.js的中间件和App Router的规则优先级
Next.js里有两个地方会生成安全响应头:一个是中间件(middleware.js),另一个是App Router里的“metadata”配置。这两个地方的规则优先级,比你在自己服务器加的规则高太多了。 比如你用Express做后端,加了CSP规则,然后Next.js的中间件里又生成了一套新的CSP,这时候浏览器收到的是两套规则吗?不是,只有最后一个生效。浏览器的规则是“谁最后来,听谁的”,而Next.js的规则是在你服务器的规则之后才发出去的,所以你的规则就被覆盖了。
三、正确配置CSP的步骤(附完整示例)
现在知道了问题所在,我们就可以一步步来配置,让自己的CSP规则生效,还能解决非内联脚本加载的问题。这里我们用Next.js 13+的App Router,因为现在大部分新项目都用这个,示例也会用这个版本的配置。
3.1 第一步:先禁用Next.js的默认安全规则
首先,我们要把Next.js自带的会覆盖我们规则的配置关掉,不然我们自己加的规则还是会被顶。 在Next.js的配置文件next.config.js里,加一个配置:
/** @type {import('next').NextConfig} */
const nextConfig = {
// 禁用Next.js自动生成的安全响应头,避免覆盖自定义规则
headers: async () => {
return [
{
source: '/(.*)', // 所有路径都生效
headers: [
{
key: 'Content-Security-Policy',
value: '', // 清空Next.js的默认CSP规则
},
],
},
];
},
};
module.exports = nextConfig;
这里的作用是,让Next.js给所有页面都发一个空的CSP规则,这样它就不会生成自己的规则来覆盖我们的了。
3.2 第二步:在中间件里配置自定义CSP规则
接下来,我们在中间件里加自己的CSP规则。中间件的作用是,在页面加载之前,先给浏览器发安全规则,而且这个规则的优先级最高,不会被覆盖。 在项目根目录新建middleware.js文件,内容如下:
// middleware.js:Next.js的中间件,用于自定义安全响应头
import { NextResponse } from 'next/server';
// 自定义CSP规则,这里可以根据自己的需求修改
const CSP_RULES = [
"default-src 'self'", // 默认规则:所有资源只能从自己服务器加载
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net", // JS可以从自己服务器、内联、eval、指定CDN加载
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", // CSS可以从自己服务器、内联、指定CDN加载
"img-src 'self' data: https://*.unsplash.com", // 图片可以从自己服务器、base64、unsplash的所有子域名加载
"font-src 'self'", // 字体只能从自己服务器加载
"connect-src 'self' https://api.xxx.com", // 接口请求只能从自己服务器、指定API域名加载
"frame-src 'none'", // 禁止页面嵌入其他网站的内容
];
// 把规则拼接成字符串,每个规则用分号分隔
const CSP_STRING = CSP_RULES.join('; ');
export async function middleware(request) {
// 给响应添加自定义CSP规则
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', CSP_STRING);
// 还可以加其他安全响应头,比如X-Frame-Options
response.headers.set('X-Frame-Options', 'DENY');
return response;
}
// 配置中间件的生效范围:所有路径都生效
export const config = {
matcher: '/(.*)',
};
这里要注意几个点:
'unsafe-inline':如果你的页面里有内联的JS(比如直接写在script标签里的代码),必须加这个,不然浏览器会拦截。'unsafe-eval':如果你的代码里用了eval函数,或者用了一些需要eval的库(比如旧版本的React),必须加这个。- 第三方CDN:如果你要加载其他网站的JS、CSS,必须把那个网站的域名加进去,比如上面的https://cdn.jsdelivr.net。
3.3 第三步:解决非内联脚本的加载问题
很多人加了CSP之后,发现自己页面里的外部JS(比如第三方统计、广告代码)加载不了,这是因为这些JS是“非内联脚本”,而且它们的域名没加在CSP的script-src里。 举个例子,你要加一个百度统计的代码,代码如下:
<script>
var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?123456";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
这段代码里,有两部分:一部分是内联的JS(就是外面的大括号里的代码),另一部分是动态加载的外部JS(https://hm.baidu.com/hm.js?123456)。 要让这段代码生效,你需要把两个东西加进CSP的script-src里:
- 内联代码:加
'unsafe-inline'(如果你不想加这个,后面会讲替代方案)。 - 外部JS的域名:加
https://hm.baidu.com。 修改后的CSP规则如下:
const CSP_RULES = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://hm.baidu.com",
// 其他规则不变
];
这样百度统计的代码就能正常加载了。
3.4 第四步:不用'unsafe-inline'的安全方案(进阶)
很多人担心'unsafe-inline'会带来安全风险,因为如果有人在你的页面里注入了恶意的内联JS,浏览器也会执行。那有没有办法不用'unsafe-inline',又能加载内联脚本?
有的,用“nonce”(一次性随机数)或者“hash”(哈希值)。
方案一:用nonce
nonce是一个随机生成的字符串,每次页面加载都会变。你把这个字符串加进CSP的script-src里,同时把它写在你的内联script标签里,只有匹配的内联脚本才会被执行。 修改middleware.js,生成nonce:
import { NextResponse } from 'next/server';
import crypto from 'crypto'; // Node.js的加密模块,用来生成随机数
export async function middleware(request) {
// 生成一个随机的nonce,每次请求都不一样
const nonce = crypto.randomBytes(16).toString('hex');
// 把nonce加进CSP规则
const CSP_RULES = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://cdn.jsdelivr.net https://hm.baidu.com`,
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
"img-src 'self' data: https://*.unsplash.com",
"font-src 'self'",
"connect-src 'self' https://api.xxx.com",
"frame-src 'none'",
];
const CSP_STRING = CSP_RULES.join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', CSP_STRING);
// 把nonce存在响应里,方便页面里获取
response.cookies.set('nonce', nonce, {
httpOnly: true, // 只有服务器能读取,前端不能读取,防止泄露
secure: process.env.NODE_ENV === 'production', // 生产环境用HTTPS时才加
sameSite: 'strict',
});
return response;
}
export const config = {
matcher: '/(.*)',
};
然后在页面里,把nonce加进script标签:
// app/page.js
import { cookies } from 'next/headers';
export default async function Home() {
// 从cookie里获取nonce
const nonce = cookies().get('nonce')?.value;
return (
<div>
<h1>Hello Next.js</h1>
{/* 内联脚本加nonce属性,只有匹配的才会被执行 */}
<script nonce={nonce}>
console.log('内联脚本执行了');
</script>
</div>
);
}
这样就不用加'unsafe-inline'了,因为只有带正确nonce的内联脚本才会被执行,恶意的脚本就算注入进来,也会因为没有正确的nonce而被拦截。
方案二:用hash
如果你的内联脚本是固定的(比如每次加载都一样),可以用hash。你先算出内联脚本的哈希值,然后把这个哈希值加进CSP的script-src里,只有哈希值匹配的内联脚本才会被执行。 举个例子,你的内联脚本是:
console.log('内联脚本执行了');
你可以用工具算出它的SHA-256哈希值,比如用这个命令:
echo -n "console.log('内联脚本执行了')" | openssl dgst -sha256 -binary | base64
假设算出的哈希值是abc123,然后把这个哈希值加进CSP规则:
const CSP_RULES = [
"default-src 'self'",
"script-src 'self' 'sha256-abc123' 'unsafe-eval' https://cdn.jsdelivr.net https://hm.baidu.com",
// 其他规则不变
];
这样这个固定的内联脚本就能被执行,其他不匹配的内联脚本会被拦截。
四、配置的应用场景、优缺点和注意事项
4.1 应用场景
这套配置方案适合所有用Next.js 13+做的项目,尤其是对安全要求比较高的项目,比如金融、电商、企业内部系统。比如:
- 电商项目:防止恶意脚本窃取用户的支付信息。
- 企业内部系统:防止页面被嵌入恶意内容,泄露企业数据。
- 有第三方统计、广告的项目:既能加载第三方资源,又能保证安全。
4.2 技术优缺点
优点:
- 自定义规则完全生效:不会被Next.js的默认规则覆盖。
- 安全等级高:可以不用
'unsafe-inline',用nonce或hash来保证内联脚本的安全。 - 兼容性好:支持所有现代浏览器,就算是旧浏览器,也会忽略不认识的CSP规则,不会导致页面无法访问。 缺点:
- 配置麻烦:需要手动维护CSP规则,每次加新的第三方资源,都要修改规则。
- 调试困难:如果规则写错了,页面会加载不了,而且控制台的报错信息有时候不够明确,需要花时间排查。
- 对开发环境不友好:开发环境下,热更新会导致nonce变化,有时候会出现内联脚本加载失败的问题。
4.3 注意事项
- 不要随便加
'unsafe-inline'和'unsafe-eval':这两个规则会降低安全等级,尽量用nonce或hash替代。 - 开发环境和生产环境的规则要分开:开发环境下,可以暂时加
'unsafe-inline'和'unsafe-eval',方便调试;生产环境下,再换成安全的规则。 - 用CSP的报告模式:如果你的项目比较大,第一次配置CSP的时候,可以先加
report-uri规则,让浏览器把违反CSP的请求报告给你,你再慢慢调整规则,不会导致页面直接崩了。 报告模式的配置如下:
const CSP_RULES = [
"default-src 'self'",
"script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://cdn.jsdelivr.net https://hm.baidu.com",
"report-uri /api/csp-report", // 把违反CSP的请求报告到这个接口
];
然后你可以写一个接口来接收报告,分析哪些资源加载失败了。 4. 定期检查规则:项目上线后,要定期检查CSP规则,去掉不需要的域名,避免安全风险。
五、总结
在Next.js里配置自定义CSP,核心就是搞清楚“规则优先级”,先禁用Next.js的默认规则,再在中间件里加自己的规则,最后解决非内联脚本的加载问题。如果不想用'unsafe-inline',可以用nonce或hash来保证安全。
配置CSP不是一劳永逸的事,需要根据项目的变化不断调整,但它能给你的项目带来实实在在的安全防护,防止恶意脚本攻击、数据泄露等问题。
评论
围绕“安全响应头在 Next.js 中屡被框架自带策略覆盖,自定义 CSP 与非cement脚本加载的正确姿势”参与讨论