为您控制的应用程序中的 HTML 页面添加可见的用户标签。Worker 检索 Gateway 身份,并通过 HTMLRewriter 流式处理响应。
在 Worker 项目中,将 @cloudflare/zt-types ↗ 2026.10.1 或更高版本安装为开发依赖项:
npm i -D @cloudflare/zt-typesyarn add -D @cloudflare/zt-typespnpm add -D @cloudflare/zt-typesbun add -d @cloudflare/zt-types保留 src/index.ts 中 GatewayMiddlewareContext 和 GatewayMiddlewareNext 的 import type 声明。它们描述 Gateway 上下文和延续函数,并会在 TypeScript 编译时被移除。从 cloudflare:workers 导入运行时 WorkerEntrypoint 类。
将以下脚本用作 Worker 项目中的 src/index.ts。它不需要绑定(binding)。
import { WorkerEntrypoint } from "cloudflare:workers";
function escapeHtml(text) {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export class GatewayMiddleware extends WorkerEntrypoint {
async handle(request, context, next) {
const response = await next(request);
const contentType = response.headers.get("content-type") ?? "";
if (
request.method !== "GET" ||
response.status !== 200 ||
response.headers.has("content-encoding") ||
!response.body ||
!/^text\/html\b/i.test(contentType)
) {
return response;
}
const identity = await context.identity().catch(() => null);
const label = escapeHtml(identity?.email ?? "Unidentified user");
const headers = new Headers(response.headers);
for (const name of [
"content-length",
"etag",
"content-md5",
"digest",
"content-digest",
"repr-digest",
]) {
headers.delete(name);
}
headers.set("cache-control", "private, no-store");
const rewritten = new HTMLRewriter()
.on("body", {
element(element) {
element.prepend(
`<aside style="position:fixed;bottom:1rem;right:1rem;z-index:2147483647;pointer-events:none">Viewed by ${label}</aside>`,
{ html: true },
);
},
})
.transform(
new Response(response.body, { status: response.status, headers }),
);
return rewritten;
}
}import { WorkerEntrypoint } from "cloudflare:workers";
import type {
GatewayMiddlewareContext,
GatewayMiddlewareNext,
} from "@cloudflare/zt-types";
function escapeHtml(text: string): string {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export class GatewayMiddleware extends WorkerEntrypoint {
async handle(
request: Request,
context: GatewayMiddlewareContext,
next: GatewayMiddlewareNext,
): Promise<Response> {
const response = await next(request);
const contentType = response.headers.get("content-type") ?? "";
if (
request.method !== "GET" ||
response.status !== 200 ||
response.headers.has("content-encoding") ||
!response.body ||
!/^text\/html\b/i.test(contentType)
) {
return response;
}
const identity = await context.identity().catch(() => null);
const label = escapeHtml(identity?.email ?? "Unidentified user");
const headers = new Headers(response.headers);
for (const name of [
"content-length",
"etag",
"content-md5",
"digest",
"content-digest",
"repr-digest",
]) {
headers.delete(name);
}
headers.set("cache-control", "private, no-store");
const rewritten = new HTMLRewriter()
.on("body", {
element(element) {
element.prepend(
`<aside style="position:fixed;bottom:1rem;right:1rem;z-index:2147483647;pointer-events:none">Viewed by ${label}</aside>`,
{ html: true },
);
},
})
.transform(
new Response(response.body, { status: response.status, headers }),
);
return rewritten;
}
}该脚本在将用户标签插入 HTML 之前会对其进行转义。它会移除过期的长度和完整性标头,并将个性化响应标记为不可缓存。其他响应标头(包括 Content Security Policy)会被保留。
此模板有意允许匿名流量。当身份为 null 或 Gateway 无法检索身份时,它会使用 Unidentified user。
部署 Worker,并为测试应用程序的 HTML 页面以及测试用户组创建 Forward 策略。访问该页面,确认标签包含您的 Gateway 身份电子邮件。测试图像或 JSON 端点,确认其响应体未发生变化。
页面的 Content Security Policy 可能阻止内联样式生效。标签仍会被插入,但其外观取决于页面。请在您自己的应用程序中配置样式,而不是削弱其安全策略。客户端渲染也可能移除注入的元素。
此模板仅更改状态为 200 的未压缩 HTML 响应。请将测试源配置为提供未压缩 HTML。它不会为 PDF 文件、图像或其他二进制下载加水印,并且可见标签可以被用户移除。在扩大策略范围之前,请测试身份验证流程和应用程序渲染。