JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第28章—配置篇草稿模式和内容安全策略

首次发表于 2024-03-22, 更新于 2024-03-22

前言

本篇我们介绍草稿模式和内容安全策略,两者都是特殊场景下会用到的解决方案。草稿模式用于针对特定情况切换到动态渲染,正如它的名字一样,适用于内容系统的草稿展示。内容安全策略用于阻止脚本恶意加载。现在让我们开始学习吧。

1. 草稿模式(Draft Mode)

1.1. 介绍

草稿模式一般是结合 headless CMS 使用。先说说 CMS,所谓 CMS,Content Management System,中文译为内容管理系统。

内容管理系统的定义可以很狭窄,通常是指门户或商业网站的发布和管理系统;定义也可以很宽泛,个人网站系统也可归入其中。Wiki也是一种内容管理系统,Blog也算是一种内容管理系统。

比如常用于搭建博客的 Wordpress 就是一个知名的内容管理系统。这些年来,headless CMS 也流行了起来。所谓 headless CMS,简单的来说,CMS 不再负责内容的展现,只提供内容存储库以及 API,这使得开发人员可以自定义展示内容,虽然带来了一定的工作量,但也让开发更加灵活自由。

现在说回 Next.js,当你从 headless CMS 中获取数据展示内容的时候,通常静态渲染(编译成 HTML,然后直接展现)就可以了,但如果你是在 headless CMS 中编写草稿,并希望能在页面立即查看到草稿内容时,静态渲染就不合适了,你会希望 Next.js 在请求时而非在构建时渲染页面,获取的是草稿内容而非发布的内容。这个时候就需要草稿模式了。它会让 Next.js 针对特定情况切换到动态渲染。让我们来看看怎么实现的吧。

1.2. 实现

1.2.1. 创建并访问路由处理程序

首先,创建一个路由处理程序,名字无所谓,就比如 app/api/draft/route.js

然后,从 next/headers中导入 draftMode,调用 enable() 方法。

// app/api/draft/route.js
import { draftMode } from 'next/headers'
 
export async function GET(request) {
  draftMode().enable()
  return new Response('Draft mode is enabled')
}

这将设置一个 cookie 用于开启草稿模式,后续包含这个 cookie 的请求都会触发草稿模式,从而改变静态生成页面的行为。

关于这个 cookie,现在通过浏览器开发工具查看 /api/draft请求,你会在 Set-Cookie 响应头中发现一个名为 __prerender_bypass的 cookie,就是此 cookie 控制了页面的展现方式。此外,每次运行 next build的时候都会生成一个新的 cookie 值以确保该值不会被猜到。

image.png

如果你想要更加安全的使用草稿模式,这是一个建议的使用方式:

首先,创建一个秘密的 token 字符串,此密钥只有你的 Next.js 应用程序和 headless CMS 知道,这个密钥可以防止无权访问 CMS 的用户访问草稿 URL。

然后,设置类似下面这样的草稿 URL,假设路由处理程序的地址是 app/api/draft/route.js,对应的草稿 URL 为:

https://<your-site>/api/draft?secret=<token>&slug=<path>

其中 <path> 表示你想要查看的页面路径,比如你想要查看 /posts/foo,这里就是 &slug=/posts/foo

最后你就可以在路由处理程序中,进行各种判断比如密钥是否匹配,参数是否存在,然后再开启 Draft Mode,重定向到预览的路径,示例代码如下:

// app/api/draft/route.js
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
 
export async function GET(request) {
  const { searchParams } = new URL(request.url)
  const secret = searchParams.get('secret')
  const slug = searchParams.get('slug')
 
  if (secret !== 'MY_SECRET_TOKEN' || !slug) {
    return new Response('Invalid token', { status: 401 })
  }
 
  const post = await getPostBySlug(slug)
 
  if (!post) {
    return new Response('Invalid slug', { status: 401 })
  }
 
  draftMode().enable()
 
  redirect(post.slug)
}

如果成功的话,浏览器就会重定向到你想要查看的路径。

1.2.2. 更新页面

第二步就是通过检查 draftMode().isEnabled的值来更新页面。如果请求的页面有设置 cookie,此时 isEnabled 的值就会是 true

// app/page.js
import { draftMode } from 'next/headers'
 
async function getData() {
  const { isEnabled } = draftMode()
 
  const url = isEnabled
    ? 'https://draft.example.com'
    : 'https://production.example.com'
 
  const res = await fetch(url)
 
  return res.json()
}
 
export default async function Page() {
  const { title, desc } = await getData()
 
  return (
    <main>
      <h1>{title}</h1>
      <p>{desc}</p>
    </main>
  )
}

这样,当你从 headless CMS 或者手动带 secret 和 slug 访问路由处理程序的时候,你应该能成功的看到草稿内容。

1.2.3. 其他

默认情况下,草稿模式的 session 会在浏览器关闭时结束。如果你想要手动清理草稿模式的 cookie,你可以创建一个路由处理程序,在此程序中调用 draftMode().disable()

// app/api/disable-draft/route.js
import { draftMode } from 'next/headers'
 
export async function GET(request) {
  draftMode().disable()
  return new Response('Draft mode is disabled')
}

然后,发送一个请求到 /api/disalbe-draft 调用路由处理程序,如果你使用 next/link 来调用这个路由,你必须传递 prefetch={false}来防止 prefetch 时意外删除 cookie。

2. 内容安全策略(Content Security Policy)

2.1. HTTP CSP

介绍 Next.js 的 CSP 实现方式前,我们先说下 HTTP 请求中的 CSP

所谓 CSP,Content Security Policy,中文译为“内容安全策略”。CSP 用于检测并削弱某些特定类型的攻击,包括跨站脚本(XSS)和数据注入攻击等。

该安全策略的实现基于一个叫做 Content-Security-Policy 的 HTTP 首部。除此之外, 元素也可以被用来配置该策略,例如:

<meta
  http-equiv="Content-Security-Policy"
  content="default-src 'self'; img-src https://*; child-src 'none';" />

CSP 到底是怎么缓解攻击的呢?以 XSS 攻击为例:

XSS 攻击利用了浏览器对于从服务器所获取的内容的信任。恶意脚本在受害者的浏览器中得以运行,因为浏览器信任其内容来源,即使有的时候这些脚本并非来自于它本该来的地方。

CSP 通过指定有效域——即浏览器认可的可执行脚本的有效来源——使服务器管理者有能力减少或消除 XSS 攻击所依赖的载体。一个 CSP 兼容的浏览器将会仅执行从白名单域获取到的脚本文件,忽略所有的其他脚本(包括内联脚本和 HTML 的事件处理属性)。

你可以使用 Content-Security-Policy HTTP 标头来指定你的策略,像这样:

Content-Security-Policy: policy

policy 表示策略,是一个包含了各种描述你的 CSP 策略指令的字符串。对于不同类型的项目都有特定的指令,因此每种类型都可以有自己的指令,包括字体、frame、图像、音频和视频媒体、script 和 worker。比如我们要限制图片的加载需要用 img-src,限制多媒体文件的加载需要用 media-src,限制脚本的加载需要用 script-src,举个例子:

Content-Security-Policy: default-src 'self'; img-src *; media-src media1.com media2.com; script-src userscripts.example.com

在这个例子里,各种内容默认仅允许从文档所在的源获取,但存在如下例外:

  • 图片可以从任何地方加载 (注意“*”通配符)
  • 多媒体文件仅允许从 media1.com 和 media2.com 加载(不允许从这些站点的子域名)
  • 可运行脚本仅允许来自于 userscripts.example.com

具体有哪些指令(*-src这种)可以查看 MDN 的 CSP 指令文档,我数了一下,具体有 29 种指令。而具体的指令内容的书写方式除了 'self' 这种表示自身域,media1.com这种表示具体的域名之外,还有总共 13 种类型写法,具体可以查看 CSP source values

我们举一个后面讲解会用到的—— 'nonce-<base64-value>',使用示例如下:

Content-Security-Policy: default-src 'self'; script-src 'nonce-rBcd2m'

'nonce-<base64-value>'表示允许使用加密随机数的特定内联脚本。还记得 <script> 有一个 nonce 属性吗?就是搭配 CSP 来使用:

<script nonce="rBcd2m">
	//...
</script>

如果脚本 nonce 的值跟 CSP 中的值一致,该脚本的内容就可以得到执行。

2.2. Next.js CSP

回到 Next.js,尽管 CSP 可以阻止恶意脚本,不过有的时候,使用内联脚本是必需的。在这种情况下,就需要借助随机数来保证脚本正确执行,为此我们需要借助 Next.js 中间件来实现,举个例子:

// middleware.js
import { NextResponse } from 'next/server'
 
export function middleware(request) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const cspHeader = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    block-all-mixed-content;
    upgrade-insecure-requests;
`
  const contentSecurityPolicyHeaderValue = cspHeader
    .replace(/\s{2,}/g, ' ')
    .trim()
 
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set(
    'Content-Security-Policy',
    contentSecurityPolicyHeaderValue
  )
 
  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
  response.headers.set(
    'Content-Security-Policy',
    contentSecurityPolicyHeaderValue
  )
 
  return response
}

中间件可以让你能够在页面渲染之前添加标题和随机数。每次查看页面时,都会生成一个新的随机数。这也意味着你必须使用动态渲染来添加随机数。

默认情况下,中间件会在所有请求上运行,如果要运行在特定路径上,使用 matcher,具体参考小册《路由篇 | 路由处理程序和中间件》。Next.js 建议忽略匹配 prefetch(来自 next/link)和静态资源,它们也不需要 CSP header。

// middleware.js
export const config = {
  matcher: [
    /*
     * 匹配所有的请求路径,除了以这些开头的
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    {
      source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

然后你就可以在服务端组件中读取随机数,从而脚本组件正确执行:

// app/page.jsx
import { headers } from 'next/headers'
import Script from 'next/script'
 
export default function Page() {
  const nonce = headers().get('x-nonce')
 
  return (
    <Script
      src="https://www.googletagmanager.com/gtag/js"
      strategy="afterInteractive"
      nonce={nonce}
    />
  )
}

参考链接

  1. 内容安全策略(CSP) - HTTP | MDN
  2. Configuring: Draft Mode | Next.js
  3. Configuring: Content Security Policy | Next.js
© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru