残留批次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:
1 parent
6e3eb7ba11
commit
9bb9a50e4e
10 files changed
+725
-38
No files matched your search
+127
-10
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user