三条边界各一格:同两个环境变量在 Server/Client 两侧的对照读数、React taint APIs 的防手滑保险丝、以及「Server Action 是公开 HTTP 端点」的入参校验心智
演示变量定义在本包 .env.local(gitignored);没建文件时两侧读数为空,对比结论不变
| 变量 | Server Component 读到 | Client Component 读到 |
|---|---|---|
| PLAYGROUND_SERVER_ONLY_DEMO | undefined(未设置 .env.local) | undefined构建期不替换,恒为 undefined |
| NEXT_PUBLIC_PLAYGROUND_BOUNDARY_DEMO | undefined(未设置 .env.local) | undefined构建期内联成字面量,随 bundle 公开分发 |
本站未开启 experimental.taint 全站实验开关,本格为代码讲解;开启方式在代码块注释里
环境变量靠「不内联」保护,但 RSC 还有另一条泄漏通道:把服务端数据 当 props 传给 Client Component 时会被序列化进 RSC 载荷,随页面发给浏览器。 正确做法是「只挑需要的字段」;taint APIs 是这条纪律的强制保险 —— 打上污点的对象/值一旦被传给客户端,服务端渲染直接抛错。
// lib/data.ts —— 数据访问层,只应被 Server Component 调用
import "server-only"; // 第一道保险:被 client bundle import 时构建直接报错
import {
experimental_taintObjectReference,
experimental_taintUniqueValue,
} from "react";
export async function getUser(id: string) {
const user = await db.user.findUnique({ where: { id } });
// 第二道保险:整对象打污点 —— 任何把它当 props 传给
// Client Component 的尝试,在服务端渲染时直接抛错,
// 而不是静默序列化进 RSC 载荷
experimental_taintObjectReference(
"不要把整个 user 对象传给客户端,只挑需要的字段",
user,
);
// 单个敏感值打污点:passwordHash 即使被「挑出来」单独传也过不去
experimental_taintUniqueValue(
"密码哈希永远不能离开服务端",
user,
user.passwordHash,
);
return user;
}
// 需要在 next.config.ts 开启:experimental: { taint: true }
// 本站未开启:该开关是全站性实验选项,而本站演示数据没有真实敏感字段,
// 为一段讲解改动全站运行时不值得 —— 所以本格是代码讲解,不是活演示分工:taintObjectReference 管「整行记录别出去」,taintUniqueValue 管「这个值本身别出去」;两者都只防手滑,不防故意的恶意代码 —— 它们替代不了「只挑字段」的纪律。
活例子见本站留言板专题 /rsc-boundary/server-actions
每个 "use server" 函数都会编译成一个带 action ID 的公开 POST 端点。表单 UI、maxLength、客户端校验都可以被绕过 —— 任何人拿 curl 就能直接打这个端点:
# action 就是一个公开 POST 端点(Next-Action 头里是 action ID): curl -X POST http://localhost:3000/rsc-boundary/server-actions \ -H "Next-Action: <action-id>" \ -F 'content=绕过表单直接提交' # 表单 UI 的 maxLength 根本拦不住这个请求 —— 它是体验,不是防线
// topics/rsc-boundary/server-actions/actions.ts —— 本站活例子
"use server";
export async function postMessage(prevState, formData) {
// FormData 的值可能是 File 或 null,统一收窄为字符串再 trim
const author = String(formData.get("author") ?? "").trim() || "匿名";
const content = String(formData.get("content") ?? "").trim();
// 服务端校验是最后一道防线:请求可以绕过前端直接打到 action
if (!content) {
return { ok: false, error: "留言内容不能为空" };
}
if (content.length > MESSAGE_MAX_LENGTH) {
return { ok: false, error: `留言最多 ${MESSAGE_MAX_LENGTH} 字` };
}
// ...鉴权、写库、revalidatePath
}本专题三格 + /security/headers 的响应头,收拢成上线前的过一遍