Skip to content

Commit 4a203b5

Browse files
committed
fix(settling): 审查修复——IPv6 SSRF 绕过/英文断行粘连/重试重复安放/响应体内存/完成计数
- validateFetchUrl 增加 IPv6 ULA(fc00::/7) 与 link-local(fe80::/10) 拦截, 封堵 [fd00::1] 等内网地址绕过(SSRF 防护补全) - contentSanitizer 断行合并时 ASCII 词边界补空格,修复 broken\\nline → brokenline 英文单词粘连(测试断言同步纠正,新增中英混排用例) - SettlingPage 部分失败重试时移除已成功安放项,消除重复笔记/卡片风险 - import:fetch-url 响应体流式截断 + Content-Length 预检,防超大响应 拖垮主进程内存 - done 页展示实际安放数(过滤空名称后),修正数字误导
1 parent 45c414e commit 4a203b5

4 files changed

Lines changed: 90 additions & 11 deletions

File tree

‎client/electron/importHandlers.ts‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ function toSettlingRecord(row: ImportRow): SettlingRecord {
5757

5858
/**
5959
* SSRF 防护:仅允许公网 HTTP(S),拦截内网/回环/链路本地地址。
60+
* 覆盖 WHATWG URL 规范化的十进制/八进制/十六进制 IPv4(统一变成点分
61+
* 十进制后可被 /^127\./ 等捕获)与 IPv6 内网前缀(ULA fc00::/7、
62+
* link-local fe80::/10、IPv4-mapped ::ffff:)。
6063
* @returns 违规原因;null 表示放行 / Return block reason or null
6164
*/
6265
export function validateFetchUrl(rawUrl: string): string | null {
@@ -82,7 +85,10 @@ export function validateFetchUrl(rawUrl: string): string | null {
8285
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
8386
/^169\.254\./.test(host) ||
8487
host === '::1' ||
85-
host === '0:0:0:0:0:0:0:1';
88+
host === '0:0:0:0:0:0:0:1' ||
89+
// IPv6 内网前缀:ULA fc00::/7(fc00-fdff)与 link-local fe80::/10(fe80-febf)
90+
/^f[cd]/.test(host) ||
91+
/^fe[89ab]/.test(host);
8692
return isPrivate ? '不允许访问内网或本机地址' : null;
8793
}
8894

@@ -113,6 +119,32 @@ async function fetchWithRedirectGuard(initialUrl: string): Promise<Response> {
113119
}
114120
}
115121

122+
/**
123+
* 流式读取响应体并截断到上限字符数(防超大响应内存风险)
124+
* Stream the response body, bounded to maxChars characters.
125+
*/
126+
async function readBodyBounded(res: Response, maxChars: number): Promise<string> {
127+
if (!res.body) return (await res.text()).slice(0, maxChars);
128+
const reader = res.body.getReader();
129+
const decoder = new TextDecoder();
130+
let body = '';
131+
try {
132+
for (;;) {
133+
const { done, value } = await reader.read();
134+
if (done) break;
135+
body += decoder.decode(value, { stream: true });
136+
if (body.length >= maxChars) {
137+
body = body.slice(0, maxChars);
138+
await reader.cancel(); // 提前终止下载
139+
break;
140+
}
141+
}
142+
} finally {
143+
reader.releaseLock();
144+
}
145+
return body;
146+
}
147+
116148
/**
117149
* 轻量 HTML 正文提取(无第三方依赖):去噪声节点后取 <article>/<main>
118150
* 或 body 文本,折叠空白。非完整渲染,仅服务知识入籍的正文抓取。
@@ -228,8 +260,15 @@ export function registerImportHandlers(): void {
228260
if (!res.ok) {
229261
return { success: false, error: `网页访问失败(HTTP ${res.status}),可手动粘贴内容` };
230262
}
231-
const raw = await res.text();
232-
const { title, text } = extractHtmlText(raw.slice(0, MAX_BODY_CHARS));
263+
// Content-Length 预检:超大响应直接拒绝,避免无谓下载
264+
const declared = Number(res.headers.get('content-length') ?? 0);
265+
if (declared > MAX_BODY_CHARS) {
266+
return { success: false, error: '网页内容过大,可手动粘贴正文内容' };
267+
}
268+
// 流式截断:即使服务器不声明 Content-Length 或分块传输,
269+
// 也只累计前 MAX_BODY_CHARS 字符,防止超大响应拖垮主进程内存
270+
const raw = await readBodyBounded(res, MAX_BODY_CHARS);
271+
const { title, text } = extractHtmlText(raw);
233272
if (!text) {
234273
return { success: false, error: '未能从该网页提取到正文,可手动粘贴内容' };
235274
}

‎client/src/features/settling/lib/contentSanitizer.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,26 @@ describe('sanitizeExtractedText', () => {
1414
expect(result).toBe('');
1515
});
1616

17-
it('should merge broken English lines into one sentence', () => {
18-
// Arrange:PDF 提取常见的断行
17+
it('should merge broken English lines with a space to avoid word sticking', () => {
18+
// Arrange:PDF 提取常见的英文断行——直接拼接会产生 brokenline 粘连
1919
const raw = 'This is a broken\nline that should\nbe merged.';
2020

2121
// Act
2222
const result = sanitizeExtractedText(raw);
2323

24+
// Assert:词边界保留空格
25+
expect(result).toBe('This is a broken line that should be merged.');
26+
});
27+
28+
it('should not insert a space between CJK and CJK, or CJK and ASCII edges', () => {
29+
// Arrange:中文字符间直接连接;中英边界也不插空格(保持原文排布)
30+
const raw = '这是被换行打断的\n中文句子,应当合并。\n学习费曼\nLearning 方法。';
31+
32+
// Act
33+
const result = sanitizeExtractedText(raw);
34+
2435
// Assert
25-
expect(result).toBe('This is a brokenline that shouldbe merged.');
36+
expect(result).toBe('这是被换行打断的中文句子,应当合并。\n学习费曼Learning 方法。');
2637
});
2738

2839
it('should merge broken Chinese lines without inserting spaces', () => {

‎client/src/features/settling/lib/contentSanitizer.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ const SENTENCE_END_RE = /[。!?.!?…]$/;
2424
/** 疑似页码/装饰行(纯数字、纯符号、空格夹杂) / Page-number-like lines */
2525
const JUNK_LINE_RE = /^[\d\s\-—–·•*|/\\]+$/;
2626

27+
/** ASCII 字母数字(英文断行合并时需补空格,避免单词粘连) / ASCII word chars */
28+
const ASCII_WORD_RE = /[A-Za-z0-9]/;
29+
30+
/**
31+
* 断行合并:中英文混排场景的换行还原。
32+
* 中文字符间直接连接;两侧均为 ASCII 字母/数字时插入空格
33+
* (PDF 断行常把 "broken line" 切成 "broken\nline",直接拼接会变
34+
* "brokenline",破坏英文语义)。
35+
* Join a wrapped line to the pending one; insert a space when both
36+
* edges are ASCII word characters (English word-wrap restoration).
37+
*/
38+
function joinPending(pending: string, collapsed: string): string {
39+
const prevChar = pending[pending.length - 1] ?? '';
40+
const nextChar = collapsed[0] ?? '';
41+
const needsSpace = ASCII_WORD_RE.test(prevChar) && ASCII_WORD_RE.test(nextChar);
42+
return needsSpace ? `${pending} ${collapsed}` : `${pending}${collapsed}`;
43+
}
44+
2745
/**
2846
* 单行清理:压缩内部连续空白 / Collapse inner whitespace of a line
2947
*/
@@ -73,7 +91,7 @@ export function sanitizeExtractedText(raw: string): string {
7391

7492
// 上行末尾无句读 → 与当前行合并(断行还原)
7593
if (pending && !SENTENCE_END_RE.test(pending)) {
76-
pending = `${pending}${collapsed}`;
94+
pending = joinPending(pending, collapsed);
7795
continue;
7896
}
7997

‎client/src/features/settling/pages/SettlingPage.tsx‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export default function SettlingPage() {
5454
const [concepts, setConcepts] = useState<ConceptCandidate[]>([]);
5555
const [error, setError] = useState<string | null>(null);
5656
const [isConceptualizing, setIsConceptualizing] = useState(false);
57+
/** 实际安放的概念数(过滤空名称后,done 页展示用) / Actually settled count */
58+
const [settledCount, setSettledCount] = useState(0);
5759

5860
/** 进入预览:统一过内容清理(断行合并/页眉丢弃),提升 AI 概念化输入质量 */
5961
const enterPreview = (input: ParsedInput) => {
@@ -117,11 +119,20 @@ export default function SettlingPage() {
117119
if (valid.length === 0) { setError('请至少填写一个概念名称'); return; }
118120
setStep('settling'); setError(null);
119121
const res = await settleConcepts({ title: parsed.title, source: parsed.source, rawName: parsed.rawName, concepts: valid });
120-
if (res.ok) setStep('done');
121-
else { setError(res.error ?? '安放失败,可稍后重试'); setStep('preview'); }
122+
if (res.ok) {
123+
setSettledCount(valid.length);
124+
setStep('done');
125+
} else {
126+
// 部分失败:从概念列表移除已成功安放项,重试不会重复创建笔记/卡片
127+
// (settleConcepts 按序安放,noteIds 即前 N 个概念的成功结果)
128+
const remaining = valid.slice(res.noteIds.length);
129+
setConcepts(remaining.length > 0 ? remaining : [emptyConcept()]);
130+
setError(res.error ?? '安放失败,可稍后重试');
131+
setStep('preview');
132+
}
122133
};
123134

124-
const resetAll = () => { setStep('source'); setParsed(null); setConcepts([]); setText(''); setUrl(''); setTitle(''); setError(null); };
135+
const resetAll = () => { setStep('source'); setParsed(null); setConcepts([]); setText(''); setUrl(''); setTitle(''); setError(null); setSettledCount(0); };
125136
const updateConcept = (i: number, patch: Partial<ConceptCandidate>) =>
126137
setConcepts((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c)));
127138

@@ -226,7 +237,7 @@ export default function SettlingPage() {
226237
const renderDone = () => (
227238
<div className="flex flex-col items-center gap-4 py-16 text-center">
228239
<div className="text-4xl">🌌</div>
229-
<div className="text-lg font-medium text-slate-200">已安放 {concepts.length} 个概念</div>
240+
<div className="text-lg font-medium text-slate-200">已安放 {settledCount} 个概念</div>
230241
<p className="text-sm text-slate-400">它们已在你的世界里亮起,呈雾中轮廓 · 可以从容地慢慢复习</p>
231242
<div className="mt-4 flex gap-3">
232243
<button onClick={() => navigate('/')} className={primaryBtn}>回到世界</button>

0 commit comments

Comments
 (0)