From 4a0d7193c26bb6d960abf161655c816a82a50954 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 17:28:27 +0000 Subject: [PATCH] fix(posts-cr): apply Copilot CR feedback on security and code quality - PostContent: narrow rehype-sanitize style attribute from * to span/svg only (KaTeX only needs style on these two elements; global style is an XSS vector) - Extract buildFrontmatter to lib/frontmatter.ts to avoid pulling editor bundle into detail page / card bundles - PostDetailOwnerActions: add .catch(()=>({})) on DELETE res.json() for resilient error body parsing - EditorPageClient: align titleToSlug comment with actual Unicode behavior, add tags trim+filter before POST, guard satoken header to avoid empty token - PromoteToDocsButton: update import path, guard satoken header Co-authored-by: copilot-pull-request-reviewer[bot] <198982749+copilot-pull-request-reviewer[bot]@users.noreply.github.com> --- app/[locale]/editor/EditorPageClient.tsx | 58 ++++--------------- .../posts/[slug]/PostDetailOwnerActions.tsx | 6 +- app/components/PostContent.tsx | 8 ++- app/components/PromoteToDocsButton.tsx | 6 +- lib/frontmatter.ts | 41 +++++++++++++ 5 files changed, 63 insertions(+), 56 deletions(-) create mode 100644 lib/frontmatter.ts diff --git a/app/[locale]/editor/EditorPageClient.tsx b/app/[locale]/editor/EditorPageClient.tsx index 6c51b309..3dcb2912 100644 --- a/app/[locale]/editor/EditorPageClient.tsx +++ b/app/[locale]/editor/EditorPageClient.tsx @@ -22,10 +22,9 @@ interface EditorPageClientProps { user: UserView; } -/** - * 从文章标题生成 slug 候选值,和后端生成逻辑保持一致(kebab-case,纯 ASCII)。 - * 后端会做唯一性去重,前端只是提前填充 filename input 用,不是最终 slug。 - */ +// titleToSlug:从文章标题生成 slug 候选值,供前端预填 filename input。 +// 保留 Unicode 字母/数字(\p{L}\p{N}),允许中文 slug 候选,和后端 sanitizeSlug 对齐。 +// 后端会做唯一性去重,前端候选值不是最终 slug。 function titleToSlug(title: string): string { return title .toLowerCase() @@ -37,44 +36,6 @@ function titleToSlug(title: string): string { .slice(0, 128); } -// buildFrontmatter 仅在「收录进知识库」(PromoteToDocsButton)路径使用, -// 这里为 PromoteToDocsButton 单独导出,editor 直发不再拼 frontmatter。 -export function buildFrontmatter({ - title, - description, - tags, -}: { - title: string; - description?: string; - tags?: string[]; -}) { - const safeTitle = JSON.stringify(title); - const safeDescription = JSON.stringify(description ?? ""); - const date = new Date().toISOString().slice(0, 10); - const normalizedTags = (tags ?? []) - .map((tag) => tag.trim()) - .filter((tag) => tag.length > 0); - - const lines = [ - "---", - `title: ${safeTitle}`, - `description: ${safeDescription}`, - `date: "${date}"`, - ]; - - if (normalizedTags.length > 0) { - lines.push( - "tags:", - ...normalizedTags.map((tag) => ` - ${JSON.stringify(tag)}`), - ); - } else { - lines.push("tags: []"); - } - - lines.push("---"); - return lines.join("\n"); -} - export function EditorPageClient({ user }: EditorPageClientProps) { const router = useRouter(); const [isPublishing, setIsPublishing] = useState(false); @@ -195,15 +156,16 @@ export function EditorPageClient({ user }: EditorPageClientProps) { }); } - // POST /api/posts 直发落库 - // TODO(backend-contract): 等后端 #2 完成后确认 BACKEND_URL 路由 rewrite 情况; - // 目前后端路由走 next.config.mjs rewrites 同源代理(参考 /api/community/links), - // 若 posts 同样走代理则直接 fetch "/api/posts",否则需要带 BACKEND_URL。 const token = localStorage.getItem("satoken") ?? ""; + if (!token) { + throw new Error("请先登录后再发布"); + } + const postRequest: PostRequest = { title: title.trim(), description: description.trim() || undefined, - tags: tags.filter((t) => t.trim().length > 0), + // trim + filter 保证后端收到的 tags 无空白项 + tags: tags.map((t) => t.trim()).filter(Boolean), contentMd: finalMarkdown, // 有用户填的 slug 就带上,后端会去重;没有则不传,后端从 title 自动生成 ...(rawSlug ? { slug: rawSlug } : {}), @@ -214,7 +176,7 @@ export function EditorPageClient({ user }: EditorPageClientProps) { headers: { "Content-Type": "application/json", // rewrite 透传:后端 sa-token.token-name=satoken,需用 satoken 而非 x-satoken - satoken: token, + ...(token ? { satoken: token } : {}), }, body: JSON.stringify(postRequest), signal: AbortSignal.timeout(30_000), diff --git a/app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx b/app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx index f3b1aa94..daacbfe6 100644 --- a/app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx +++ b/app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx @@ -55,10 +55,10 @@ export function PostDetailOwnerActions({ const token = localStorage.getItem("satoken") ?? ""; const res = await fetch(`/api/posts/${postId}`, { method: "DELETE", - // rewrite 透传:后端读 satoken,不是 x-satoken - headers: { satoken: token }, + // rewrite 透传:后端读 satoken,不是 x-satoken;空 token 不发 header + headers: { ...(token ? { satoken: token } : {}) }, }); - const body = (await res.json()) as ApiResponse; + const body = (await res.json().catch(() => ({}))) as ApiResponse; if (res.ok && body.success) { router.replace(`/u/${authorUsername}/posts`); } else { diff --git a/app/components/PostContent.tsx b/app/components/PostContent.tsx index 9181da1c..955fe646 100644 --- a/app/components/PostContent.tsx +++ b/app/components/PostContent.tsx @@ -49,8 +49,12 @@ const sanitizeSchema = { ], attributes: { ...defaultSchema.attributes, - // 允许所有元素携带 className(rehype-katex / rehype-autolink-headings 需要) - "*": [...(defaultSchema.attributes?.["*"] ?? []), "className", "style"], + // className 全局允许(rehype-katex / rehype-autolink-headings 均需要) + // style 不全局放行(XSS via CSS),只给 KaTeX 渲染必须的元素开放 + "*": [...(defaultSchema.attributes?.["*"] ?? []), "className"], + // KaTeX span/svg 需要 style 控制数学符号排版,普通 UGC 元素不需要 + span: [...(defaultSchema.attributes?.["span"] ?? []), "style"], + svg: [...(defaultSchema.attributes?.["svg"] ?? []), "style"], // KaTeX math 元素的专有属性 math: ["xmlns", "display"], annotation: ["encoding"], diff --git a/app/components/PromoteToDocsButton.tsx b/app/components/PromoteToDocsButton.tsx index 8ffd7e8d..751737f1 100644 --- a/app/components/PromoteToDocsButton.tsx +++ b/app/components/PromoteToDocsButton.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { DocsDestinationForm } from "@/app/components/DocsDestinationForm"; import { buildDocsNewUrl } from "@/lib/github"; -import { buildFrontmatter } from "@/app/[locale]/editor/EditorPageClient"; +import { buildFrontmatter } from "@/lib/frontmatter"; interface Props { postId: number; @@ -114,8 +114,8 @@ export function PromoteToDocsButton({ method: "POST", headers: { "Content-Type": "application/json", - // rewrite 透传:后端读 satoken,不是 x-satoken - satoken: token, + // rewrite 透传:后端读 satoken,不是 x-satoken;空 token 不发 header + ...(token ? { satoken: token } : {}), }, body: JSON.stringify({ prUrl: githubUrl }), }).catch((err) => { diff --git a/lib/frontmatter.ts b/lib/frontmatter.ts new file mode 100644 index 00000000..43ad82af --- /dev/null +++ b/lib/frontmatter.ts @@ -0,0 +1,41 @@ +/** + * buildFrontmatter:为 /docs 知识库生成 YAML frontmatter 字符串。 + * + * 抽到独立模块的原因:EditorPageClient 和 PromoteToDocsButton 都需要它, + * 把它留在 EditorPageClient.tsx 会让详情页/卡片 bundle 拖进整个编辑器栈。 + */ +export function buildFrontmatter({ + title, + description, + tags, +}: { + title: string; + description?: string; + tags?: string[]; +}): string { + const safeTitle = JSON.stringify(title); + const safeDescription = JSON.stringify(description ?? ""); + const date = new Date().toISOString().slice(0, 10); + const normalizedTags = (tags ?? []) + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); + + const lines = [ + "---", + `title: ${safeTitle}`, + `description: ${safeDescription}`, + `date: "${date}"`, + ]; + + if (normalizedTags.length > 0) { + lines.push( + "tags:", + ...normalizedTags.map((tag) => ` - ${JSON.stringify(tag)}`), + ); + } else { + lines.push("tags: []"); + } + + lines.push("---"); + return lines.join("\n"); +}