一、踩坑前先搞懂两个基础概念

很多人做前端安全配置时,总觉得是“给代码加个规则”这么简单,结果加完发现完全不生效,甚至页面直接崩了。要解决后面的问题,得先把两个最核心的东西掰碎了说,别记术语,就按字面意思理解。

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: '/(.*)',
};

这里要注意几个点:

  1. 'unsafe-inline':如果你的页面里有内联的JS(比如直接写在script标签里的代码),必须加这个,不然浏览器会拦截。
  2. 'unsafe-eval':如果你的代码里用了eval函数,或者用了一些需要eval的库(比如旧版本的React),必须加这个。
  3. 第三方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里:

  1. 内联代码:加'unsafe-inline'(如果你不想加这个,后面会讲替代方案)。
  2. 外部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+做的项目,尤其是对安全要求比较高的项目,比如金融、电商、企业内部系统。比如:

  1. 电商项目:防止恶意脚本窃取用户的支付信息。
  2. 企业内部系统:防止页面被嵌入恶意内容,泄露企业数据。
  3. 有第三方统计、广告的项目:既能加载第三方资源,又能保证安全。

4.2 技术优缺点

优点:

  1. 自定义规则完全生效:不会被Next.js的默认规则覆盖。
  2. 安全等级高:可以不用'unsafe-inline',用nonce或hash来保证内联脚本的安全。
  3. 兼容性好:支持所有现代浏览器,就算是旧浏览器,也会忽略不认识的CSP规则,不会导致页面无法访问。 缺点:
  4. 配置麻烦:需要手动维护CSP规则,每次加新的第三方资源,都要修改规则。
  5. 调试困难:如果规则写错了,页面会加载不了,而且控制台的报错信息有时候不够明确,需要花时间排查。
  6. 对开发环境不友好:开发环境下,热更新会导致nonce变化,有时候会出现内联脚本加载失败的问题。

4.3 注意事项

  1. 不要随便加'unsafe-inline''unsafe-eval':这两个规则会降低安全等级,尽量用nonce或hash替代。
  2. 开发环境和生产环境的规则要分开:开发环境下,可以暂时加'unsafe-inline''unsafe-eval',方便调试;生产环境下,再换成安全的规则。
  3. 用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不是一劳永逸的事,需要根据项目的变化不断调整,但它能给你的项目带来实实在在的安全防护,防止恶意脚本攻击、数据泄露等问题。