audit: resident website loop architecture and code quality to 10/10
Fix F1 name race with partial unique index, F2 pause/end mid-poll race with guarded updates, F3 redirect/numeric-IP SSRF, F4 quota prefix check, F5 duplicate row_get, F6 audit ternary; S1 unused import, S2 backoff doc, S3 defensive tearDown. Directed 20 pass, full 1700 pass.
This commit is contained in:
1 parent
23725f92e6
commit
6525bcafd0
5 files changed
+586
-80
No files matched your search
@@ -2,19 +2,23 @@
|
||||
抓取一律注入假 fetcher。覆盖创建校验、暂停保游标、恢复续跑、
|
||||
结束终态、防重复轮询、仅变化建任务、失败退避、配额耗尽转暂停、
|
||||
worker 驱动批量入口。"""
|
||||
import http.server
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
try:
|
||||
import cloud.resident as resident_module
|
||||
from cloud.resident import (
|
||||
claim_due_watch,
|
||||
create_watch,
|
||||
default_fetch,
|
||||
end_watch,
|
||||
ensure_resident_tables,
|
||||
get_watch,
|
||||
@@ -24,14 +28,17 @@ try:
|
||||
resume_watch,
|
||||
run_due_resident_watch,
|
||||
update_watch,
|
||||
validate_target_url,
|
||||
)
|
||||
from cloud.server import init_db
|
||||
from cloud.worker import Worker
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
import cloud.resident as resident_module
|
||||
from cloud.resident import (
|
||||
claim_due_watch,
|
||||
create_watch,
|
||||
default_fetch,
|
||||
end_watch,
|
||||
ensure_resident_tables,
|
||||
get_watch,
|
||||
@@ -41,6 +48,7 @@ except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.p
|
||||
resume_watch,
|
||||
run_due_resident_watch,
|
||||
update_watch,
|
||||
validate_target_url,
|
||||
)
|
||||
from cloud.server import init_db
|
||||
from cloud.worker import Worker
|
||||
@@ -314,6 +322,172 @@ class ResidentTestCase(unittest.TestCase):
|
||||
self.assertTrue(all(p["status"] == "changed" for p in polls))
|
||||
self.assertEqual(len(self._tasks()), 2)
|
||||
|
||||
def test_url_validation_blocks_localhost_and_numeric_forms(self):
|
||||
for bad in ("http://localhost./x", "http://foo.localhost/x",
|
||||
"http://2130706433/x", "http://0x7f000001/x",
|
||||
"http://0177.0.0.1/x", "http://0x7f.0.0.1/x"):
|
||||
with self.assertRaises(ValueError, msg=bad):
|
||||
validate_target_url(bad)
|
||||
# 规范点分十进制公网 IP 与带尾点的普通域名仍放行。
|
||||
self.assertEqual(validate_target_url("https://8.8.8.8/x"),
|
||||
"https://8.8.8.8/x")
|
||||
self.assertEqual(validate_target_url("https://example.com./x"),
|
||||
"https://example.com./x")
|
||||
|
||||
def test_unique_index_guards_concurrent_name_clash(self):
|
||||
idx = self.conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index'"
|
||||
" AND name = 'idx_resident_watches_name'").fetchone()
|
||||
self.assertIsNotNone(idx)
|
||||
self._watch()
|
||||
# 正常路径仍先报 ValueError。
|
||||
with self.assertRaises(ValueError):
|
||||
self._watch()
|
||||
# 绕过 SELECT 查重直接插:唯一索引本身必须拦住,
|
||||
# 这才是并发下真正的关口。
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.conn.execute(
|
||||
"INSERT INTO resident_watches(id, project_id, name,"
|
||||
" target_url, prompt, interval_seconds, status,"
|
||||
" created_by, created_at, updated_at)"
|
||||
" VALUES('dup', 'proj', 'watch1', ?, 'p', 60,"
|
||||
" 'running', 'owner1', ?, ?)",
|
||||
(URL, self.now, self.now))
|
||||
other = self._watch(name="watch2")
|
||||
with self.assertRaises(ValueError):
|
||||
update_watch(self.conn, other["id"], {"name": "watch1"},
|
||||
now=self.now)
|
||||
|
||||
def test_quota_check_failed_is_transient_not_paused(self):
|
||||
fetcher = _Fetcher("v1")
|
||||
watch = self._watch()
|
||||
run_due_resident_watch(self.conn, watch["id"],
|
||||
fetcher=fetcher, now=self.now)
|
||||
cursor_hash = get_watch(self.conn, watch["id"],
|
||||
now=self.now)["last_content_hash"]
|
||||
original = resident_module._automation_quota_exceeded
|
||||
resident_module._automation_quota_exceeded = lambda conn, owner: (
|
||||
"quota_check_failed: boom")
|
||||
try:
|
||||
fetcher.value = "v2"
|
||||
self.now += 61
|
||||
poll = run_due_resident_watch(
|
||||
self.conn, watch["id"], fetcher=fetcher, now=self.now)
|
||||
finally:
|
||||
resident_module._automation_quota_exceeded = original
|
||||
self.assertEqual(poll["status"], "changed_quota_check_failed")
|
||||
after = get_watch(self.conn, watch["id"], now=self.now)
|
||||
self.assertEqual(after["status"], "running")
|
||||
self.assertEqual(after["last_content_hash"], cursor_hash)
|
||||
self.assertEqual(after["consecutive_failures"], 1)
|
||||
self.assertEqual(after["next_poll_at"], self.now + 60 + 30)
|
||||
self.assertEqual(len(self._tasks()), 0)
|
||||
|
||||
def test_pause_during_fetch_aborts_without_task(self):
|
||||
fetcher = _Fetcher("v1")
|
||||
watch = self._watch()
|
||||
run_due_resident_watch(self.conn, watch["id"],
|
||||
fetcher=fetcher, now=self.now)
|
||||
cursor_hash = get_watch(self.conn, watch["id"],
|
||||
now=self.now)["last_content_hash"]
|
||||
db_path = self.db_path
|
||||
|
||||
def pausing_fetcher(url, timeout):
|
||||
conn2 = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn2.row_factory = sqlite3.Row
|
||||
pause_watch(conn2, watch["id"], now=self.now + 61)
|
||||
finally:
|
||||
conn2.close()
|
||||
return "v2"
|
||||
|
||||
self.now += 61
|
||||
poll = run_due_resident_watch(
|
||||
self.conn, watch["id"], fetcher=pausing_fetcher, now=self.now)
|
||||
self.assertIsNone(poll)
|
||||
after = get_watch(self.conn, watch["id"], now=self.now)
|
||||
self.assertEqual(after["status"], "paused")
|
||||
self.assertIsNone(after["next_poll_at"])
|
||||
self.assertEqual(after["last_content_hash"], cursor_hash)
|
||||
self.assertEqual(len(self._tasks()), 0)
|
||||
self.assertEqual(
|
||||
[p["status"] for p in list_polls(self.conn, watch["id"])],
|
||||
["baseline"])
|
||||
|
||||
|
||||
class RedirectFetchTestCase(unittest.TestCase):
|
||||
"""重定向校验:只把 redirect-test.test 指到本地,其它主机
|
||||
走原始解析;no_proxy 同款处理,tearDown 全部还原。"""
|
||||
|
||||
HOST = "redirect-test.test"
|
||||
|
||||
def setUp(self):
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/go":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/final")
|
||||
elif self.path == "/bad":
|
||||
self.send_response(302)
|
||||
self.send_header(
|
||||
"Location", "http://127.0.0.1:1/secret")
|
||||
else:
|
||||
body = b"final-body"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
self.server = http.server.ThreadingHTTPServer(
|
||||
("127.0.0.1", 0), Handler)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self._orig_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
def patched(host, port, family=0, type=0, proto=0, flags=0):
|
||||
if host == self.HOST:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "",
|
||||
("127.0.0.1", port))]
|
||||
return self._orig_getaddrinfo(
|
||||
host, port, family, type, proto, flags)
|
||||
|
||||
socket.getaddrinfo = patched
|
||||
self._old_no_proxy = os.environ.get("no_proxy")
|
||||
self._old_NO_PROXY = os.environ.get("NO_PROXY")
|
||||
os.environ["no_proxy"] = (
|
||||
(self._old_no_proxy + ",") if self._old_no_proxy
|
||||
else "") + self.HOST
|
||||
os.environ["NO_PROXY"] = (
|
||||
(self._old_NO_PROXY + ",") if self._old_NO_PROXY
|
||||
else "") + self.HOST
|
||||
|
||||
def tearDown(self):
|
||||
socket.getaddrinfo = self._orig_getaddrinfo
|
||||
if self._old_no_proxy is None:
|
||||
os.environ.pop("no_proxy", None)
|
||||
else:
|
||||
os.environ["no_proxy"] = self._old_no_proxy
|
||||
if self._old_NO_PROXY is None:
|
||||
os.environ.pop("NO_PROXY", None)
|
||||
else:
|
||||
os.environ["NO_PROXY"] = self._old_NO_PROXY
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
def test_relative_redirect_followed_and_private_blocked(self):
|
||||
base = "http://%s:%d" % (self.HOST, self.port)
|
||||
self.assertEqual(default_fetch(base + "/go", 10), "final-body")
|
||||
with self.assertRaises(ValueError):
|
||||
default_fetch(base + "/bad", 10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -114,13 +114,18 @@ class WebsiteLoopTestCase(unittest.TestCase):
|
||||
returncode=0, stdout="{}", stderr=""))
|
||||
|
||||
def tearDown(self):
|
||||
socket.getaddrinfo = self._orig_getaddrinfo
|
||||
if self._old_no_proxy is None:
|
||||
os.environ.pop("no_proxy", None)
|
||||
# setUp 中途失败时属性可能未建:getattr 防御,避免
|
||||
# tearDown 的 AttributeError 掩盖原始失败。
|
||||
if getattr(self, "_orig_getaddrinfo", None) is not None:
|
||||
socket.getaddrinfo = self._orig_getaddrinfo
|
||||
if getattr(self, "_old_no_proxy", None) is None:
|
||||
if "_old_no_proxy" in self.__dict__:
|
||||
os.environ.pop("no_proxy", None)
|
||||
else:
|
||||
os.environ["no_proxy"] = self._old_no_proxy
|
||||
if self._old_NO_PROXY is None:
|
||||
os.environ.pop("NO_PROXY", None)
|
||||
if getattr(self, "_old_NO_PROXY", None) is None:
|
||||
if "_old_NO_PROXY" in self.__dict__:
|
||||
os.environ.pop("NO_PROXY", None)
|
||||
else:
|
||||
os.environ["NO_PROXY"] = self._old_NO_PROXY
|
||||
try:
|
||||
@@ -128,15 +133,27 @@ class WebsiteLoopTestCase(unittest.TestCase):
|
||||
self.content_server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
self.content_thread.join(timeout=5)
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.content_thread.join(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.thread.join(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
try:
|
||||
self.tmp.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port,
|
||||
|
||||
Reference in new issue
Block a user