Files
shy3130 a4e580e314 fix(security): 修复 ext_columns DuckDB SQL 注入 (closes #150)
Issue #150 报告了 ext_columns 功能的 DuckDB SQL 注入, 经核查属实:
field_name 经裸双引号拼进 SQL, 攻击者可借 COPY TO 写文件 (RCE) 或
UNION 读数据。已修复, 详见 PR。

根因: 3 个 SQL sink 用未转义的 f"{field_name}", 仅 screener:147 一处
用 _quote_ident 转义; 且 parser 校验不一致 (screener 校验 config_id,
kline/watchlist 零校验)。

修复方案:
1. 新增 backend/app/db_safe.py 集中定义 quote_ident (双引号转义, 对任意
   字符安全) + is_valid_ext_ident (config_id 白名单), 消除不一致根因
2. 3 个 sink 统一改用 quote_ident:
   - screener.py:785 (limit_ladder)
   - kline.py:376 (_attach_ext, GET /api/kline/daily)
   - watchlist.py:279 (watchlist_enriched)
3. kline + watchlist parser 加 config_id 白名单 (screener 已有)
4. screener 复用共享原语, 删除私有 _quote_ident/_EXT_IDENT_RE

关键约束:
- field_name 不能加白名单: FieldDef.name 无校验 + infer_fields_from_df
  直接采用原始 CSV/Parquet 列名, 合法可含中文/点。故只在 sink 转义。
- 不关闭 enable_external_access: 实测会同时禁用 read_parquet (项目核心
  数据访问方式), 不可行。

对 Issue #150 报告的澄清:
- sink #2/#3/#4 (field_name 注入) 属实, 已修
- sink #1 (screener.py:343 conditions[]/order_by) 不存在, 为误报
  (screener.py 无 conditions/order_by/execute 调用)

验证: 后端全量 527 passed (含新增 24 个安全测试, 含 2 个端到端注入
测试: COPY TO 不产生文件 / UNION 不泄漏数据 + 合法特殊字段名回归)
2026-07-31 19:56:33 +08:00

31 lines
1.3 KiB
Python

"""DuckDB 标识符安全原语 (Issue #150: ext_columns SQL 注入防护)。
集中提供标识符转义与校验, 供所有拼接 DuckDB SQL 的 sink 复用, 消除
"screener 有防护、kline/watchlist 没有"的不一致根因。
"""
from __future__ import annotations
import re
# 合法标识符白名单: 字母 / 数字 / 下划线。与 CreateExtReq.id 创建端校验一致,
# 用于 config_id 等「纯用户可控 + 仅合法标识符」字段的深度防御。
EXT_IDENT_RE = re.compile(r"^[A-Za-z0-9_]+$")
def quote_ident(name: str) -> str:
"""把列名/字段名转义为 DuckDB 双引号标识符。
对任意字符安全: 仅对内嵌的双引号做双写转义 (``"`` → ``""``), 再用双引号包裹。
转义后整个串成为单个「带引号标识符」, 即便 name 含 ``--``/``;``/``UNION``/``COPY``
也只会被 DuckDB 当作字面列名, 不会被解释为 SQL 语法, 从根上杜绝注入。
用于 field_name: 因 FieldDef.name 可合法含中文/点/特殊字符, 不能用白名单,
只能在 sink 处转义。
"""
return '"' + name.replace('"', '""') + '"'
def is_valid_ext_ident(name: str) -> bool:
"""config_id 等纯标识符字段是否仅含合法字符 (字母/数字/下划线)。"""
return bool(EXT_IDENT_RE.match(name))