Files
tick-stock-panel/backend/tests/test_screener_external_access.py
T
shy3130 e89ea9becf fix: 修复 4 个 P1 issue (#224/#232/#223/#215)
- #224 screener 自定义 SQL 的内存连接关闭 enable_external_access,
  注入的 read_parquet/COPY 文件读写直接报错 (安全)
- #232 指数展示缓存百分数口径在消费边界显式 /100:
  pipeline._bench_rt_pct_of 与 abnormal_moves._bench_rt_pct 两处,
  修复 3/10/30 日偏离值被放大两个数量级
- #223 盘后管道按同日 daily/enriched 行数比较检测实时合并提前
  创建的部分分区, 删除后由增量重算全市场补齐
- #215 _basic_filter_for_asset 扩展中和股票专属键 (price_min/max/boards),
  并应用到回测/挖掘/策略扫描三个运行期入口, 修复 ETF 静默零信号
2026-09-03 13:01:43 +08:00

102 lines
3.1 KiB
Python

"""#224 回归: screener 自定义 SQL 的内存连接必须关闭 external_access。
conditions/order_by 是用户可控的 SQL 片段; 隔离连接若允许外部访问,
注入的 read_parquet/COPY 可读写任意文件 (文件写 RCE)。
"""
from __future__ import annotations
from datetime import date
from unittest.mock import MagicMock
import polars as pl
from app.services.screener import ScreenerService
def _service_with_panel(panel: pl.DataFrame) -> ScreenerService:
svc = ScreenerService(MagicMock(), asset_type="stock")
svc._load_enriched_for_date = lambda d: panel # type: ignore[method-assign]
return svc
def _panel() -> pl.DataFrame:
return pl.DataFrame(
{
"symbol": ["600000.SH", "000001.SZ"],
"close": [10.0, 20.0],
"turnover_rate": [1.0, 2.0],
}
)
def test_normal_condition_still_works() -> None:
svc = _service_with_panel(_panel())
result = svc.run(date(2026, 9, 2), ["close > 15"], limit=10)
assert [r["symbol"] for r in result.rows] == ["000001.SZ"]
def test_injected_read_parquet_is_rejected() -> None:
# 注入试图读任意文件: external_access 关闭后 DuckDB 直接报错,
# run_custom 的 except 分支吞错返回空结果, 而非泄漏文件内容
svc = _service_with_panel(_panel())
result = svc.run(
date(2026, 9, 2),
["1=1) UNION SELECT * FROM read_parquet('/etc/passwd') --"],
limit=10,
)
assert result.rows == []
def test_injected_copy_write_is_rejected(tmp_path) -> None:
target = tmp_path / "pwned.csv"
svc = _service_with_panel(_panel())
result = svc.run(
date(2026, 9, 2),
[f"close > 0); COPY enriched TO '{target}' --"],
limit=10,
)
assert result.rows == []
assert not target.exists()
def test_order_by_injection_also_isolated(tmp_path) -> None:
# order_by 同样是拼接片段, 不能借 external 函数逃逸
svc = _service_with_panel(_panel())
result = svc.run(
date(2026, 9, 2),
["close > 0"],
order_by=f"close; COPY enriched TO '{tmp_path / 'x.csv'}'",
limit=10,
)
assert result.rows == []
assert not (tmp_path / "x.csv").exists()
def test_external_access_switch_is_the_effective_barrier(tmp_path) -> None:
# 正反对照: 同一条注入 SQL, 未关 external_access 的普通内存连接能读到
# 任意 parquet 文件 (证明攻击面真实存在); 关闭后直接报错。
import duckdb
victim = tmp_path / "victim.parquet"
_panel().write_parquet(victim)
inject = f"SELECT * FROM read_parquet('{victim}')"
plain = duckdb.connect(database=":memory:")
try:
assert plain.execute(inject).pl().height == 2 # 普通连接: 可读 → 攻击面成立
finally:
plain.close()
hardened = duckdb.connect(
database=":memory:", config={"enable_external_access": False}
)
try:
raised = False
try:
hardened.execute(inject)
except Exception:
raised = True
assert raised, "external_access=False 的连接不应能读外部文件"
finally:
hardened.close()