残留批次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 默认会塞代理)
This commit is contained in:
host committed 2026-10-27 19:10:49 +00:00
1 parent 6e3eb7ba11
commit 9bb9a50e4e
10 files changed
+725 -38

No files matched your search

+127 -10
View File
@@ -46,6 +46,8 @@
密钥、token、证书一律不由本模块读取、写入或返回。
"""
import errno
import http.client
import ipaddress
import json
import os
@@ -285,13 +287,16 @@ def _resolve_channel_sender(sender, channel):
_PUSH_SEND_TIMEOUT = 10
def _post_json(url, payload):
def _post_json(url, payload, resolver=None):
"""POST JSON 到 url:返回 (ok, detail),2xx 为 ok。
HTTP 状态错误与网络异常一律返回 (False, reason),不抛
(调用方据此记 errors);只有调用方传了非法 URL 时
Request 构造本身可能抛 ValueError,由发送端先校验 scheme
挡住,不走到这里。
建连时解析一次并判段、钉 IP 直连(见 _checked_opener):
Host 头与 TLS SNI 仍为原域名,只把拨号地址换成判过的
IP——预检解析与建连解析不一致时,建连侧的判定为准,
绝不会拨向未验证的 IP。HTTP 状态错误与网络异常一律
返回 (False, reason),不抛(调用方据此记 errors);
只有调用方传了非法 URL 时 Request 构造本身可能抛
ValueError,由发送端先校验 scheme 挡住,不走到这里。
"""
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
@@ -299,7 +304,7 @@ def _post_json(url, payload):
headers={"Content-Type": "application/json",
"User-Agent": "codex-app-server"})
try:
with urllib.request.urlopen(
with _checked_opener(resolver).open(
request, timeout=_PUSH_SEND_TIMEOUT) as response:
response.read()
return True, "HTTP %s" % response.status
@@ -309,6 +314,114 @@ def _post_json(url, payload):
return False, "%s: %s" % (type(exc).__name__, exc)
def _pick_connect_ip(host, resolver):
"""建连时解析一次并判段,返回要拨的 IP(只抛 OSError)。
字面量 IP 不经过 DNS(只复核段);域名解析一次:
allow-private 时取首个,否则取首个全局单播 IP;无可用
IP 时抛 OSError。调用方拨的就是这里判过的 IP,之后
不再二次解析——校验与建连之间的 DNS 重绑定窗口就此
闭合(旧版 validate 后 urlopen 再解析一次的 TOCTOU)。
"""
if _allow_private_targets():
try:
ipaddress.ip_address(host)
return host
except ValueError:
pass
ips = (resolver or _default_resolver)(host)
if not ips:
raise OSError("DNS resolution failed for target host")
return ips[0]
try:
ipaddress.ip_address(host)
except ValueError:
pass
else:
if _ip_is_blocked(host):
raise OSError(
"target resolves to a non-public address: %s" % host)
return host
ips = (resolver or _default_resolver)(host)
if not ips:
raise OSError("DNS resolution failed for target host")
for ip_text in ips:
if not _ip_is_blocked(ip_text):
return ip_text
raise OSError("target host resolves to a non-public address")
def _checked_connection_class(base, resolver):
"""建连时解析+判段的连接类:Host 头与 TLS SNI 仍为原域名
(self.host 不动),只把拨号地址换成判过的 IP。
resolver 由调用方注入(测试 mock DNS 用);为 None 时
用默认解析。代理隧道场景(_tunnel_host 已置)仍走基类:
隧道里解析权在代理方,钉 IP 不适用,预检判定为准。
"""
class CheckedConnection(base):
def connect(self):
if getattr(self, "_tunnel_host", None):
super().connect()
return
picked = _pick_connect_ip(self.host, resolver)
self.sock = socket.create_connection(
(picked, self.port), self.timeout, self.source_address)
try:
self.sock.setsockopt(
socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except OSError as exc:
if exc.errno != errno.ENOPROTOOPT:
raise
if isinstance(self, http.client.HTTPSConnection):
self.sock = self._context.wrap_socket(
self.sock, server_hostname=self.host)
return CheckedConnection
class _CheckedHTTPHandler(urllib.request.HTTPHandler):
def __init__(self, resolver=None):
super().__init__()
self._resolver = resolver
def http_open(self, req):
return self.do_open(
_checked_connection_class(http.client.HTTPConnection,
self._resolver), req)
class _CheckedHTTPSHandler(urllib.request.HTTPSHandler):
def __init__(self, resolver=None, debuglevel=0, context=None,
check_hostname=None):
super().__init__(debuglevel=debuglevel, context=context,
check_hostname=check_hostname)
self._resolver = resolver
def https_open(self, req):
return self.do_open(
_checked_connection_class(http.client.HTTPSConnection,
self._resolver), req,
context=self._context, check_hostname=self._check_hostname)
def _checked_opener(resolver=None):
"""发送用 opener:建连时解析+判段的连接类 + 重定向/错误处理。
刻意直接构造 OpenerDirector、不用 build_opener:后者会
自动塞入 ProxyHandler,本机 egress 代理会把请求改写成
代理端口、钉 IP 失效(实测拨号跑到代理端口)。钉 IP 的
前提是直连;代理环境下发送会直连失败并记入 errors
(loud,不静默成功)。重定向仍跟随,但每一跳都重新
走建连判定(新目标同样解析+判段)。
"""
opener = urllib.request.OpenerDirector()
opener.add_handler(_CheckedHTTPHandler(resolver))
opener.add_handler(_CheckedHTTPSHandler(resolver))
opener.add_handler(urllib.request.HTTPRedirectHandler())
opener.add_handler(urllib.request.HTTPErrorProcessor())
return opener
def _require_http_url(target, channel):
"""发送端 target 校验:只接受 http(s) URL,其他一律 ValueError
(dispatch 记入 errors,不静默成功、不乱 POST)。"""
@@ -358,8 +471,11 @@ def validate_push_target(target, resolver=None, check_dns=True):
顺序:scheme(http/https)-> 主机名存在 -> 元数据主机名 ->
IP 字面量直接判段 -> (check_dns 时)DNS 解析后逐 IP 判段,
解析失败或解析出空结果都拒绝(DNS 重绑定下解析时与发送时
仍可能不一致,这是本层能做的上限,见残留说明)。
解析失败或解析出空结果都拒绝。本预检只是 fail-fast:
真正起强制作用的是发送时建连类的解析+判段(解析一次、
只拨判过的 IP),预检解析与建连解析不一致时以建连侧
的判定为准,不会拨向未验证的 IP(旧版 validate 后
urlopen 再解析一次的 TOCTOU 已闭合)。
check_dns=False 时只做字面量判定:供直接调 send_* 但不想
做 DNS 的调用方用;dispatch 的真实发送路径一律
check_dns=True,显式 sender 路径不走本预检(见 dispatch)。
@@ -424,7 +540,7 @@ def send_webhook(message):
"project_id": message["project_id"],
"task_id": payload.get("task_id"),
"payload": message["payload"],
})
}, resolver=message.get("_resolver"))
if not ok:
raise RuntimeError("webhook delivery failed: %s" % detail)
@@ -450,7 +566,8 @@ def send_slack(message):
message["project_id"])
if payload.get("error"):
text += "\nerror: %s" % str(payload["error"])[:500]
ok, detail = _post_json(target, {"text": text})
ok, detail = _post_json(target, {"text": text},
resolver=message.get("_resolver"))
if not ok:
raise RuntimeError("slack delivery failed: %s" % detail)