Files
codex-app-server/cloud/tests/test_push_toctou.py
T
host 9bb9a50e4e 残留批次A:成员删除、PR needs_local_worker 落库、推送 DNS TOCTOU
- 成员删除:DELETE /projects/{id}/members/{user_id},限 owner 或
  全局管理员,禁删唯一 owner,记 remove_member 审计
  (AuthStore.remove_member + server 分发,沿用 PATCH 守卫口径)
- PR needs_local_worker 落库:非 local 扫到 queued 任务时 CAS
  写行状态为 needs_local_worker;claim/find_open 同步认该状态,
  标记过的行仍可被 local worker 认领、不被搁置;另修
  claim/find_open 对裸 sqlite3 tuple 行的 dict() 兜底
  (TypeError/ValueError)
- 推送 DNS TOCTOU:发送改走建连时解析+判段的连接类,解析
  一次、只拨判过的 IP,Host/SNI 仍为原域名;opener 直接构造
  以排除 ProxyHandler 改写(build_opener 默认会塞代理)
2026-10-27 19:10:49 +00:00

173 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""推送 DNS TOCTOU 单测(残留批次 A 任务 3)。
旧版:validate_push_target 解析一次,urlopen 建连时又解析
一次;两次解析结果不一致(DNS 重绑定)时可能拨向未验证
的内网 IP。新版:建连时解析一次并判段、只拨判过的 IP,
Host 头与 TLS SNI 仍为原域名。
- 两次解析不一致(先公网、后内网)时:发送失败,且一次
都没有拨向内网 IP;
- 混合解析结果(先内网、后公网)时:只拨公网 IP;
- HTTP 真服务器:域名经 resolver 钉到 127.0.0.1,服务器
收到的 Host 仍为原域名;
- HTTPS 连接类:拨号地址为判过的 IP,wrap_socket 的
server_hostname 仍为原域名(SNI 不变)。
测试用的 IP、域名、key 全是假值;除本地回环服务器外不联网。
"""
import http.client
import http.server
import os
import sys
import threading
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud.push import (
_checked_connection_class, _post_json, send_webhook)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.push import (
_checked_connection_class, _post_json, send_webhook)
PUBLIC_IP = "8.8.8.8"
PRIVATE_IP = "169.254.169.254"
class _FlipFlopResolver:
"""第一次返回公网 IP,之后返回内网 IP:模拟重绑定。
旧版两次解析恰好踩中这个窗口;新版第二次解析判段后
直接拒绝,绝不拨号。
"""
def __init__(self):
self.calls = 0
def __call__(self, host):
self.calls += 1
return [PUBLIC_IP] if self.calls == 1 else [PRIVATE_IP]
class _DialRecorder:
"""记录拨号地址后直接失败:测试只断言拨向,不跑完 HTTP。"""
def __init__(self):
self.calls = []
def __call__(self, address, timeout=None, source_address=None):
self.calls.append(address)
raise OSError("dial stopped by test")
class _DummySocket:
def setsockopt(self, *args):
pass
class _RecordingHandler(http.server.BaseHTTPRequestHandler):
requests = []
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b""
_RecordingHandler.requests.append({
"host": self.headers.get("Host"),
"body": body,
})
self.send_response(200)
self.send_header("Content-Length", "2")
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, *args):
pass
class PushToctouTestCase(unittest.TestCase):
def setUp(self):
self._old_allow = os.environ.get("CODEX_PUSH_ALLOW_PRIVATE_TARGETS")
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
_RecordingHandler.requests = []
def tearDown(self):
if self._old_allow is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old_allow
def _message(self, target, resolver=None):
msg = {"event_type": "completed", "project_id": "proj-a",
"payload": {"task_id": "t-1"}, "target": target}
if resolver is not None:
msg["_resolver"] = resolver
return msg
def test_rebinding_second_resolution_never_dialed(self):
resolver = _FlipFlopResolver()
dials = _DialRecorder()
with mock.patch("socket.create_connection", dials):
with self.assertRaises(RuntimeError):
send_webhook(self._message(
"http://hook.example.invalid/hook", resolver))
# 两次解析确实都发生了(一次预检、一次建连),但建连侧
# 判段后拒绝:内网 IP 一次都没被拨。
self.assertEqual(resolver.calls, 2)
self.assertEqual(dials.calls, [])
def test_mixed_resolution_dials_only_public(self):
dials = _DialRecorder()
with mock.patch("socket.create_connection", dials):
ok, _detail = _post_json(
"http://hook.example.invalid/hook", {"a": 1},
resolver=lambda host: [PRIVATE_IP, PUBLIC_IP])
self.assertFalse(ok)
self.assertEqual(dials.calls, [(PUBLIC_IP, 80)])
def test_http_pinned_host_header_preserved(self):
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = "1"
server = http.server.HTTPServer(("127.0.0.1", 0),
_RecordingHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
send_webhook(self._message(
"http://webhook.test.invalid:%d/hook" % port,
resolver=lambda host: ["127.0.0.1"]))
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
self.assertEqual(len(_RecordingHandler.requests), 1)
host_header = _RecordingHandler.requests[0]["host"]
# 拨的是 127.0.0.1,但 Host 仍为原域名(虚拟主机依赖它)。
self.assertEqual(host_header.split(":")[0],
"webhook.test.invalid")
def test_https_sni_preserved_with_pinned_ip(self):
wrap_calls = []
class FakeContext:
def wrap_socket(self, sock, server_hostname=None):
wrap_calls.append(server_hostname)
return sock
cls = _checked_connection_class(http.client.HTTPSConnection,
lambda host: [PUBLIC_IP])
conn = cls("example.com", 443)
conn._context = FakeContext()
dials = []
def fake_create(address, timeout=None, source_address=None):
dials.append(address)
return _DummySocket()
with mock.patch("socket.create_connection", fake_create):
conn.connect()
self.assertEqual(dials, [(PUBLIC_IP, 443)])
self.assertEqual(wrap_calls, ["example.com"])