@@ -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+
217229def 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""
0 commit comments