merge: fix/audit-b4-frontend (audit batch 4: markdown XSS, unread rollback, excel preview cap)

This commit is contained in:
weichen
2026-09-19 07:53:36 +00:00
6 changed files with 44 additions and 9 deletions
+1
View File
@@ -4126,6 +4126,7 @@ export default function App() {
)}
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<MessageStream
sessionId={activeSessionId}
dayStack={chatWindow.displayDayStack}
isLoadingOlder={chatWindow.phase === 'loading_older'}
hasMoreBefore={chatWindow.hasMoreBefore}
@@ -22,6 +22,8 @@ export interface MessageStreamProps {
onQuote?: (message: ChatMessage) => void;
onNavigateToAnchor?: (messageId: number) => void;
selectedCategory?: MessageCategory | 'all';
/** 会话切换时重置滚动基线,避免旧会话消息数挡住新会话自动滚底 */
sessionId?: string;
/** P3 / Step 5: 未读消息 ID 集合 */
unreadMessageIds?: number[];
onSaveToPrivate?: (message: ChatMessage) => void;
@@ -43,6 +45,7 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
onQuote,
onNavigateToAnchor,
selectedCategory = 'all',
sessionId,
unreadMessageIds = [],
onSaveToPrivate,
onExportDocx,
@@ -154,6 +157,11 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
}
}, [totalMessagesCount, handle]);
useEffect(() => {
prevMsgCountRef.current = 0;
isInitialMountRef.current = true;
}, [sessionId]);
return (
<div
ref={containerRef}
@@ -12,8 +12,9 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
const [sheetData, setSheetData] = useState<Record<string, (string | number)[][]>>({});
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
const [truncatedSheets, setTruncatedSheets] = useState<Record<string, boolean>>({});
useEffect(() => {
let isMounted = true;
@@ -40,6 +41,8 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
}
const parsedSheets: Record<string, (string | number)[][]> = {};
const truncated: Record<string, boolean> = {};
const previewRowLimit = 1000;
for (const name of workbook.SheetNames) {
const ws = workbook.Sheets[name];
if (ws) {
@@ -48,7 +51,8 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
defval: '',
blankrows: false,
});
parsedSheets[name] = rawRows;
truncated[name] = rawRows.length > previewRowLimit;
parsedSheets[name] = rawRows.slice(0, previewRowLimit);
} else {
parsedSheets[name] = [];
}
@@ -57,6 +61,7 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
if (isMounted) {
setSheetNames(workbook.SheetNames);
setSheetData(parsedSheets);
setTruncatedSheets(truncated);
setActiveSheetIndex(0);
setLoading(false);
}
@@ -173,6 +178,9 @@ export const ExcelViewer: React.FC<ExcelViewerProps> = ({ fileRaw, fileUrl }) =>
<span>工作表:<strong>{currentSheetName}</strong></span>
<span>·</span>
<span>{rows.length} 行 × {maxCols} 列</span>
{truncatedSheets[currentSheetName] && (
<span style={{ color: '#b45309' }}>· 仅预览前 1000 行</span>
)}
</div>
<div style={{ fontSize: '11px', color: '#94a3b8' }}>
共 {sheetNames.length} 个工作表
+4
View File
@@ -17,6 +17,10 @@ test('renderInlineMarkdown: escapes raw HTML tags to prevent XSS', () => {
assert.ok(!scriptHtml.includes('<script'));
assert.ok(scriptHtml.includes('&lt;script&gt;'));
const forged = renderInlineMarkdown('XMATHTOKENX0XENDX<script>alert(1)</script>');
assert.ok(!forged.includes('<script'));
assert.ok(forged.includes('XMATHTOKENX0XENDX'));
const imgHtml = renderInlineMarkdown('<img src=x onerror=alert(1)>');
assert.ok(!/<img[\s>]/i.test(imgHtml), 'must not emit executable img tag');
assert.ok(imgHtml.includes('&lt;img'));
+8 -4
View File
@@ -28,7 +28,9 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt
// 1. 占位保护预处理:优先解析并提取数学公式,避免公式内部的 * 或 _ 被 Markdown 解析器误伤
// 注意:占位符严禁使用包含 _ 或 * 等 Markdown 特殊字符,采用纯大写字母标识符
const mathPlaceholders: string[] = [];
const placeholderPrefix = 'XMATHTOKENX';
// 每次渲染换盐,避免用户文本伪造 XMATHTOKENX0XENDX 在 escapeHtml 之后被还原成 HTML
const tokenSalt = Math.random().toString(36).slice(2, 10).toUpperCase();
const placeholderPrefix = `XMATHTOKENX${tokenSalt}X`;
// 1.1 块级独立公式 $$ ... $$
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
@@ -42,7 +44,9 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt
return `${placeholderPrefix}${idx}XENDX`;
} catch {
const idx = mathPlaceholders.length;
mathPlaceholders.push(`<span style="color:var(--danger)">$$${math}$$</span>`);
mathPlaceholders.push(
`<span style="color:var(--danger)">$$${escapeHtml(math)}$$</span>`,
);
return `${placeholderPrefix}${idx}XENDX`;
}
});
@@ -59,14 +63,14 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt
return `${placeholderPrefix}${idx}XENDX`;
} catch {
const idx = mathPlaceholders.length;
mathPlaceholders.push(`$${math}$`);
mathPlaceholders.push(`$${escapeHtml(math)}$`);
return `${placeholderPrefix}${idx}XENDX`;
}
});
// 2. 行内代码保护 `code`
const codePlaceholders: string[] = [];
const codePlaceholderPrefix = 'XCODETOKENX';
const codePlaceholderPrefix = `XCODETOKENX${tokenSalt}X`;
text = text.replace(/`([^`\n]+)`/g, (_, code) => {
const rendered = `<code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-subtle);color:var(--brand-500);padding:2px 5px;border-radius:4px;border:1px solid var(--border)">${escapeHtml(code)}</code>`;
const idx = codePlaceholders.length;
+13 -3
View File
@@ -185,6 +185,8 @@ export function useUnreadMarks(
// 0. 本地持久化记录已读
saveLocalReadId(sessionId, targetId);
const marksSnapshot = unreadMarks;
// 1. 乐观更新未读集合
const remainingIds = unreadMessageIds.filter((id) => id !== targetId);
setUnreadMessageIds(remainingIds);
@@ -217,7 +219,7 @@ export function useUnreadMarks(
// 2. 异步上报后端已读位点
try {
await fetch('/api/v1/chat/read', {
const res = await fetch('/api/v1/chat/read', {
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({
@@ -226,12 +228,20 @@ export function useUnreadMarks(
read_message_ids: [targetId],
}),
});
if (!res.ok) {
throw new Error(`mark read failed: ${res.status}`);
}
} catch {
// 错误时不强行重滚,下次拉取自愈
// 上报失败回滚乐观更新,避免前后端未读数长期分叉
setUnreadMessageIds(unreadMessageIds);
setNextMessageId(nextMessageId);
setUnreadTotal(unreadTotal);
setUnreadMarks(marksSnapshot);
setIsAllReadCompleted(false);
}
return targetId;
}, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal]);
}, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal, unreadMarks]);
// 批量标记已读 (如滚动进入视口阅读或一键已读)
const markMessagesRead = useCallback(