Skip to content

Commit 147803b

Browse files
committed
Harden local server (Host/Origin gate vs DNS-rebind, confine import/report paths), atomic state-file writes, isolate per-creator save failures, empty-data + YouTube danmaku display guards
1 parent a1ecef0 commit 147803b

7 files changed

Lines changed: 72 additions & 14 deletions

File tree

scripts/app.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,23 @@ def slug(s):
214214
return s or "imported"
215215

216216

217+
def _inside(child: Path, base: Path) -> bool:
218+
"""child 解析后是否落在 base 之内(解析符号链接,防越权读盘外文件)。"""
219+
try:
220+
c, b = child.resolve(), base.resolve()
221+
except Exception:
222+
return False
223+
try:
224+
return c.is_relative_to(b) # Py3.9+
225+
except AttributeError:
226+
return c == b or b in c.parents # 老 Python 兜底
227+
228+
217229
def do_import(path):
218230
p = Path(path.strip().strip('"').strip("'"))
231+
# 只允许导入 DATA 目录内的文件:防攻击者借 /api/import 读任意 .json 再经 /api/data 外泄
232+
if not _inside(p, DATA):
233+
return {"ok": False, "error": "只能导入数据目录内的文件"}
219234
if not p.exists():
220235
return {"ok": False, "error": f"找不到文件:{p}"}
221236
if p.suffix.lower() != ".json":
@@ -243,6 +258,21 @@ class Handler(BaseHTTPRequestHandler):
243258
def log_message(self, *a):
244259
pass # 安静,别刷屏
245260

261+
def _local_ok(self):
262+
"""防 DNS rebinding:只认本机 Host(精确到端口);POST 再校验 Origin 必须是 loopback。
263+
远程网页拿到的 Host 是攻击者域名,据此拒掉,远程 JS 就驱动不了本地 API。"""
264+
port = self.server.server_address[1]
265+
host = (self.headers.get("Host") or "").strip()
266+
if host not in (f"127.0.0.1:{port}", f"localhost:{port}"):
267+
return False
268+
if self.command == "POST":
269+
origin = (self.headers.get("Origin") or "").strip()
270+
if origin:
271+
netloc = urlparse(origin).hostname
272+
if netloc not in ("127.0.0.1", "localhost"):
273+
return False
274+
return True
275+
246276
def _json(self, code, obj):
247277
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
248278
self.send_response(code)
@@ -263,6 +293,8 @@ def _file(self, target: Path):
263293
self.wfile.write(data)
264294

265295
def do_GET(self):
296+
if not self._local_ok():
297+
return self._json(403, {"error": "forbidden"})
266298
path = urlparse(self.path).path
267299
if path in ("/", "/index.html"):
268300
if not GUI.exists():
@@ -337,12 +369,15 @@ def do_GET(self):
337369
if path == "/report" or path.startswith("/report/"):
338370
rel = path[len("/report"):].lstrip("/") or "index.html"
339371
target = (REPORTS / rel).resolve()
340-
if not str(target).startswith(str(REPORTS.resolve())) or not target.exists():
372+
# 真·目录包含(resolve 已归一化 ..);startswith 会被 reports_x 这类兄弟目录绕过
373+
if not _inside(target, REPORTS) or not target.exists():
341374
return self._json(404, {"error": "还没有报告 —— 先点『抓取并分析』生成"})
342375
return self._file(target)
343376
return self._json(404, {"error": "not found"})
344377

345378
def do_POST(self):
379+
if not self._local_ok():
380+
return self._json(403, {"error": "forbidden"})
346381
path = urlparse(self.path).path
347382
length = int(self.headers.get("Content-Length", 0) or 0)
348383
raw = self.rfile.read(length) if length else b""

scripts/build_report.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ def section_table(videos):
202202
f'<td class="t">{title_html}</td>'
203203
f'<td data-v="{v.get("play") or 0}">{fmt_play(v.get("play"))}</td>'
204204
f'<td data-v="{v.get("comment") or 0}">{esc(v.get("comment"))}</td>'
205-
f'<td data-v="{v.get("danmaku") or v.get("like") or 0}">{esc(v.get("danmaku") if v.get("danmaku") is not None else v.get("like"))}</td>'
205+
f'<td data-v="{v.get("danmaku") or v.get("like") or 0}">{esc(v.get("danmaku") or v.get("like") or 0)}</td>'
206206
f'<td data-v="{v.get("duration_sec") or 0}">{fmt_dur(v.get("duration_sec"))}</td>'
207207
f'<td>{esc((v.get("created_iso") or "")[:10])}</td>'
208208
f'</tr>'

scripts/creator_profile.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ def safe_div(a, b):
4343

4444
def creator_stats(vids):
4545
"""一位 UP 主的结构性画像(可跨人对比的量)。"""
46+
if not vids:
47+
# 空数据集:返回零化但键齐全的画像,别让 len(feats)/len(vids) 除零崩掉整条 --report
48+
return {
49+
"play_med": 0,
50+
"in_format_rate": 0,
51+
"dur_med": 0,
52+
"gap_days": None,
53+
"comment_per_10k": 0,
54+
"danmaku_per_min": 0,
55+
}
4656
feats = [extract(v, NOW) for v in vids]
4757
plays = [f["play"] for f in feats]
4858
durs = [f["duration_sec"] for f in feats if f["duration_sec"] > 0]

scripts/enrich_bilibili.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from bilibili_api import video, Credential
2323

24-
from runtime import DATA
24+
from runtime import DATA, atomic_write_text
2525
from creators import CREATORS
2626

2727
try:
@@ -94,8 +94,8 @@ async def main():
9494
await asyncio.sleep(PACE)
9595
n_ok += await enrich_one(v, cred)
9696
if n_ok and n_ok % 10 == 0: # 边取边存,中断不丢
97-
p.write_text(json.dumps(vids, ensure_ascii=False, indent=2), encoding="utf-8")
98-
p.write_text(json.dumps(vids, ensure_ascii=False, indent=2), encoding="utf-8")
97+
atomic_write_text(p, json.dumps(vids, ensure_ascii=False, indent=2))
98+
atomic_write_text(p, json.dumps(vids, ensure_ascii=False, indent=2))
9999
print(f" ✓ {c['name']:<16} 三连补全 {n_ok}/{len(todo)} 条")
100100

101101
print("\n✅ 完成。重跑 scan_signals.py / 报告,会自动出现「三连率 / 投币率」维度")

scripts/fetch_covers.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
if hasattr(sys.stdout, "reconfigure"):
3131
sys.stdout.reconfigure(encoding="utf-8")
3232

33-
from runtime import DATA # 源码=仓库/data,打包成 app 时=用户数据目录
33+
from runtime import DATA, atomic_write_text # 源码=仓库/data,打包成 app 时=用户数据目录
3434
from creators import CREATORS
3535

3636
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
@@ -91,7 +91,10 @@ def main():
9191
print(f" ✗ 缺 {c['alias']}_videos.json"); continue
9292
vids = json.loads(vp.read_text(encoding="utf-8"))
9393
cp = DATA / f"{c['alias']}_covers.json"
94-
cache = json.loads(cp.read_text(encoding="utf-8")) if cp.exists() else {}
94+
try:
95+
cache = json.loads(cp.read_text(encoding="utf-8")) if cp.exists() else {}
96+
except Exception:
97+
cache = {} # 上次写了一半损坏了:当没缓存重建,别让整轮崩在读取上
9598

9699
jobs = [(v.get("bvid"), v.get("cover_url")) for v in vids
97100
if v.get("bvid") and v.get("cover_url") and v.get("bvid") not in cache]
@@ -116,8 +119,8 @@ def work(bvid, url):
116119
cache[bvid] = m
117120
n_new += 1
118121
if n_new % 10 == 0: # 边下边存,中断不丢
119-
cp.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
120-
cp.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
122+
atomic_write_text(cp, json.dumps(cache, ensure_ascii=False, indent=2))
123+
atomic_write_text(cp, json.dumps(cache, ensure_ascii=False, indent=2))
121124
print(f" ✓ {c['name']}: 封面 {len(cache)}/{len(vids)} (新增 {n_new}, 失败 {n_fail})")
122125

123126
print("\n✅ 封面指标已缓存,可重跑 scan_signals.py 看封面与播放的相关性")

scripts/fetch_multi.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
if hasattr(sys.stdout, "reconfigure"):
2727
sys.stdout.reconfigure(encoding="utf-8")
2828

29-
from runtime import DATA # 可写数据目录(源码=仓库/data,app=用户目录;runtime 已自动建好)
29+
from runtime import DATA, atomic_write_text # 可写数据目录(源码=仓库/data,app=用户目录;runtime 已自动建好)
3030

3131
try:
3232
from config_local import SESSDATA
@@ -65,7 +65,7 @@ def _save_result(c, platform, videos):
6565
print(f" ✗ {c['name']} [{platform}]: 没抓到视频")
6666
return False
6767
out = DATA / f"{c['alias']}_videos.json"
68-
out.write_text(json.dumps(videos, ensure_ascii=False, indent=2), encoding="utf-8")
68+
atomic_write_text(out, json.dumps(videos, ensure_ascii=False, indent=2))
6969
hi, lo = videos[0], videos[-1] # 适配器按播放降序返回:hi=最高播放,lo=最低
7070
# 时间跨度要按日期算,不能拿播放排序的首尾凑(那是"最低播放的日期 ~ 最高播放的日期")
7171
isos = sorted(i for i in (v.get("created_iso") for v in videos) if i)
@@ -111,10 +111,10 @@ async def run_bili_serial():
111111
await asyncio.sleep(2.0)
112112
try:
113113
videos = await fetch_for(c)
114+
n_ok += _save_result(c, "bilibili", videos) # 写入也包进 try:一人写失败不拖垮已抓到的别人
114115
except Exception as e:
115116
print(f" ✗ {c['name']} [bilibili]: {type(e).__name__}: {e}")
116117
continue
117-
n_ok += _save_result(c, "bilibili", videos)
118118

119119
async def run_others_parallel():
120120
"""YouTube(及未来的无风控平台):线程池并发;按清单顺序收割,输出顺序稳定。"""
@@ -134,13 +134,13 @@ def work(c, platform):
134134
for (c, platform), t in zip(others, tasks):
135135
try:
136136
videos = await t
137+
n_ok += _save_result(c, platform, videos) # 写入也包进 try:一人写失败不拖垮已抓到的别人
137138
except Exception as e:
138139
print(f" ✗ {c['name']} [{platform}]: {type(e).__name__}: {e}")
139140
continue
140-
n_ok += _save_result(c, platform, videos)
141141

142142
# 两条线同时跑:B 站在等风控间隔时,YouTube 在并行抓
143-
await asyncio.gather(run_bili_serial(), run_others_parallel())
143+
await asyncio.gather(run_bili_serial(), run_others_parallel(), return_exceptions=True)
144144
print(f"\n✅ 完成,{n_ok} 个创作者已写 data/<alias>_videos.json")
145145

146146

scripts/runtime.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ def bootstrap() -> None:
7979
_BOOTSTRAPPED = True
8080

8181

82+
def atomic_write_text(path, text: str, encoding: str = "utf-8") -> None:
83+
"""原子写文本:先写同目录临时文件,再 os.replace 覆盖目标。
84+
中断(断电/Ctrl+C)时目标文件要么是旧内容、要么是新内容,绝不会是写了一半的残缺数据。
85+
用于不可再生的主数据(<alias>_videos.json / 封面缓存等)。"""
86+
path = Path(path)
87+
tmp = path.with_name(path.name + ".tmp")
88+
tmp.write_text(text, encoding=encoding)
89+
os.replace(tmp, path)
90+
91+
8292
def worker_cmd(script: str, args=None):
8393
"""拼出「用本工具自己的解释器跑某个子脚本」的命令行。
8494
源码模式: [python, scripts/<script>, *args] (和历史行为一致)

0 commit comments

Comments
 (0)