本篇我们介绍草稿模式和内容安全策略,两者都是特殊场景下会用到的解决方案。草稿模式用于针对特定情况切换到动态渲染,正如它的名字一样,适用于内容系统的草稿展示。内容安全策略用于阻止脚本恶意加载。现在让我们开始学习吧。
草稿模式一般是结合 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 针对特定情况切换到动态渲染。让我们来看看怎么实现的吧。
首先,创建一个路由处理程序,名字无所谓,就比如 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 值以确保该值不会被猜到。
如果你想要更加安全的使用草稿模式,这是一个建议的使用方式:
首先,创建一个秘密的 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)
}
如果成功的话,浏览器就会重定向到你想要查看的路径。
第二步就是通过检查 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 访问路由处理程序的时候,你应该能成功的看到草稿内容。
默认情况下,草稿模式的 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。
介绍 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
在这个例子里,各种内容默认仅允许从文档所在的源获取,但存在如下例外:
具体有哪些指令(*-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 中的值一致,该脚本的内容就可以得到执行。
回到 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}
/>
)
}