Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,47 @@ jobs:
- name: Cargo test
run: cargo test --locked
working-directory: src-tauri

# 为什么必须有 Linux 一档(2026-09-18 补):
# Windows 检出是 CRLF、且 CI 机器有网络/DNS,于是下面两类缺陷在 Windows 上「全绿」:
# ① LF 检出才暴露的测试缺陷(例如逐行注释剥离正则遇到 LF 会吃掉字符串里的 `//`);
# ② 依赖真实 DNS 的用例(离线/禁网运行器下才变红,且反例是「因解析失败而空过」的假绿)。
# 只跑 pytest + 前端测试(不含 cargo:Linux 上 Tauri 需要额外系统库,成本不值当)。
checks-linux:
name: Checks (Linux / LF + no-DNS)
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 24

- name: Install frontend dependencies
run: npm ci

- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.12'

- name: Install Python dependencies
run: pip install -r requirements.txt -r requirements-dev.txt

- name: Run pytest
run: pytest -q

# 仓库自带的强隔离门禁:清空 WORKBUDDY* 环境变量、把用户目录指向临时目录、
# 禁真实 DNS/socket。此前它常年红(与用例互相矛盾),故无人接入 CI;现已修复。
- name: "Run pytest (isolated: no network, disposable user data)"
run: python tests/run_isolated_tests.py

- name: Three-source sync check (GPT_FALLBACK_MAP <-> GPT_PREMARKED)
run: python scripts/check_premarked_sync.py

- name: Run frontend unit tests
run: npm test
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,27 @@ Tauri v2 桌面应用 + Python 反代内核。

```bash
./.venv/Scripts/python.exe -m pytest tests/ -q # Python:423 passed 为当前基线 (含长思考超时与防惊群契约)
./.venv/Scripts/python.exe tests/run_isolated_tests.py # 同上,但在「禁真实 DNS/socket + 用户目录指向临时目录 + 清空 WORKBUDDY* 环境变量」下再跑一遍
npm test # 前端:202 passed(node --test)
cd src-tauri && cargo test --quiet # Rust:85 passed
```

⚠️ **裸 `python -m pytest` 会失败**(`No module named pytest`)——`python` 命中的是
Hermes 运行时 venv,不是本仓 venv。必须用 `./.venv/Scripts/python.exe`。

⚠️ **改测试或写「看源码做结构/负向断言」的用例前必读(2026-09-18 修复)**:
① 剥离注释**不能用逐行正则** `line.replace(/\/\/.*$/,'')`——它既会在 LF 下吃掉字符串字面量里的
`//`(`format!("https://…")` 整行尾部被剪掉,正向断言恒失败),又会在 CRLF 下因 `.` 不匹配 `\r`
而完全空转(负向断言把注释算进代码,写一句解释性注释即误报)。统一用
`tests/helpers/strip-rust-comments.mjs`(会跳过字符串/字符字面量,LF/CRLF 结果一致)。
② 用例**不得依赖真实 DNS / 网络**(如放行 `example.com`):离线或隔离运行器下,正例会红,
反例则「因解析失败提前返回」而**空过**——即断言看着绿、其实没验证到那条防线。
需要走通公网分支时,打桩 `converter._resolve_host_ips`。
③ 路径断言的期望值要用与实现相同的 `path.join` 推导,别写死 `z:/workbuddy/` 这类正则
(Windows 下 `path.join('Z:\','workbuddy')` 是 `Z:\workbuddy`,POSIX 下是 `Z:\/workbuddy`)。
④ 修完三类问题后 `pytest` / 隔离运行器 / `npm test` 在 **LF 与 CRLF 检出下都必须全绿**,
CI 的 `checks-linux`(LF + 禁网)一档就是为守这条而加。

### 构建:用户自己跑,助手不要绕

**构建由仓库主自己执行**:`npm run tauri build`(PowerShell,仓库根)。
Expand Down
99 changes: 52 additions & 47 deletions converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from pathlib import Path
from contextlib import asynccontextmanager
from contextvars import ContextVar
from typing import Optional
from typing import Any, Optional

import httpx
from fastapi import FastAPI, Header, HTTPException, Request
Expand Down Expand Up @@ -1277,43 +1277,62 @@ def _resolve_host_ips(host: str) -> list:
return ips


def _url_is_safe_for_fetch(url: str) -> tuple[bool, str]:
"""SSRF 前置校验:仅允许 http(s)、禁止内网/回环/元数据地址。

返回 (是否安全, 拒绝原因)。DNS 解析后逐个校验 IP,任一落在内网即拒绝
(防 DNS rebinding 式的「一公网一内网」混合解析)。
"""
from urllib.parse import urlsplit
try:
parts = urlsplit(url)
except Exception as e:
return False, f"URL 解析失败: {e}"
def _resolve_public_connect_ip(host: str) -> tuple[Optional[str], str]:
"""把主机解析为「可直接连接的公网 IP」;拒绝时返回 (None, 原因)。

if parts.scheme not in ("http", "https"):
return False, f"不支持的协议: {parts.scheme}"
这是 SSRF 主机/IP 判定的**唯一实现**:URL 级校验 `_url_is_safe_for_fetch`
与实际下载路径 `_url_to_data_uri`(逐跳)都经由它,杜绝两处逻辑漂移
—— 历史教训是「一处被改、另一处才是真正生效的防线」,改动者据此产生虚假信心。

host = (parts.hostname or "").strip().lower()
规则:
- 命中本机/云元数据主机名单(含 `*.localhost` 后缀)直接拒绝;
- 纯 IP 字面量:无需 DNS,直接判定;
- 域名:要求**每一个**解析结果都是公网地址(防「一公网一内网」混合解析),
并返回首个公网 IP 供调用方**绑定连接**,从而消除「校验一次、连接时再解析
一次」的 DNS rebinding 窗口。
"""
host = (host or "").strip().lower()
if not host:
return False, "缺少主机名"
return None, "缺少主机名"
if host in _BLOCKED_IMAGE_HOSTS or host.endswith(".localhost"):
return False, f"禁止访问本机/元数据主机: {host}"
return None, f"禁止访问本机/元数据主机: {host}"

# 纯 IP 直连:直接判定,无需 DNS
try:
ip = ipaddress.ip_address(host)
if not _ip_is_public(ip):
return False, f"禁止访问内网/保留地址: {ip}"
return True, ""
literal = ipaddress.ip_address(host)
except ValueError:
pass
literal = None
if literal is not None:
if not _ip_is_public(literal):
return None, f"禁止访问内网/保留地址: {literal}"
return str(literal), ""

ips = _resolve_host_ips(host)
if not ips:
return False, f"域名无法解析: {host}"
return None, f"域名无法解析: {host}"
for ip in ips:
if not _ip_is_public(ip):
return False, f"域名 {host} 解析到内网/保留地址: {ip}"
return True, ""
return None, f"域名 {host} 解析到内网/保留地址: {ip}"
return str(ips[0]), ""


def _url_is_safe_for_fetch(url: str) -> tuple[bool, str]:
"""SSRF 前置校验(URL 级):仅允许 http(s),禁止内网/回环/元数据地址。

返回 (是否安全, 拒绝原因)。主机/IP 判定全部委托给 `_resolve_public_connect_ip`
—— 与实际下载路径同源,不存在第二份实现。
"""
from urllib.parse import urlsplit
try:
parts = urlsplit(url)
except Exception as e:
return False, f"URL 解析失败: {e}"

if parts.scheme not in ("http", "https"):
return False, f"不支持的协议: {parts.scheme}"

_, reason = _resolve_public_connect_ip(parts.hostname or "")
return (False, reason) if reason else (True, "")


async def _url_to_data_uri(url: str, timeout: float = 15.0) -> str:
Expand Down Expand Up @@ -1354,28 +1373,14 @@ async def _url_to_data_uri(url: str, timeout: float = 15.0) -> str:
parts = urlsplit(current)
orig_host = (parts.hostname or "").strip()
port = parts.port or (443 if parts.scheme == "https" else 80)

# DNS 解析并在公网 IP 中取已验证的 IP 建立直连,防 DNS rebinding
try:
ip_literal = ipaddress.ip_address(orig_host)
if not _ip_is_public(ip_literal):
_log(f"🚫 远程图片禁止访问内网/保留地址: {ip_literal}", level="warning")
return url
connect_ip = str(ip_literal)
except ValueError:
resolved_ips = _resolve_host_ips(orig_host)
if not resolved_ips:
_log(f"🚫 远程图片域名无法解析: {orig_host}", level="warning")
return url
for ip in resolved_ips:
if not _ip_is_public(ip):
_log(f"🚫 远程图片域名 {orig_host} 解析到内网/保留地址: {ip}", level="warning")
return url
public_ips = [ip for ip in resolved_ips if _ip_is_public(ip)]
if not public_ips:
_log(f"🚫 远程图片域名无有效公网 IP: {orig_host}", level="warning")
return url
connect_ip = str(public_ips[0])

# 与 _url_is_safe_for_fetch 共用同一判定(含本机/元数据主机名单),
# 并把连接绑定到已校验的 IP,杜绝「校验一次、连接时再解析一次」的
# DNS rebinding 窗口。
connect_ip, reason = _resolve_public_connect_ip(orig_host)
if not connect_ip:
_log(f"🚫 远程图片拒绝下载({reason}): {current[:60]}", level="warning")
return url

# 构造直连 URL 与连接扩展
target_host = f"[{connect_ip}]" if ":" in connect_ip else connect_ip
Expand Down
Loading
Loading