P1 security batch: quota execution checks, cost accounting, goal/coordinator semantics, PR channel, orphan signature, control-plane and console hardening
- Docker quota watchdog + fail-closed quota checks, system tasks need real owner - max_cost_usd actually accumulated, cancel records cost, estimated rows excluded from unpriced cap, cache_write declared non-billed - goal-resume keeps original verify on breakpoint resume, goal retry falls back to resume, goal_max_rounds capped at 100 at create and execution - coordinator children inherit parent model/effort, orphans marked interrupted with audit - PR jobs on docker workers return explicit needs_local_worker instead of silent empty - worker code signature covers 6 files and is checked before claim, fail-closed - sub2api lazy provisioning with rotation, backup whitelist + 0600, push SSRF block, repos/workers desensitized, key/project audits and limits, console buttons/quota/status wording, coordinator/automation quota reserve
This commit is contained in:
1 parent
d254240d84
commit
1402dc67fd
20 files changed
+2616
-179
No files matched your search
+125
-5
@@ -37,15 +37,22 @@
|
||||
发送端是后续待接线的工作,见模块末尾的缺口说明。
|
||||
- 本模块不跑真实模型回合。webhook/slack 的真实发送只 POST
|
||||
到订阅者自己填的 target URL(创建订阅已要求项目
|
||||
create_task 权限;能建订阅的成员本来就能经任务执行代码,
|
||||
因此不对 target 做内网屏蔽——屏蔽既拦不住提权,也会误伤
|
||||
本地接收器)。密钥、token、证书一律不由本模块读取、写入
|
||||
或返回。
|
||||
create_task 权限)。P1 收紧后:真实发送层对 target 做 SSRF
|
||||
校验——loopback、私有网段、链路本地、云元数据地址一律不
|
||||
POST;主机名先 DNS 解析再按解析出的 IP 判,解析失败拒绝
|
||||
(见 validate_push_target)。本地接收器如需放行,须运维显式
|
||||
置 CODEX_PUSH_ALLOW_PRIVATE_TARGETS=1(默认关闭,测试用
|
||||
本地接收器也须经此开关,不在代码里默认放行 loopback)。
|
||||
密钥、token、证书一律不由本模块读取、写入或返回。
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from cloud.db import connect as _db_connect
|
||||
from cloud.db import PgConnection as _PgConnection
|
||||
@@ -67,6 +74,7 @@ __all__ = [
|
||||
"send_webhook",
|
||||
"send_slack",
|
||||
"dispatch_event",
|
||||
"validate_push_target",
|
||||
]
|
||||
|
||||
CHANNELS = ("webhook", "slack", "webpush", "apns", "fcm")
|
||||
@@ -311,6 +319,91 @@ def _require_http_url(target, channel):
|
||||
return target
|
||||
|
||||
|
||||
# 云元数据主机名:即使解析出的 IP 恰好不在私有段,也直接挡。
|
||||
_BLOCKED_HOSTNAMES = frozenset({"metadata.google.internal"})
|
||||
|
||||
|
||||
def _allow_private_targets():
|
||||
"""运维显式开关:仅 CODEX_PUSH_ALLOW_PRIVATE_TARGETS=1 时放行
|
||||
内网目标(本地接收器场景)。默认关闭;开关只影响 IP 段判定,
|
||||
不影响 scheme 校验。"""
|
||||
return os.environ.get(
|
||||
"CODEX_PUSH_ALLOW_PRIVATE_TARGETS", "").strip() == "1"
|
||||
|
||||
|
||||
def _default_resolver(hostname):
|
||||
"""默认 DNS 解析:返回 IP 字符串列表;解析失败抛异常,
|
||||
由 validate_push_target 转成“拒绝”。"""
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
out = []
|
||||
for info in infos:
|
||||
sockaddr = info[4] if len(info) > 4 else None
|
||||
if sockaddr and sockaddr[0]:
|
||||
out.append(str(sockaddr[0]))
|
||||
return out
|
||||
|
||||
|
||||
def _ip_is_blocked(ip_text):
|
||||
"""非全局单播 IP 一律算内网/特殊地址:loopback、私有网段、
|
||||
链路本地(169.254/16,含云元数据 169.254.169.254)、保留段、
|
||||
组播、未指定地址都在 is_global=False 里。"""
|
||||
try:
|
||||
return not ipaddress.ip_address(ip_text).is_global
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
def validate_push_target(target, resolver=None, check_dns=True):
|
||||
"""SSRF 校验:通过返回 None,否则返回原因字符串(不抛)。
|
||||
|
||||
顺序:scheme(http/https)-> 主机名存在 -> 元数据主机名 ->
|
||||
IP 字面量直接判段 -> (check_dns 时)DNS 解析后逐 IP 判段,
|
||||
解析失败或解析出空结果都拒绝(DNS 重绑定下解析时与发送时
|
||||
仍可能不一致,这是本层能做的上限,见残留说明)。
|
||||
check_dns=False 时只做字面量判定:供直接调 send_* 但不想
|
||||
做 DNS 的调用方用;dispatch 的真实发送路径一律
|
||||
check_dns=True,显式 sender 路径不走本预检(见 dispatch)。
|
||||
"""
|
||||
if not isinstance(target, str) or not (
|
||||
target.startswith("http://") or target.startswith("https://")):
|
||||
return "target must be an http(s) URL"
|
||||
try:
|
||||
host = urllib.parse.urlparse(target).hostname
|
||||
except ValueError:
|
||||
return "target host is invalid"
|
||||
if not host:
|
||||
return "target host required"
|
||||
lowered = host.lower()
|
||||
if lowered in _BLOCKED_HOSTNAMES or lowered.endswith(
|
||||
".metadata.google.internal"):
|
||||
return "target host is a cloud metadata address"
|
||||
if _allow_private_targets():
|
||||
return None
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
is_literal = True
|
||||
except ValueError:
|
||||
is_literal = False
|
||||
if is_literal:
|
||||
if _ip_is_blocked(host):
|
||||
return "target resolves to a non-public address: %s" % host
|
||||
return None
|
||||
if not check_dns:
|
||||
return None
|
||||
resolve = resolver or _default_resolver
|
||||
try:
|
||||
ips = resolve(host)
|
||||
except Exception:
|
||||
return "DNS resolution failed for target host"
|
||||
if not ips:
|
||||
return "DNS resolution failed for target host"
|
||||
for ip_text in ips:
|
||||
if _ip_is_blocked(ip_text):
|
||||
return ("target host resolves to a non-public address: %s"
|
||||
% ip_text)
|
||||
return None
|
||||
|
||||
|
||||
def send_webhook(message):
|
||||
"""webhook 通道真实发送:POST JSON 到订阅的 target URL。
|
||||
|
||||
@@ -319,6 +412,11 @@ def send_webhook(message):
|
||||
RuntimeError(dispatch 记入 errors,不算 sent)。
|
||||
"""
|
||||
target = _require_http_url(message["target"], "webhook")
|
||||
blocked = validate_push_target(
|
||||
target, resolver=message.get("_resolver"))
|
||||
if blocked is not None:
|
||||
raise RuntimeError(
|
||||
"webhook delivery failed: target blocked: %s" % blocked)
|
||||
payload = message["payload"] if isinstance(
|
||||
message["payload"], dict) else {}
|
||||
ok, detail = _post_json(target, {
|
||||
@@ -340,6 +438,11 @@ def send_slack(message):
|
||||
事件附 error 摘要(截断 500 字)。非 2xx 抛 RuntimeError。
|
||||
"""
|
||||
target = _require_http_url(message["target"], "slack")
|
||||
blocked = validate_push_target(
|
||||
target, resolver=message.get("_resolver"))
|
||||
if blocked is not None:
|
||||
raise RuntimeError(
|
||||
"slack delivery failed: target blocked: %s" % blocked)
|
||||
payload = message["payload"] if isinstance(
|
||||
message["payload"], dict) else {}
|
||||
text = "[codex] task %s %s (project %s)" % (
|
||||
@@ -357,7 +460,8 @@ def send_slack(message):
|
||||
DEFAULT_SENDERS = {"webhook": send_webhook, "slack": send_slack}
|
||||
|
||||
|
||||
def dispatch_event(db, event_type, project_id, payload, sender=None):
|
||||
def dispatch_event(db, event_type, project_id, payload, sender=None,
|
||||
resolver=None):
|
||||
"""分发一次状态变化事件,只发本项目的订阅。
|
||||
|
||||
参数:
|
||||
@@ -418,6 +522,7 @@ def dispatch_event(db, event_type, project_id, payload, sender=None):
|
||||
}]
|
||||
return result
|
||||
|
||||
real_send = sender is None
|
||||
if sender is None:
|
||||
# 内置真实发送端:webhook/slack 真 POST;显式 sender
|
||||
# 传入时不走这里(注入优先),webpush/apns/fcm 永远
|
||||
@@ -445,6 +550,21 @@ def dispatch_event(db, event_type, project_id, payload, sender=None):
|
||||
result["errors"].append(
|
||||
dict(base, reason=MISSING_CREDENTIAL_REASONS[sub["channel"]]))
|
||||
continue
|
||||
# SSRF 预检(真实发送路径,发送前挡,不 POST):含 DNS
|
||||
# 的全量校验。显式 sender 路径不预检:注入的 sender 是
|
||||
# 调用方自控的投递(测试 seam),不是服务端发起的 HTTP,
|
||||
# 挡它不增加生产安全性;生产服务端一律走 sender=None
|
||||
# 的真实发送,必经本预检与 send_webhook/send_slack 的
|
||||
# 二次校验。原因文案含 "delivery failed",与旧调用方
|
||||
# 对投递失败的断言兼容。
|
||||
if real_send:
|
||||
blocked = validate_push_target(
|
||||
sub["target"], resolver=resolver, check_dns=True)
|
||||
if blocked is not None:
|
||||
result["errors"].append(
|
||||
dict(base, reason="%s delivery failed: target blocked:"
|
||||
" %s" % (sub["channel"], blocked)))
|
||||
continue
|
||||
# webhook/slack:sender=None 时已换成内置真实发送端;
|
||||
# 显式 sender 里没有该通道时明确报错,不静默成功。
|
||||
channel_sender = _resolve_channel_sender(sender, sub["channel"])
|
||||
|
||||
Reference in new issue
Block a user