> For the complete documentation index, see [llms.txt](https://onenesss-organization.gitbook.io/jj-xiaoce/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://onenesss-organization.gitbook.io/jj-xiaoce/next.js-kai-fa-zhi-nan/31.-pei-zhi-pian-cao-gao-mo-shi-he-nei-rong-an-quan-ce-le.md).

# 31. 配置篇 ❘ 草稿模式和内容安全策略

### 前言

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

### 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()` 方法。

```javascript
// 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](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ac10557004dc4aaa8fcef1fed316fe44~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=2546\&h=1352\&s=391251\&e=png\&b=242424)

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

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

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

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

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

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

```javascript
// 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`。

```javascript
// 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()`。

```javascript
// 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](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP)。

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

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

```javascript
<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](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Security-Policy) HTTP 标头来指定你的策略，像这样：

```javascript
Content-Security-Policy: policy
```

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

```markdown
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 指令文档](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/base-uri)，我数了一下，具体有 29 种指令。而具体的指令内容的书写方式除了 `'self'` 这种表示自身域，`media1.com`这种表示具体的域名之外，还有总共 13 种类型写法，具体可以查看 [CSP source values](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources)。

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

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

`'nonce-<base64-value>'`表示允许使用加密随机数的特定内联脚本。还记得 `<script>` 有一个 [nonce](https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLElement/nonce) 属性吗？就是搭配 CSP 来使用：

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

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

#### 2.2. Next.js CSP

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

```javascript
// 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，具体参考小册[《路由篇 | 路由处理程序和中间件》](https://juejin.cn/book/7307859898316881957/section/7308914343129645065)。Next.js 建议忽略匹配 prefetch（来自 `next/link`）和静态资源，它们也不需要 CSP header。

```javascript
// 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' },
      ],
    },
  ],
}
```

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

```javascript
// 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](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP)
2. [Configuring: Draft Mode | Next.js](https://nextjs.org/docs/app/building-your-application/configuring/draft-mode)
3. [Configuring: Content Security Policy | Next.js](https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy)
