diff --git a/app/[locale]/docs/[...slug]/page.tsx b/app/[locale]/docs/[...slug]/page.tsx
index 2a13a16..7ca6b3d 100644
--- a/app/[locale]/docs/[...slug]/page.tsx
+++ b/app/[locale]/docs/[...slug]/page.tsx
@@ -1,4 +1,5 @@
import { source } from "@/lib/source";
+import { safeJsonLdString } from "@/lib/json-ld";
import { SITE_URL } from "@/lib/site-url";
import { DocsPage, DocsBody } from "fumadocs-ui/page";
import { notFound } from "next/navigation";
@@ -104,12 +105,12 @@ export default async function DocPage({ params }: Param) {
diff --git a/app/[locale]/u/[username]/page.tsx b/app/[locale]/u/[username]/page.tsx
index 2397ca0..04b496c 100644
--- a/app/[locale]/u/[username]/page.tsx
+++ b/app/[locale]/u/[username]/page.tsx
@@ -17,6 +17,7 @@ import { GithubRepos, GithubReposSkeleton } from "./GithubRepos";
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import { sanitizeExternalUrl } from "@/lib/url-safety";
+import { safeJsonLdString } from "@/lib/json-ld";
import { SITE_URL } from "@/lib/site-url";
interface UserView {
@@ -367,7 +368,7 @@ export default async function UserProfilePage({ params }: Param) {
diff --git a/app/layout.tsx b/app/layout.tsx
index b8b3dfe..6815ef0 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -18,6 +18,7 @@ const geistMono = localFont({
});
import { SITE_URL } from "@/lib/site-url";
+import { safeJsonLdString } from "@/lib/json-ld";
const en_description =
"内卷地狱(Involution Hell)是一个由开发者发起的开源学习社区,专注算法、系统设计、工程实践与技术分享,帮助华人程序员高效成长,专注真实进步。Involution Hell is an open-source community empowering builders with real-world engineering.";
@@ -205,7 +206,7 @@ export default function RootLayout({
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
- __html: JSON.stringify({
+ __html: safeJsonLdString({
"@context": "https://schema.org",
"@type": "WebSite",
name: "Involution Hell",
@@ -227,7 +228,7 @@ export default function RootLayout({
"})`
+ 输出不能包含字面 `<` 或 ``,并且应包含转义后的 `\\u003c` 序列。
+- **为什么**:`JSON.stringify` 默认不转义 `<` `>` `&`,攻击者把
+ ``
+ 写进任何 user-generated 字段(profile bio、displayName 等)即触发 stored XSS。
+ satoken 存在 localStorage 且写入非 HttpOnly cookie(跨子域 pgAdmin 的设计取舍),
+ 一次 XSS 等于完整账户接管。
+- **历史**:2026-05-07 三方 CR attack chain A 起点(详见内部报告)。
diff --git a/lib/json-ld.ts b/lib/json-ld.ts
new file mode 100644
index 0000000..4fdd7bf
--- /dev/null
+++ b/lib/json-ld.ts
@@ -0,0 +1,34 @@
+/**
+ * 把任意对象序列化为可安全嵌入
+ * JSON.stringify 默认不转义 `<`,攻击者文本作为合法 JSON 字符串嵌入 闭合 script 块,接着把后续 `
+ *
+ * JSON.stringify 默认输出原文,浏览器看到 `` 就闭合 script block,
+ * 接着把后续 ` 不再出现在输出里", () => {
+ const payload = {
+ bio: ``,
+ };
+ const out = safeJsonLdString(payload);
+ expect(out).not.toContain("");
+ expect(out).not.toContain("载荷 with & 'quotes'` };
+ const out = safeJsonLdString(original);
+ const parsed = JSON.parse(out);
+ expect(parsed.bio).toBe(original.bio);
+ });
+});