fix: 搜索模块审计修复 — ATM+NOT、affiliation、分页、SB 等 12 项漏洞
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

This commit is contained in:
34047007@qq.com
2026-07-27 16:57:13 +08:00
parent 4a24f764f7
commit 4f6024aa7a
6 changed files with 93 additions and 61 deletions
+1
View File
@@ -71,6 +71,7 @@ class AdvancedSearchRequest(BaseModel):
# keyset 游标分页(所有排序模式通用,设了 cursor 后 page 参数被忽略,不做 COUNT)
cursor_val: str | None = None # 上一页最后一条的排序列值(字符串,服务端按 sort 模式解析)
cursor_id: str | None = None # 上一页最后一条的 id(UUID 字符串)
cursor_date: str | None = None # 向后兼容(映射到 cursor_val
# ── PubMed 筛选器参数 ──
# Text Availability
+19 -4
View File
@@ -553,6 +553,13 @@ class PubmedQueryParser:
end_pos = self.pos # P2-2: 记录组结束标记位置
self.expect(TokenType.RPAREN)
self._depth -= 1
# P2-F12: (a OR b)[TI] — 组后字段标签应用到组内所有词
if self.peek().type == TokenType.FIELD:
ft = self.advance()
_raw_field = ft.value[1:-1].upper()
_field = _normalize_field_label(_raw_field)
for t in terms:
t.field = _field
# 标记为子组,不放入 flat lists,保留括号分组结构
group_id = len(result.groups)
for t in terms:
@@ -640,10 +647,18 @@ class PubmedQueryParser:
start_val, end_val = end_val, start_val
elif not _start_is_digit and not _end_is_digit and start_val > end_val:
start_val, end_val = end_val, start_val
elif _start_is_digit and not _end_is_digit and int(start_val) > int(end_val[:4]):
start_val, end_val = end_val, start_val
elif not _start_is_digit and _end_is_digit and int(start_val[:4]) > int(end_val):
start_val, end_val = end_val, start_val
elif _start_is_digit and not _end_is_digit:
try:
if int(start_val) > int(end_val[:4]):
start_val, end_val = end_val, start_val
except (ValueError, TypeError):
pass
elif not _start_is_digit and _end_is_digit:
try:
if int(start_val[:4]) > int(end_val):
start_val, end_val = end_val, start_val
except (ValueError, TypeError):
pass
# 确定两端是否是 4 位年份
_start_is_year = start_val.isdigit() and len(start_val) == 4
_end_is_year = end_val.isdigit() and len(end_val) == 4
+44 -27
View File
@@ -4,7 +4,7 @@ import logging
import re
from datetime import datetime
from sqlalchemy import String, and_, case, cast, func, literal_column, not_, or_, select, text
from sqlalchemy import String, and_, case, cast, exists, func, literal_column, not_, or_, select, text
logger = logging.getLogger(__name__)
from sqlalchemy.ext.asyncio import AsyncSession
@@ -597,6 +597,9 @@ class AdvancedSearchEngine:
_keyset_cond = AdvancedSearchEngine._keyset_condition(sort, cursor_val, cursor_id)
if _keyset_cond is not None:
conditions.append(_keyset_cond)
elif sort not in AdvancedSearchEngine.KEYSET_COLUMN_SORTS and page > 1:
# P1-F5: best_match/relevance 不支持 keyset,用 OFFSET 翻页
q = q.offset((page - 1) * page_size)
# 重新构建查询
q = select(GlobalLiterature)
@@ -625,7 +628,9 @@ class AdvancedSearchEngine:
# 后续页从 facet 缓存读 total
facet_cached = await _cache.get(_facet_cache_key)
if facet_cached:
total = facet_cached.get("total", 0)
if isinstance(facet_cached, dict):
total = facet_cached.get("total", 0)
# P2-F11: 旧格式 list(只有 year_counts),保持 total=0
# 构建游标供翻页
next_cursor_val = None
@@ -783,29 +788,35 @@ class AdvancedSearchEngine:
term_conditions.append(field_combine(*field_conds) if len(field_conds) > 1 else field_conds[0])
# 2. 纯文本词(无字段标签)— P0-2: 对无标签词补充 ATM MeSH 展开
# P0-F1: ATM 只展开肯定词,否定词独立 AND,避免被 ATM OR 短路
if pp.plain_terms:
plain_conds = []
for term in pp.plain_terms:
cond = AdvancedSearchEngine._field_condition("all", term.text, term.exact)
if term.is_not:
cond = not_(cond)
plain_conds.append(cond)
if plain_conds:
combined = " ".join(t.text for t in pp.plain_terms if not t.is_not).strip()
if combined and not re.search(r'[一-鿿㐀-䶿豈-﫿]', combined):
from app.services.query_expansion import expand_atm as _expand_atm_inline
try:
atm_cond = await _expand_atm_inline(db, combined)
except Exception:
logger.exception("ATM expansion failed (pubmed plain_terms): %s", combined[:100])
atm_cond = None
if atm_cond is not None:
text_cond = field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0]
term_conditions.append(or_(atm_cond, text_cond))
else:
term_conditions.append(field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
pos_conds = [AdvancedSearchEngine._field_condition("all", t.text, t.exact)
for t in pp.plain_terms if not t.is_not]
neg_conds = [not_(AdvancedSearchEngine._field_condition("all", t.text, t.exact))
for t in pp.plain_terms if t.is_not]
combined_pos_text = " ".join(t.text for t in pp.plain_terms if not t.is_not).strip()
if combined_pos_text and pos_conds and not re.search(r'[一-鿿㐀-䶿豈-﫿]', combined_pos_text):
from app.services.query_expansion import expand_atm as _expand_atm_inline
try:
atm_cond = await _expand_atm_inline(db, combined_pos_text)
except Exception:
logger.exception("ATM expansion failed (pubmed plain_terms): %s", combined_pos_text[:100])
atm_cond = None
else:
atm_cond = None
term_cond = None
if pos_conds:
pos_combined = field_combine(*pos_conds) if len(pos_conds) > 1 else pos_conds[0]
if atm_cond is not None:
term_cond = or_(atm_cond, pos_combined)
else:
term_conditions.append(field_combine(*plain_conds) if len(plain_conds) > 1 else plain_conds[0])
term_cond = pos_combined
elif atm_cond is not None:
term_cond = atm_cond
if term_cond is not None:
term_conditions.append(term_cond)
# 否定词独立 AND(不参与 ATM 展开)
term_conditions.extend(neg_conds)
# 3. [MH] → tree_number 展开,支持 is_not 和 _noexp
if pp.mesh_terms:
@@ -1037,7 +1048,8 @@ class AdvancedSearchEngine:
continue # no-op: 所有记录都是 PubMed
elif val == "MEDLINE":
cond = GlobalLiterature.citation_status == "medline"
elif len(val) == 1 and val.isalpha():
elif val.isalpha():
# P1-F7: 所有字母子集码(含 AIM 等多字母)统一走 nlm_subsets
subq = select(GlobalJournal.issn).where(
GlobalJournal.nlm_subsets.overlap([val])
)
@@ -1300,7 +1312,12 @@ class AdvancedSearchEngine:
GlobalLiterature.journal_iso.ilike(pat),
)
elif field == "affiliation":
return cast(GlobalLiterature.authors, String).ilike(_pt())
# P0-F2: 用 jsonb_array_elements 提取 affiliation 值,避免 JSON 键名假阳性
_pat = _pt()
return text(
"EXISTS (SELECT 1 FROM jsonb_array_elements(global_literature.authors) AS _e "
"WHERE _e->>'affiliation' ILIKE :aff_pat)"
).bindparams(aff_pat=_pat)
elif field == "language":
return GlobalLiterature.language.ilike(_pt())
elif field == "volume":
@@ -1493,11 +1510,11 @@ class AdvancedSearchEngine:
return [GlobalLiterature.cited_by_count.desc().nullslast(), GlobalLiterature.id.desc()]
elif sort == "best_match" and relevance_query.strip():
tsq = func.plainto_tsquery("english", relevance_query)
return [AdvancedSearchEngine._best_match_order(tsq)]
return [AdvancedSearchEngine._best_match_order(tsq), GlobalLiterature.id.desc()]
elif sort == "relevance" and relevance_query.strip():
tsq = func.plainto_tsquery("english", relevance_query)
rank = func.ts_rank(GlobalLiterature.search_tsv, tsq)
return [rank.desc()]
return [rank.desc(), GlobalLiterature.id.desc()]
elif sort == "first_author":
return [GlobalLiterature.authors[0]['family'].astext.asc().nullslast()]
elif sort == "journal":