Skip to content

Commit 3fee11a

Browse files
committed
fix(review): 内网/回环 SSRF 边界 + 校对会话门控与建议长度护栏
1 parent c55bdef commit 3fee11a

6 files changed

Lines changed: 78 additions & 11 deletions

File tree

‎app/src-tauri/src/ai_proofread.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ pub fn parse_suggestions(raw: &str, expected: &[String]) -> Vec<ProofreadSuggest
136136
.filter(|s| {
137137
let orig_norm = normalize_for_match(&s.original);
138138
let sugg = s.suggestion.trim();
139-
!sugg.is_empty()
139+
// 审查 L3:建议长度护栏(≤2000 字符——防模型失控超长回文)
140+
sugg.chars().count() <= 2000
141+
&& !sugg.is_empty()
140142
&& sugg != s.original.trim()
141143
&& expected_norm.iter().any(|e| *e == orig_norm)
142144
})

‎app/src-tauri/src/commands_proofread.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ pub async fn proofread_run(
125125
return Err("无效的会话 id".to_string());
126126
}
127127
let st: AppState = (*state).clone();
128+
// 审查 L3:会话存在性 + 非 recording 门控(与 second_pass 同口径——
129+
// 幽灵/进行中会话不得零成本成功还记账)
130+
{
131+
let s = st.db.get_session(session_id).map_err(|e| e.to_string())?;
132+
let Some(session) = s else { return Err("会话不存在".to_string()) };
133+
if session.status == "recording" {
134+
return Err("会话进行中——结束捕获后方可校对".to_string());
135+
}
136+
}
128137
let (settings, model) = model_and_settings(&st)?;
129138
settings.proofread_gate()?;
130139
if !authorized {

‎app/src-tauri/src/commands_web.rs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use tauri::State;
1818
use crate::commands::AppState;
1919
use crate::db_web::WebPage;
2020
use crate::types::NewNote;
21-
use crate::web_capture::{extract_page, host_of};
21+
use crate::web_capture::{extract_page, host_of, is_blocked_host};
2222

2323
/// 抓取体量上限(防超大响应拖垮内存/耗时)。
2424
const FETCH_MAX_BYTES: usize = 5 * 1024 * 1024;
@@ -57,6 +57,11 @@ pub async fn web_capture_url(
5757
}
5858

5959
fn capture_inner(st: &AppState, url: &str) -> Result<WebCaptureView, String> {
60+
// 审查 M2:内网/回环/链路本地拒绝(页面重定向链无法在客户端逐跳验证——
61+
// 首跳阻断 + 注释明示局限;另由 ureq redirects 上限约束跳数)
62+
if is_blocked_host(url) {
63+
return Err("目标为内网/回环地址(SSRF 边界拒绝;如确需本机页面请用图文采集)".to_string());
64+
}
6065
let agent = ureq::AgentBuilder::new()
6166
.timeout(FETCH_TIMEOUT)
6267
.redirects(5)
@@ -242,6 +247,10 @@ pub async fn web_snapshot_export(
242247
if assets >= SNAPSHOT_MAX_ASSETS {
243248
return None;
244249
}
250+
// 审查 M2:子资源同样拒绝内网主机(恶意页面植入的内网地址不得随快照带出)
251+
if is_blocked_host(url) {
252+
return None;
253+
}
245254
let bytes = fetch_bytes(url, SNAPSHOT_ASSET_CAP)?;
246255
assets += 1;
247256
total_bytes += bytes.len();

‎app/src-tauri/src/commands_web_inbox.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,12 @@ fn ingest_from_extension(
285285
app: Option<&tauri::AppHandle>,
286286
p: &IngestPayload,
287287
) -> Result<i64, String> {
288+
// 审查 M2:投递 URL 拒绝内网主机(扩展可能位于内网/本地页面语境)
289+
if let Some(u) = &p.url {
290+
if crate::web_capture::is_blocked_host(u) {
291+
return Err("投递 URL 为内网/回环地址(SSRF 边界拒绝)".to_string());
292+
}
293+
}
288294
let now = crate::db::unix_seconds();
289295
let session = db
290296
.create_session(&crate::types::NewSession {

‎app/src-tauri/src/web_capture.rs‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,43 @@ fn extract_title(html: &str) -> String {
8989
String::new()
9090
}
9191

92-
/// URL 主机名(会话标题兜底;纯函数)。
92+
/// URL 主机名(会话标题兜底;纯函数;剥 userinfo——审查 M2:凭证不得入库)。
9393
pub fn host_of(url: &str) -> Option<String> {
9494
let rest = url.split("://").nth(1)?;
95-
Some(
96-
rest.split(['/', '?', '#'])
97-
.next()
98-
.unwrap_or("")
99-
.chars()
100-
.take(100)
101-
.collect(),
102-
)
95+
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
96+
let host = authority.rsplit('@').next().unwrap_or("").to_string();
97+
Some(host.chars().take(100).collect())
98+
}
99+
100+
/// 内网/回环/链路本地主机拦截(审查 M2:SSRF-lite 边界——公网页面的跳转与
101+
/// 子资源不得把本机/内网内容拉入产物)。局限注明:不做 DNS 再解析(域名解析
102+
/// 到内网 IP 的场景留待 resolver 级防护迭代)。
103+
pub fn is_blocked_host(url: &str) -> bool {
104+
let Some(host) = host_of(url) else { return true };
105+
let host = host.trim().to_ascii_lowercase();
106+
let host = host.trim_start_matches('[').trim_end_matches(']');
107+
if host.is_empty() {
108+
return true;
109+
}
110+
if host == "localhost" || host.contains(':') || host.contains('%') {
111+
return true; // 含冒号=IPv6 字面量(::1/fe80 等回环与链路本地一并拦截)
112+
}
113+
if host == "0.0.0.0"
114+
|| host.starts_with("127.")
115+
|| host.starts_with("10.")
116+
|| host.starts_with("169.254.")
117+
|| host.starts_with("192.168.")
118+
{
119+
return true;
120+
}
121+
if let Some(rest) = host.strip_prefix("172.") {
122+
if let Some(second) = rest.split('.').next().and_then(|s| s.parse::<u32>().ok()) {
123+
if (16..=31).contains(&second) {
124+
return true;
125+
}
126+
}
127+
}
128+
false
103129
}
104130

105131
/// HTML → Markdown 正文(轻量规则:script/style 剔除;块级换行;标题/列表/

‎app/src-tauri/src/web_capture_tests.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ fn entity_decode_and_collapse() {
3939
assert!(page.markdown.contains("甲 & 乙 丙"));
4040
}
4141

42+
#[test]
43+
fn host_guard_blocks_private_and_linklocal() {
44+
assert!(is_blocked_host("http://127.0.0.1:8080/x"));
45+
assert!(is_blocked_host("http://localhost/a"));
46+
assert!(is_blocked_host("http://10.1.2.3/a"));
47+
assert!(is_blocked_host("http://192.168.1.1/a"));
48+
assert!(is_blocked_host("http://172.16.5.5/a"));
49+
assert!(is_blocked_host("http://172.32.5.5/a") == false, "172.32 为公网段");
50+
assert!(is_blocked_host("http://169.254.169.254/latest/meta-data/"));
51+
assert!(is_blocked_host("http://[::1]/"));
52+
assert!(!is_blocked_host("https://example.com/x"));
53+
// userinfo 剥除(标题兜底与拦截判断同源)
54+
assert_eq!(host_of("https://user:pass@example.com/x").as_deref(), Some("example.com"));
55+
}
56+
4257
#[test]
4358
fn body_too_short_marks_fail() {
4459
let page = extract_page("<html><head><title>仅标题</title></head><body><p>短</p></body></html>");

0 commit comments

Comments
 (0)