上个月在给一个Next.js项目做上线前的安全加固,清单里列着一项“补充CSP响应头”。我当时觉得这活儿简单,在next.config.ts里加一段 headers,把Content-Security-Policy塞进去,部署完一刷新——页面样式全崩,控制台里刷屏“Refused to execute inline script”。更麻烦的是,这种报错在本地开发环境几乎复现不出来,因为next dev和生产构建的脚本加载流程并不一致。后来我把一个叫 next-csp 的开源库的源码从头到尾读了一遍,才真正搞懂 CSP nonce 在 Next.js 里应该怎么传。这篇文章就把那次的排查过程,以及我从 next-csp 源码里提炼出的实现思路完整写下来,适合正在给 Next.js 项目配 CSP、而且不想只靠“响应头写死”来糊弄安全清单的开发者。
1. 一个“加个响应头就以为完事”的案例:nonce的真正难点在哪
1.1 我用 next.config.ts 配置CSP后的现场
当时的配置大概长这样:
// next.config.ts const nextConfig = { async headers() { return [ { source: '/:path*', headers: [ { key: 'Content-Security-Policy', value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" } ] } ] } } export default nextConfig本地一跑,看似正常。为什么本地正常?因为开发模式下的热更新脚本加载路径和生产模式差异很大,而且浏览器对本地localhost的CSP报错通常会弱化展示。等上了生产环境,打开页面能看到HTML渲染出来了,但页面交互全部失灵,控制台里一堆violation,指向的都是Next.js自己生成的内联脚本。
Next.js生产环境HTML里至少有三类内联脚本绕不开:
- 页面初始数据:
self.__next_f.push([...])这类代码。 - Hydration相关的引导脚本。
- 通过
next/script的 inline 模式插入的代码。
这些脚本在构建时生成,构建器并不知道未来某个访客请求会拿到什么 nonce。所以想让它们通过CSP,不能靠“构建时写死”,必须让“请求到达时生成的一个随机数”能注入到这些脚本标签上。这就是标题里说的 nonce 传递问题。
1.2 为什么“同一次请求里要有同一个、且每次都不同的nonce”
CSP nonce 的完整称呼是number used once,一次性随机数。它存在的意义就是:服务器在每次响应时生成一个随机值,浏览器只执行带有这个随机值的<script>标签。攻击者即使能注入一段脚本,也猜不到当前请求的 nonce,因此无法通过CSP的检查。
可以理解为一张一次性的入场券。票面编号每次请求都变,持票人只能在当次请求里使用。如果你把 nonce 写死在next.config.ts的静态headers里,所有用户拿到的是同一张票,攻击者只要抓一次包就能拿到票面值,然后把它写进自己注入的脚本里,CSP等于没配。
真正有效的nonce需要满足两个条件:一是同一次请求中,响应头里的 nonce 与 HTML 内联脚本标签上的 nonce 完全一致;二是请求之间 nonce 不能复用。这两点,决定了 nonce 必须在请求到达时生成,并通过某种方式同时传给“响应头”和“页面模板”。
1.3 Next.js的inline bootstrap脚本是绕不开的关键
很多人一开始配CSP只想着“放行外部脚本”,把script-src配成'self'就觉得万事大吉。但实际上 Next.js 的 hydration 依赖大量内联脚本,这些脚本只能靠 nonce 或 hash 来放行。hash 方案需要预计算脚本内容摘要,而 Next.js 每次构建的脚本内容都可能变化,维护成本极高,因此 nonce 是社区里更通用的选择。
于是问题变成:Nonce 在请求时生成,但 Next.js 生成 HTML 的过程发生在请求处理流程中的某个环节。怎么把 nonce 塞进那个环节?这也是 next-csp 这个库存在的原因。
2. next-csp解决问题的思路:nonce的完整传递链路
2.1 Middleware是唯一“每请求一次”的执行入口
Next.js 里有个很特别的设计:Middleware(中间件)会在每个请求到达页面组件之前执行一次。它既能读取请求,也能修改响应。对于 CSP nonce 这种“每请求生成一次”的需求来说,Middleware 是天然的执行入口。
在 Middleware 里生成 nonce 之后,要同时做两件事:第一,把它写到请求头里,传给后面的页面渲染流程;第二,把它拼进 CSP 响应头里,随响应下发。这样,浏览器拿到的 CSP 策略与页面脚本上的 nonce 才能对齐。
next-csp 的核心思路就是围绕这个链路展开的。源码里最值得看的部分,不是它生成了多大的策略字符串,而是它如何处理 nonce 在“请求头、响应头、页面组件”三个位置之间的一致性。
2.2 请求头传值、响应头下发:两条路径的分工
先看一个我在还原 next-csp 核心路径时写的示例:
// middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { const nonce = crypto.randomUUID() const requestHeaders = new Headers(request.headers) requestHeaders.set('x-csp-nonce', nonce) const cspHeader = [ "default-src 'self'", `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "font-src 'self' data:", "connect-src 'self'", "base-uri 'self'", "form-action 'self'" ].join('; ') const response = NextResponse.next({ request: { headers: requestHeaders } }) response.headers.set('Content-Security-Policy', cspHeader) return response }这里有两行是灵魂:
requestHeaders.set('x-csp-nonce', nonce):把随机数塞进请求头,页面组件里通过headers()读取。response.headers.set('Content-Security-Policy', cspHeader):把带同一个随机数的CSP策略放进响应头。
一环是“内部告知”,一环是“外部约束”。很多人配CSP失败,是因为只做了响应头那一半,页面脚本上根本没有对应的 nonce 属性;或者只在页面里生成了 nonce,响应头里却没有,浏览器直接判定所有带这个 nonce 的脚本来源不受信任。
2.3 链路中哪些环节不能出错
从 Middleware 生成 nonce 到浏览器放行内联脚本,中间有四个环节必须全部对齐:
- Middleware 生成 nonce,写入请求头。
- 页面组件(或
_document)读取该请求头,拿到 nonce。 - 生成的 HTML 中的每个内联
<script>都带有nonce="这个值"。 - 响应头 CSP 中
script-src 'nonce-这个值'与 HTML 中的 nonce 完全一致。
任何一个环节掉链子,浏览器都会报 nonce 不匹配或者脚本被拒绝。这也是为什么排查CSP问题时,我最常做的第一件事不是看配置项,而是直接查看渲染出来的HTML源码,确认脚本标签上是否真的有nonce属性。
3. 源码拆解:nonce从生成到注入脚本的关键实现
3.1 生成nonce:randomUUID与randomBytes的取舍
next-csp 源码里生成 nonce 的部分,通常直接用crypto.randomUUID()。这个 API 在 Middleware 的运行时环境里可用,每次调用返回一个全局唯一的随机UUID,格式统一,调试时也方便。
也有项目用crypto.randomBytes(16).toString('base64'),生成一个更长的随机字符串。两者安全性都足够,差别在于:
| 方案 | 长度 | 可读性 | 适用场景 |
|---|---|---|---|
crypto.randomUUID() | 36字符 | 高,格式固定 | 通用场景,调试方便 |
crypto.randomBytes(16).toString('base64') | 约24字符 | 低,字符串较乱 | 对头部长短敏感或需要更高熵的场景 |
我在一个压测项目里对比过,二者对性能基本没有影响,随机性上也都满足CSP的要求。关键不是选哪个,而是保证“同一请求内只用同一个值”,不要在 Middleware 里生成一次、在页面组件里又生成一次。
3.2 把nonce挂到Script组件和inline script上
拿到 nonce 之后,怎么把它注入到脚本标签,是 next-csp 源码里最容易被忽略、也最值得学的部分。对于next/script引入的外部脚本,直接传nonce属性:
import Script from 'next/script' <Script src="https://example.com/analytics.js" nonce={nonce} strategy="afterInteractive" />对于自己写的内联脚本,需要通过Script组件的 inline 模式或者dangerouslySetInnerHTML的方式插入,并同样加上nonce属性:
<Script id="my-inline" strategy="afterInteractive" nonce={nonce} dangerouslySetInnerHTML={{ __html: `window.__CONFIG__ = ${JSON.stringify(config)}` }} />注意,这里有个容易踩的坑:如果你直接写<script>{code}</script>,Next.js 会把它当作 React 组件里的一个普通元素处理。涉及注入的脚本,官方推荐dangerouslySetInnerHTML方式,否则代码内容可能会被 React 转义或特殊处理,导致脚本执行结果不符合预期。
3.3 App Router下用headers()读取nonce的写法
在 App Router 下,页面组件是异步的,读取请求头的方式如下:
// app/layout.tsx import { headers } from 'next/headers' export default async function RootLayout({ children }: { children: React.ReactNode }) { const headerStore = await headers() const nonce = headerStore.get('x-csp-nonce') ?? '' return ( <html lang="zh-CN"> <body> {children} <Script src="https://example.com/analytics.js" nonce={nonce} strategy="afterInteractive" /> </body> </html> ) }这里有个重要细节:从 Next.js 15 开始,headers()返回的是 Promise,必须await。早期版本同步调用,升级后很多人在这里报类型错误。而一旦在根布局里调用了headers(),这个页面就会变成动态渲染,不再被静态优化。这个取舍后面专门讲。
3.4 meta标签兜底方案的限制:不只是替代响应头那么简单
有人在社区里推荐的“省事方案”是直接用<meta httpEquiv="Content-Security-Policy" content="...">把CSP写进 HTML 里,而不是设响应头。next-csp 源码里没有走这条路,因为 meta 兜底有几个硬伤:
frame-ancestors、sandbox、report-uri这些指令在 meta 标签里不生效。- meta CSP 与响应头 CSP 同时存在时,浏览器会采用两者规则的“最严格交集”,很容易出现“明明都配了,但更严的生效了”的现象。
- report-only 模式必须走响应头,meta 里写不了
Content-Security-Policy-Report-Only。 - 更关键的是,meta 标签里的 nonce 必须在生成 HTML 时就已经写死。对于纯静态页面,这个 nonce 对所有访客都一样,等于没有。
所以 next-csp 一直坚持“Middleware 生成 nonce + 响应头下发”这条路,不是因为它更复杂,而是因为这条路才真正满足 CSP 的随机性要求。
4. 渲染模式一换,nonce就得换一套思路:SSG / SSR / Streaming / PPR
4.1 SSG静态页面:没有请求就没有nonce
静态生成(SSG)页面在构建时生成 HTML,这时根本没有“请求”概念,也没有运行时 Middleware 给它传值。如果你在页面组件里调用headers()去读 nonce,Next.js 会把该页面标记为动态渲染,等于放弃了静态生成。
那纯 SSG 页面想配 CSP nonce 怎么办?我的实测结论是:不建议在纯 SSG 页面里依赖 nonce 放行内联脚本,因为构建时写死一个 nonce 放进 HTML,Middleware 再在响应头里用同一个值,所有访客拿到的脚本信任令牌就是固定的,攻击者只要抓一次包就能复用。如果业务要求必须静态化,要么改用 hash 方案,要么接受更严格的限制,把脚本全部外置并用'self'覆盖。
4.2 SSR:header链路最顺畅
SSR 场景下,每次请求都会重新执行页面组件,headers()能拿到 Middleware 写入的x-csp-nonce。这是 next-csp 默认最顺的场景:Middleware 生成随机数,页面组件同步读取并注入脚本,响应头下发的也是同一个值,链路完整,随机性有保障。
之前看到有人问“Middleware 和getServerSideProps有什么区别”,其实在 CSP nonce 这件事上,Middleware 的优势在于它能在页面渲染之前统一处理请求头,而getServerSideProps里的req只能读到已经存在的请求头,你没法在页面里把 nonce 写回请求头再继续往下传。所以 nonce 的“源头”必须在 Middleware,不能是页面逻辑。
4.3 Streaming与Suspense:nonce要覆盖被推迟执行的脚本
App Router 默认支持 Streaming。页面可以先输出一部分内容,再慢慢把剩余部分推给浏览器。有人担心:CSP 响应头已经发出去了,后面流式输出的脚本还能用 nonce 吗?
答案是可以。CSP 策略在响应头里,作用于整份文档。浏览器只要在解析到带 nonce 的脚本标签时,核对响应头里的 nonce 值一致即可,不要求 nonce 在最开头就出现。所以 Streaming 场景下,只要 Middleware 生成的 nonce 在整个请求生命周期内保持不变,后续流出的所有内联脚本都能正常放行。
不过要注意,如果你在 Streaming 的某个子组件里再去调用headers()读取 nonce,理论上没问题,但会让整个渲染链路依赖动态上下文,调试时容易糊涂。我的建议是 nonce 只在根布局或_document读一次,然后通过 props 或 Context 往下传。
4.4 PPR:缓存与动态nonce的边界
Partial Prerendering(PPR)把页面拆成静态外壳和动态内容。静态外壳提前渲染,动态内容请求时再流式输出。这里有个隐蔽的问题:静态外壳里的 nonce 是在构建时就确定的,它不会随请求变化。
如果你在布局里读取 nonce 并注入到静态外壳里的某个脚本,那 PPR 场景下的 nonce 就是固定的,与 SSG 有类似的风险。要让 nonce 保持动态,需要把依赖 nonce 的脚本放到动态渲染的组件里,让它在请求时重新生成。这就引出一个架构层面的斟酌:PPR 的核心收益是静态外壳的快速返回,但 CSP nonce 的动态性要求会侵蚀一部分静态边界,两者需要专门平衡。
4.5 四种模式对比表格
| 渲染模式 | nonce来源 | 是否每请求唯一 | 注意点 |
|---|---|---|---|
| SSG | 构建时固定 | 否 | 内联脚本的nonce形同虚设,不建议 |
| SSR | Middleware生成 | 是 | 最推荐的路经,链路完整 |
| Streaming | Middleware生成 | 是 | 同一请求内nonce保持不变即可 |
| PPR | 静态外壳固定/动态组件动态 | 部分 | 静态部分与动态部分的nonce边界要分清楚 |
5. 从源码读出来的安全细节,以及我踩过的坑
5.1 strict-dynamic不是万能钥匙
在配上'strict-dynamic'之后,脚本的信任规则会发生变化:浏览器会忽略'self',只信任带 nonce 的脚本,以及这些被信任脚本动态加载的其他脚本。这对“防注入”来说更严格了,但它不是万能钥匙。
举一个我踩过的例子:有个第三方的数据上报脚本,初始 snippet 我给了 nonce,但它内部会去加载另一个子脚本,而这个子脚本走了独立的加载逻辑,没有经过受信任脚本的链式加载,结果在线上被拦了。排查了半天,最后发现是这个第三方脚本用document.write或者在 shadow DOM 里动态创建标签,绕过了 strict-dynamic 的信任链传递。
在 strict-dynamic 下,外部脚本的加载必须严格从“带 nonce 的脚本”链式展开。第三方 SDK 如果不兼容这个约束,就需要单独评估,必要时给它单独配置 host 源,而不是盲目依赖 nonce。
5.2 上线前用Report-Only模式试跑一周
next-csp 源码里有不少关于报告模式的配置,我后来也养成了习惯:大版本 CSP 调整先不直接强制,而是用Content-Security-Policy-Report-Only先放行,把违规上报到一个接收端,观察一到两周再收紧。
配置方式很简单,把响应头从Content-Security-Policy换成Content-Security-Policy-Report-Only,再配合report-uri或report-to指定接收地址。注意,meta 标签方式不支持 report-only,所以必须走 Middleware 响应头。上线前跑报告模式,能提前发现哪些第三方脚本会被误伤,比强制拦截后收到一堆工单要省心得多。
5.3 几个关于CSP nonce的常见误解
我在技术群里看到过不少关于CSP nonce的误解,这里集中列一下:
- “nonce 只要生成一次就行。”不行,nonce 的价值就在于每次请求都不同。复用 nonce 等于给攻击者准备了可复用的通行证。
- “有 nonce 就不需要写
'unsafe-inline'了。”对也不对。现代浏览器在策略中存在 nonce 或 hash 时会忽略'unsafe-inline',这是CSP3规范。所以配置里写不写'unsafe-inline'对现代浏览器差别不大,但最好不要写,避免在一些边缘浏览器里被当作允许内联。 - “外部脚本不受CSP影响。”错。外部脚本同样需要匹配
script-src里的来源列表。nonce 只是放行内联脚本的一种手段,外部脚本的域名必须在script-src白名单里。 - “用了 nonce 就一定能拦截所有XSS。”nonce 能拦截大部分脚本注入,但若攻击者能将代码注入到带 nonce 的脚本内容里,或者页面本身把关不严,仍然有绕过的可能。nonce 是CSP里很重要的一块,不是全部。
5.4 构建产物优化会不会动我的nonce
还有一个让我疑惑了很久的问题:自己写在next/script里的 nonce,在生产构建之后会不会被压缩或移除?
实测下来,Next.js 构建过程不会移除 script 标签的 nonce 属性。生产HTML里能看到<script src="..." nonce="c4d..."></script>这样的输出。但对于内联脚本,如果它被 Next.js 的构建优化做了合并或转换,nonce 属性仍然会保留,因为浏览器校验的是标签属性,而不是脚本内容,只要属性在就有效。
不过要注意,如果代码里用的是dangerouslySetInnerHTML手动拼 HTML 字符串,而不是走next/script,那就得自己保证每一个内联 script 都带 nonce。构建工具不会帮你自动补。这也是我后来习惯统一用next/script来管理所有脚本的原因,省心。
6. 最小可用自主实现:不引入库也能跑通的裁剪版
6.1 middleware里的生成与下发
如果不引库,自己实现一个最小可用的 nonce 链路,其实就三步:Middleware 生成 nonce 并下发响应头,页面读取请求头注入脚本,验证两边一致。
Middleware 部分上面已经贴过完整代码。这里补充几个容易忽略的配置细节:
- Middleware 默认会匹配所有路由,包括静态资源和图片。最好加个
config.matcher,只对需要 CSP 的页面路径生效,避免对每个图片请求都跑一遍额外逻辑。 - nonce 不要写在日志里。Middleware 打印日志时如果带上 nonce,等于把通行证写进了日志系统,不建议这么做。
- 在多实例部署或 Serverless 环境里,Middleware 每次执行都是独立上下文,randomUUID 没有任何跨请求状态,天然适合这种场景,不需要额外处理。
6.2 Server Component读取nonce并注入脚本
// app/layout.tsx import { headers } from 'next/headers' import Script from 'next/script' export default async function RootLayout({ children }: { children: React.ReactNode }) { const headerStore = await headers() const nonce = headerStore.get('x-csp-nonce') ?? '' return ( <html lang="zh-CN"> <body> {children} <Script id="noop" strategy="afterInteractive" nonce={nonce}> {`window.__APP_VERSION__ = '1.0.0';`} </Script> </body> </html> ) }Pages Router 下则是在_document.tsx里读ctx.req?.headers['x-csp-nonce']。两边拿到的值应该与响应头里的 nonce 一致。如果打开页面发现 HTML 里的 nonce 是空字符串,先确认 Middleware 有没有正确匹配该路径,再确认请求头是否被某些缓存层吞掉了。
我这里说的是最基础的逻辑。实际项目中,页面可能有很多子组件都需要加载脚本,建议把 nonce 通过 Context 提供出去,而不是每个组件都去调一次headers(),那样既浪费又有可能导致动态渲染范围扩大。
6.3 完整验证步骤和错误现象对照
部署之后,别急着关工单,按下述步骤过一遍:
- 浏览器开发者工具打开 Network,查看主文档响应头,确认
Content-Security-Policy里存在'nonce-xxx'。 - 查看响应HTML,搜索
nonce,确认内联脚本标签上带有同一个 nonce 值。 - 两边 nonce 一致后,在控制台确认没有
Refused to execute inline script或Content Security Policyviolation。 - 把页面刷新多次,确认每次请求的 nonce 值都不同。
如果出现“响应头有 nonce 但脚本没有”的情况,大概率是页面组件没读到请求头;如果出现“脚本有 nonce 但响应头没有”,说明 CSP 根本没被 Middleware 下发。常见对照表如下:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 响应头有CSP,但内联脚本不带nonce | 页面组件未读取请求头 | 检查 headers() 调用位置和 Middleware 路径匹配 |
| 脚本带nonce,但没有CSP响应头 | Middleware 未匹配该路径 | 检查 matcher 配置 |
| nonce每次请求都一样 | nonce 生成位置不在 Middleware | 确认代码是否在构建时执行 |
| 控制台报unsafe-inline错误 | script-src 未包含 nonce 或策略被叠加 | 检查 meta 标签和响应头是否并存 |
6.4 我的取舍思路:什么时候引入库、什么时候自己来
next-csp 的价值在于把上面这套链路封装好了,还额外处理了一些边界情况,比如策略构建、报告配置、脚本注入辅助等。如果你的项目安全要求高,团队多人维护,直接引入它比自己造轮子稳。我自己在核心业务项目里就是用这种思路,省掉很多重复劳动。
但如果只是个人项目,或者需要深度定制策略,我觉得自己实现一套并不复杂。核心代码就三块,我都贴在上面了。关键是把 nonce 的传递链路理解透,再决定哪些部分用库,哪些自己写。引入库不等于不用理解原理,自己写也不等于比库差,重点是别停留在“配置完看不到报错就行”的阶段。
最后分享一点个人习惯:每次改完CSP,我一定会在真实的生产构建下跑一遍next build && next start验证,而不是依赖开发模式。因为开发模式会掩盖掉很多脚本加载问题,CSP nonce 又是那种“开发环境一切正常、生产环境全崩”的典型场景。另外,我会把 report-only 作为默认灰度手段,强制策略作为最终防线,两道配合,既不阻塞业务上线,又能逐步收紧。这套思路配合 next-csp 的源码理解,基本能覆盖 Next.js 项目里绝大多数 CSP nonce 的配置问题。