P0 security batch: true global admin, quota gates on all spending paths, default quota for quota-less users, fix-review author trust + docker/owner attribution, GitHub App setup issuer binding, real cancel kill, wider shell env exclusion
- CODEX_GLOBAL_ADMINS replaces maintainer-derived global admin; admin endpoints (quotas, sweep, backup, push dispatch) fail-closed 403 when unset, with audit on allow and deny. - continue/followups/messages/retry check both caller and original creator quotas (USD + unpriced token cap), 402 on either overrun. - Quota-less users now get the #86 default quota instead of unlimited; init_db backfills and OAuth create/bind/existing-login branches INSERT OR IGNORE a default row; explicit NULL row stays unlimited. user_quotas added to migration tables. - /fix-review: untrusted authors rejected before dedup claim, no task; trusted tasks force executor=docker, created_by=project owner, owner quota gate, prompt is a fixed template plus a 2000-char excerpt. - GitHub App setup state only issued by global admins, state rows bind issued_by (ALTER-compatible for old DBs), callback rejects issuers no longer in the admin list without consuming the state. - Cancel: popen poll checks DB status every second, Docker path runs a cancel watchdog that kills by container name, write-back is CAS-guarded so late results never overwrite a new status. - SHELL_ENV_EXCLUDE adds *_TOKEN, *_AUTH*, *_CREDENTIAL*, *API_KEY*; comment states the residual risk: same-UID /proc environ remains readable, true isolation needs UID separation or hidepid. - Tests: new cloud/tests/test_p0_security.py (17 cases); old tests updated to the new semantics (admin env, default quota, trusted fix-review author) without weakening any gate.
This commit is contained in:
1 parent
d0ca1f7f60
commit
7091a2d0d2
13 files changed
+1017
-146
No files matched your search
+229
-95
@@ -146,6 +146,7 @@ from cloud.github_app import (
|
||||
)
|
||||
from cloud.webhooks import (
|
||||
WebhookDeliveryStore,
|
||||
is_trusted_fix_review_author,
|
||||
parse_github_check_run,
|
||||
parse_github_pr_comment,
|
||||
parse_slack_command,
|
||||
@@ -979,11 +980,12 @@ def _default_quota_usd_micros():
|
||||
|
||||
|
||||
def _apply_default_quota(conn, user_id):
|
||||
"""给新用户写默认配额行(已存在则不动,幂等)。
|
||||
"""给用户写默认配额行(已存在则不动,幂等)。
|
||||
|
||||
只在 OAuth 建号(created=True)时调:INSERT OR IGNORE
|
||||
保证重复回调不覆盖管理员后来改过的额度。返回 True 表示
|
||||
本次写了新行(调用方据此记 quota_default 审计)。
|
||||
INSERT OR IGNORE 保证重复回调不覆盖管理员后来改过的额度。
|
||||
返回 True 表示本次写了新行(调用方据此记 quota_default
|
||||
审计)。P0 安全批后:OAuth 新建、绑定、已存在用户登录
|
||||
三条分支都调它补行,不再只给新建用户补。
|
||||
"""
|
||||
_ensure_quotas_table(conn)
|
||||
cur = conn.execute(
|
||||
@@ -997,6 +999,43 @@ def _apply_default_quota(conn, user_id):
|
||||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def _global_admin_ids():
|
||||
"""真全局管理员清单:环境变量 CODEX_GLOBAL_ADMINS。
|
||||
|
||||
逗号分隔的用户 id 列表,空白项忽略。未配置或为空时返回
|
||||
空集——此时 admin 端点一律 fail-closed,绝不回落到项目
|
||||
角色推导(P0 安全批前“任一项目 maintainer 即全局管理”
|
||||
的推导已被废掉,见 _is_global_admin)。
|
||||
"""
|
||||
raw = os.environ.get("CODEX_GLOBAL_ADMINS") or ""
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
def _backfill_default_quotas(conn):
|
||||
"""给已有无行用户回填默认配额行(init_db 时调,幂等)。
|
||||
|
||||
只补“users 里有、user_quotas 里无”的用户,已有行(含
|
||||
管理员显式设为 NULL 的不限额行)一律不动。返回补写行数。
|
||||
"""
|
||||
_ensure_quotas_table(conn)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO user_quotas(user_id,"
|
||||
" quota_usd_micros, updated_at)"
|
||||
" SELECT u.id, ?, ? FROM users u"
|
||||
" LEFT JOIN user_quotas q ON q.user_id = u.id"
|
||||
" WHERE q.user_id IS NULL",
|
||||
(_default_quota_usd_micros(), time.time()))
|
||||
conn.commit()
|
||||
return cur.rowcount or 0
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
_ENV_BRANCH_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
|
||||
|
||||
|
||||
@@ -1058,6 +1097,7 @@ def init_db(db_path):
|
||||
_ensure_followups_table(conn)
|
||||
_ensure_environments_table(conn)
|
||||
_ensure_quotas_table(conn)
|
||||
_backfill_default_quotas(conn)
|
||||
_ensure_gateway_keys(conn)
|
||||
_ensure_uploads_tables(conn)
|
||||
conn.commit()
|
||||
@@ -1091,6 +1131,7 @@ def init_db(db_path):
|
||||
_ensure_followups_table(conn)
|
||||
_ensure_environments_table(conn)
|
||||
_ensure_quotas_table(conn)
|
||||
_backfill_default_quotas(conn)
|
||||
_ensure_uploads_tables(conn)
|
||||
# worker 每轮 poll 都按 status 取任务(见 load_tasks 的
|
||||
# WHERE status IN (...)),无索引时是全表扫描;任务表随
|
||||
@@ -3005,6 +3046,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
user_id, task["project_id"], "create_task"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
if self._reject_if_over_quota_for_task(user_id, task):
|
||||
return
|
||||
text = body.get("body")
|
||||
if not text or not isinstance(text, str) or not text.strip():
|
||||
self._send_json(400, {"error": "body required"})
|
||||
@@ -3143,6 +3186,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
user_id, sender["project_id"], "read"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
if self._reject_if_over_quota_for_task(user_id, target):
|
||||
return
|
||||
text = body.get("body")
|
||||
if not text or not isinstance(text, str) or not text.strip():
|
||||
self._send_json(400, {"error": "body required"})
|
||||
@@ -3680,6 +3725,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
user_id, task["project_id"], "create_task"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
if self._reject_if_over_quota_for_task(user_id, task):
|
||||
return
|
||||
if task["status"] in ("queued", "running"):
|
||||
self._send_json(409, {
|
||||
"error": "进行中任务请用后续消息继续",
|
||||
@@ -3812,8 +3859,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
interrupted / merge-failed(其它状态一律 409:running
|
||||
本来就在跑,queued 本来就在等,completed/merged 已有
|
||||
结果,重跑应是新任务而不是覆盖结果)。
|
||||
配额按任务创建者查(费用记在创建者名下):维护者替
|
||||
他人重试时,操作人超限不应拦、创建者超限才应拦。
|
||||
配额按双人门查(P0 安全批,见
|
||||
_reject_if_over_quota_for_task):触发人与原任务创建
|
||||
人任一超限即拦。费用仍记在创建者名下,所以只查创建
|
||||
者不够——触发人用自己的额度过门、费用记他人名下,
|
||||
仍是绕限;只查触发人也不够——原创建者已超限时,
|
||||
替他人重试仍会把费用记到已超限的账上。
|
||||
成功后状态回 queued,上一轮的 result_text/error_text/
|
||||
actual_model 清空(否则新一轮的结果会和旧结果混在一起
|
||||
无法分辨);prompt、模型、优先级、依赖原样保留,依赖
|
||||
@@ -3832,8 +3883,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
user_id, task["project_id"], "approve_merge"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
if self._reject_if_over_quota(
|
||||
user_id, quota_user_id=task.get("created_by") or user_id):
|
||||
if self._reject_if_over_quota_for_task(user_id, task):
|
||||
return
|
||||
if task["status"] not in ("failed", "interrupted", "merge-failed"):
|
||||
self._send_json(409, {
|
||||
@@ -3882,9 +3932,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _quota_status(self, user_id):
|
||||
"""返回 (quota_usd_micros|None, used_usd_micros, month)。
|
||||
quota 为 None 表示不限额:老用户与未设配额的用户默认
|
||||
不限额,行为与过去一致,不会因为上了配额功能就突然
|
||||
被挡住。"""
|
||||
P0 安全批后:无配额行不再等于不限额——缺行时按默认
|
||||
配额处理(与 _default_quota_usd_micros() 同值,只在
|
||||
返回值里合成,不在这里写行;写行由 _apply_default_quota
|
||||
/ _backfill_default_quotas 负责)。只有管理员显式把
|
||||
行写成 NULL 时,quota 才为 None(不限额)。"""
|
||||
month = self._quota_month()
|
||||
conn = self._db()
|
||||
try:
|
||||
@@ -3902,7 +3954,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
"SELECT quota_usd_micros FROM user_quotas"
|
||||
" WHERE user_id = ?",
|
||||
(user_id,)).fetchone()
|
||||
quota = row[0] if row is not None else None
|
||||
if row is None:
|
||||
quota = _default_quota_usd_micros()
|
||||
else:
|
||||
quota = row[0]
|
||||
used = conn.execute(
|
||||
"SELECT COALESCE(SUM(c.usd_micros), 0)"
|
||||
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
|
||||
@@ -3959,22 +4014,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
def _check_unpriced_token_cap(self, user_id):
|
||||
"""无价 token 上限准入:超限返回 (cap, used, month),否则 None。
|
||||
|
||||
只有有配额行的用户才受限(无配额行 = 不限额,与 USD
|
||||
口径一致);USD 配额本身不计量无价模型,所以这道是
|
||||
补 USD 口径的洞,不是第二套计费。"""
|
||||
P0 安全批后:无配额行用户同样受限(无价 token 上限与
|
||||
配额行存在与否无关);USD 配额本身不计量无价模型,所
|
||||
以这道是补 USD 口径的洞,不是第二套计费。"""
|
||||
month = self._quota_month()
|
||||
conn = self._db()
|
||||
try:
|
||||
_ensure_quotas_table(conn)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT quota_usd_micros FROM user_quotas"
|
||||
" WHERE user_id = ?",
|
||||
(user_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
used = self._quota_unpriced_tokens(user_id, month)
|
||||
cap = unpriced_token_cap()
|
||||
if used >= cap:
|
||||
@@ -3987,9 +4030,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
太快,所以不用 429。
|
||||
|
||||
准入点:建任务 / 分支 / 重试 / 续跑,均为权限校验
|
||||
通过后、写库之前。后续消息(followup)创建不查:
|
||||
排队本身不花钱,执行时轮次边界的配额门照常生效,
|
||||
不要顺手把 followup 也改成准入校验。
|
||||
通过后、写库之前。P0 安全批后,后续消息与任务间
|
||||
消息创建时同样查双人配额门(见
|
||||
_reject_if_over_quota_for_task):排队本身不花钱,
|
||||
但续跑/复活必然花钱,只靠执行时轮次边界门拦不住
|
||||
“超限用户靠他人任务续命”,所以创建时先拦一道,
|
||||
执行时轮次边界门仍照常生效。
|
||||
|
||||
跑中掐断(与旧 docstring 不同,旧说法已过时):
|
||||
Local popen 路径的 worker 轮询每 30 秒查一次配额,
|
||||
@@ -4030,19 +4076,43 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_global_maintainer(self, user_id):
|
||||
"""全局管理判定:沿用 _admin_backup 的口径——在任一
|
||||
项目里是 maintainer/owner 即算(没有单设的全局管理
|
||||
员表;没有任何项目角色的用户无权改任何人的配额)。"""
|
||||
conn = self._db()
|
||||
def _reject_if_over_quota_for_task(self, caller_id, task):
|
||||
"""花钱路径的双人配额门:触发人 + 原任务创建人。
|
||||
|
||||
任一超限即 402(USD 配额与无价 token cap 都查)。
|
||||
触发人等于创建人时只查一次。调用方须在权限校验后、
|
||||
写状态前调它;返回 True 表示已回 402,调用方直接
|
||||
return。"""
|
||||
if self._reject_if_over_quota(caller_id):
|
||||
return True
|
||||
creator = (task or {}).get("created_by")
|
||||
if creator and creator != caller_id:
|
||||
if self._reject_if_over_quota(creator):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _audit_admin(self, user_id, action, detail):
|
||||
"""admin 调用审计(成功与被拒都记,失败绝不挡主流程)。"""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM project_members"
|
||||
" WHERE user_id = ? AND role IN ('maintainer', 'owner')",
|
||||
(user_id,)).fetchone()
|
||||
return (row[0] if row is not None else 0) > 0
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, "", action, detail)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_global_admin(self, user_id):
|
||||
"""真全局管理员:只认 CODEX_GLOBAL_ADMINS。
|
||||
|
||||
未配置时返回 False(fail-closed)。P0 安全批前“任一
|
||||
项目 maintainer 即全局管理”的推导已被废掉:项目角色
|
||||
只管项目内权限,不管全系统管理权。部署时运维必须先
|
||||
把管理员用户 id 写进 CODEX_GLOBAL_ADMINS 再重启,
|
||||
否则 admin 端点对所有人都是 403(数据不丢,改 env
|
||||
重启即恢复)。"""
|
||||
return user_id is not None and user_id in _global_admin_ids()
|
||||
|
||||
def _is_global_maintainer(self, user_id):
|
||||
"""旧名兼容:等价于 _is_global_admin(推导口径已废)。"""
|
||||
return self._is_global_admin(user_id)
|
||||
|
||||
def _user_exists(self, target):
|
||||
conn = self._db()
|
||||
@@ -4061,7 +4131,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._quota_unpriced_tokens(user_id)))
|
||||
|
||||
def _admin_quota_get(self, user_id, query):
|
||||
if not self._is_global_maintainer(user_id):
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_quota_denied",
|
||||
"get quotas")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
target = (query.get("user_id") or [None])[0]
|
||||
@@ -4078,7 +4150,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._quota_unpriced_tokens(target)))
|
||||
|
||||
def _admin_quota_set(self, user_id):
|
||||
if not self._is_global_maintainer(user_id):
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_quota_denied",
|
||||
"set quotas")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
body, err = self._read_json()
|
||||
@@ -4323,14 +4397,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
def _admin_sweep(self, user_id):
|
||||
"""POST /admin/sweep:把超阈值的陈旧 running 任务标 interrupted。
|
||||
|
||||
权限:与 _admin_backup 同口径——调用方须在任一项目里
|
||||
是 maintainer 以上,否则 403。过去这里没有任何权限检查,
|
||||
任意持 key 用户都能跨用户改任务状态。threshold_seconds
|
||||
下限钳到 60 秒:0 会把所有 running 任务一口气扫掉,
|
||||
只能是管理员的显式紧急操作才需要那么小的值,而 60 秒
|
||||
以下的“陈旧”本就没有意义。扫完记 admin_sweep 审计。
|
||||
权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin),
|
||||
未配置时一律 403。threshold_seconds 下限钳到 60 秒:
|
||||
0 会把所有 running 任务一口气扫掉,只能是管理员的
|
||||
显式紧急操作才需要那么小的值,而 60 秒以下的“陈旧”
|
||||
本就没有意义。扫完记 admin_sweep 审计,被拒也记审计。
|
||||
"""
|
||||
if not self._is_global_maintainer(user_id):
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_sweep_denied", "sweep")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
body, err = self._read_json()
|
||||
@@ -4354,32 +4428,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
def _admin_backup(self, user_id):
|
||||
"""POST /admin/backup:备份当前后端数据库,返回路径与规模信息。
|
||||
|
||||
权限:调用方须在至少一个项目里是 maintainer 以上(全局
|
||||
管理端点没有单个项目上下文,按“任一项目 maintainer+”
|
||||
判定;无此角色返回 403)。返回只含 backend、备份文件
|
||||
路径、文件大小与任务行数,绝不返回数据库密码、备份文件
|
||||
内容或任何凭据。out_dir 可由 body 指定,否则走环境变量
|
||||
CLOUD_BACKUP_DIR,未设则用 cloud-backups。
|
||||
权限:只认 CODEX_GLOBAL_ADMINS(见 _is_global_admin),
|
||||
未配置时一律 403。返回只含 backend、备份文件路径、
|
||||
文件大小与任务行数,绝不返回数据库密码、备份文件内容
|
||||
或任何凭据。out_dir 可由 body 指定,否则走环境变量
|
||||
CLOUD_BACKUP_DIR,未设则用 cloud-backups。成功与被拒
|
||||
都记审计(审计里只记路径与规模,不记库内容)。
|
||||
"""
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_backup_denied", "backup")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
member_rows = conn.execute(
|
||||
"SELECT role FROM project_members WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
role_levels = {"viewer": 0, "contributor": 1,
|
||||
"maintainer": 2, "owner": 3}
|
||||
is_maintainer = any(
|
||||
role_levels.get(row[0], 0) >= 2 for row in member_rows)
|
||||
if not is_maintainer:
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
out_dir = body.get("out_dir") or os.environ.get(
|
||||
"CLOUD_BACKUP_DIR") or "cloud-backups"
|
||||
if not isinstance(out_dir, str) or not out_dir:
|
||||
@@ -4403,6 +4466,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
except Exception as exc:
|
||||
self._send_json(500, {"error": "backup failed: %s" % (exc,)})
|
||||
return
|
||||
self._audit_admin(
|
||||
user_id, "admin_backup",
|
||||
"backend=%s path=%s size=%d tasks=%d"
|
||||
% (backend, dest, size_bytes, task_count))
|
||||
self._send_json(200, {
|
||||
"backend": backend, "path": dest,
|
||||
"size_bytes": size_bytes, "task_count": task_count,
|
||||
@@ -4571,6 +4638,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
auth, provider, identity, bind_user_id)
|
||||
auth.audit(bind_user_id, "", "bind_external",
|
||||
"%s:%s" % (provider, identity.get("external_id")))
|
||||
if _apply_default_quota(auth.conn, bind_user_id):
|
||||
auth.audit(bind_user_id, "", "quota_default",
|
||||
"quota_usd_micros=%d"
|
||||
% _default_quota_usd_micros())
|
||||
identity["already_bound"] = binding["already"]
|
||||
return (bind_user_id, False, None, False, identity, True)
|
||||
bound_user_id = find_bound_user(auth, provider, identity)
|
||||
@@ -4580,6 +4651,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
auth.audit(bound_user_id, "", "oauth_login",
|
||||
"%s:%s" % (provider, identity.get("external_id")))
|
||||
auth.audit(bound_user_id, "", "api_key_issued", provider)
|
||||
if _apply_default_quota(auth.conn, bound_user_id):
|
||||
auth.audit(bound_user_id, "", "quota_default",
|
||||
"quota_usd_micros=%d"
|
||||
% _default_quota_usd_micros())
|
||||
return (bound_user_id, False, api_key_plain, False, identity,
|
||||
False)
|
||||
local_id, created, api_key_plain, rotated = \
|
||||
@@ -4587,7 +4662,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
auth, provider, identity)
|
||||
auth.audit(local_id, "", "oauth_login",
|
||||
"%s:%s" % (provider, identity.get("external_id")))
|
||||
if created and _apply_default_quota(auth.conn, local_id):
|
||||
if _apply_default_quota(auth.conn, local_id):
|
||||
auth.audit(local_id, "", "quota_default",
|
||||
"quota_usd_micros=%d"
|
||||
% _default_quota_usd_micros())
|
||||
@@ -4864,17 +4939,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
html.escape(str(message))))
|
||||
|
||||
def _github_app_setup_state(self):
|
||||
"""签发 setup state:须持本站 key(只有操作方能发起建 App)。
|
||||
"""签发 setup state:只限全局管理员(见 _is_global_admin)。
|
||||
|
||||
要求环境变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
|
||||
P0 安全批前任何持 key 用户都能签发并回调覆盖全局
|
||||
App 配置;现在非管理员直接 403 并记审计。要求环境
|
||||
变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
|
||||
https://api.diante.me):manifest 里的 hook / 回调地址
|
||||
都由它拼出。返回 {state, setup_url},setup_url 是用户
|
||||
在浏览器里打开的表单页;state 一次性、1 小时过期。
|
||||
在浏览器里打开的表单页;state 一次性、1 小时过期,
|
||||
且绑定签发人。
|
||||
"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "github_app_setup_denied",
|
||||
"setup state")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
base = self._github_app_public_base()
|
||||
if not base:
|
||||
self._send_json(400, {
|
||||
@@ -4886,7 +4969,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
org = (body or {}).get("org")
|
||||
store = SetupStateStore(self.server.db_path)
|
||||
try:
|
||||
state = store.create_state()
|
||||
state = store.create_state(issued_by=user_id)
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
@@ -4968,6 +5051,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
报错误页,不存任何东西。成功页只显示 App 身份(slug、
|
||||
id、主页)与安装链接,绝不显示 webhook_secret 与 pem
|
||||
(两者只在库里,server 进程内用)。
|
||||
P0 安全批后:回调要求签发人仍在 CODEX_GLOBAL_ADMINS
|
||||
里,否则 state 不消耗、直接报 invalid_state;覆盖全局
|
||||
配置时记审计。诚实边界:回调是浏览器无认证重定向,
|
||||
这里核对的是“签发人仍是管理员”,不是“回调者就是
|
||||
签发人”(见 SetupStateStore.consume_state 文档)。
|
||||
"""
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
code = (query.get("code") or [None])[0]
|
||||
@@ -4978,13 +5066,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
store = SetupStateStore(self.server.db_path)
|
||||
try:
|
||||
ok = store.consume_state(state)
|
||||
issuer = store.state_issuer(state)
|
||||
ok = store.consume_state(
|
||||
state, allowed_issuers=_global_admin_ids())
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if not ok:
|
||||
self._audit_admin(
|
||||
issuer or "unknown", "github_app_callback_denied",
|
||||
"state issuer not a global admin")
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"invalid_state", "setup state invalid or expired"))
|
||||
return
|
||||
@@ -5001,6 +5094,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
save_app_config(conn, config)
|
||||
finally:
|
||||
conn.close()
|
||||
self._audit_admin(
|
||||
issuer or "unknown", "github_app_config_replaced",
|
||||
"slug=%s app_id=%s" % (config.get("slug"),
|
||||
config.get("app_id")))
|
||||
slug = config.get("slug") or ""
|
||||
page = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
@@ -5130,17 +5227,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
if not event_type or not project_id:
|
||||
self._send_json(400, {"error": "event_type and project_id required"})
|
||||
return
|
||||
# 分发者须是该项目的成员(至少可读),否则不能往别人项目里推。
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
if not auth.check_permission(user_id, project_id, "read"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
# P0 安全批后:/admin/push/dispatch 是管理端点,只认
|
||||
# CODEX_GLOBAL_ADMINS,不再只凭项目 read 就能让服务端
|
||||
# 向订阅目标(可能含内网地址)真 POST。被拒与成功都记审计。
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "admin_push_denied",
|
||||
"dispatch %s %s" % (event_type, project_id))
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
# 分发:sender=None 时用 push 的内置真实发送端
|
||||
# (webhook/slack 真 POST,webpush/apns/fcm 仍报缺凭证);
|
||||
# 显式注入的 sender 优先于内置端(见 push.dispatch_event)。
|
||||
result = _push_dispatch(
|
||||
self.server.db_path, event_type, project_id,
|
||||
body.get("payload") or {}, sender=None)
|
||||
self._audit_admin(user_id, "admin_push_dispatch",
|
||||
"dispatch %s %s" % (event_type, project_id))
|
||||
self._send_json(200, result)
|
||||
|
||||
# -- webhooks(签名认证,不走 X-API-Key)---------------------------
|
||||
@@ -5255,12 +5357,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
GITHUB_WEBHOOK_SECRET(手动 webhook,过渡期共存;App
|
||||
装好、手动 webhook 删掉后自然不再被命中)。
|
||||
/fix-review 解析成功后在控制面建一个 queued 任务(prompt
|
||||
含 repo、PR 号、评论原文、作者),由 worker 认领执行——
|
||||
webhook 本身不跑模型。任务归属:按项目名 == repo
|
||||
full_name 匹配项目;没有匹配项目时不凭空建项目、不建
|
||||
任务,返回 task_id 为 None 并说明原因(worker 的 repo
|
||||
仍是配置里的 repo,不是任意 GitHub 仓库的自动克隆,
|
||||
见 README)。check_run / check_suite 只做通知解析,
|
||||
是固定模板 + 截断后的评论摘录,不是评论原文原样),
|
||||
由 worker 认领执行——webhook 本身不跑模型。任务归属:
|
||||
按项目名 == repo full_name 匹配项目,created_by 归属
|
||||
项目 owner 并走 owner 的配额门,executor 强制 docker;
|
||||
没有匹配项目时不凭空建项目、不建任务,返回 task_id
|
||||
为 None 并说明原因(worker 的 repo 仍是配置里的 repo,
|
||||
不是任意 GitHub 仓库的自动克隆,见 README)。作者
|
||||
不可信时同样不建任务(见 is_trusted_fix_review_author)。check_run / check_suite 只做通知解析,
|
||||
返回解析结果而不建任务、不改云端任务状态(CI 状态回填
|
||||
由 pr-checks 主动拉取)。installation / installation_repositories
|
||||
事件记安装记录(created/added 增行、removed 减行、deleted
|
||||
@@ -5368,12 +5472,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(200, {"result": None})
|
||||
return
|
||||
# /fix-review 执行:建 queued 任务(不跑模型,worker 认领)。
|
||||
# P0 安全批后:先验作者、再查配额、最后才建任务。
|
||||
# 作者校验用 GitHub 事件自带的 author_association 与
|
||||
# PR/issue 作者比对(见 is_trusted_fix_review_author):
|
||||
# 不可信一律不建任务、不占去重位,fail-closed。
|
||||
conn = self._db()
|
||||
try:
|
||||
project_row = None
|
||||
if repo_name:
|
||||
project_row = conn.execute(
|
||||
"SELECT id FROM projects WHERE name = ?",
|
||||
"SELECT id, owner_id FROM projects WHERE name = ?",
|
||||
(repo_name,),
|
||||
).fetchone()
|
||||
if project_row is None:
|
||||
@@ -5382,6 +5490,28 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
error="no matching project for repo"))
|
||||
return
|
||||
project_id = project_row[0]
|
||||
project_owner = project_row[1]
|
||||
if not is_trusted_fix_review_author(payload):
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit("system", project_id,
|
||||
"webhook_fix_review_rejected",
|
||||
"untrusted author %s"
|
||||
% (result.get("author"),))
|
||||
self._send_json(200, dict(
|
||||
result, task_id=None, project_id=project_id,
|
||||
error="untrusted fix-review author"))
|
||||
return
|
||||
if not project_owner:
|
||||
self._send_json(200, dict(
|
||||
result, task_id=None, project_id=project_id,
|
||||
error="project has no owner for quota attribution"))
|
||||
return
|
||||
if self._reject_if_over_quota(project_owner):
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit("system", project_id,
|
||||
"webhook_fix_review_quota_blocked",
|
||||
project_owner)
|
||||
return
|
||||
# 内容去重:手动 webhook 与 App webhook 共存时同一评论
|
||||
# 送两遍(delivery_id 不同),只建一次任务;重复送达
|
||||
# 回读当初的 task_id,不重复记审计。
|
||||
@@ -5395,20 +5525,24 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
task_id = uuid.uuid4().hex
|
||||
now = time.time()
|
||||
# prompt 不再原样贴评论全文:固定模板 + 截断后的
|
||||
# 评论摘录(上限 2000 字),防超长评论撑爆 prompt
|
||||
# 与指令注入面无限放大。
|
||||
prompt = (
|
||||
"[webhook /fix-review] repo=%s pr=%s author=%s\n%s"
|
||||
% (result.get("repo"), result.get("pr_number"),
|
||||
result.get("author"), result.get("comment")))
|
||||
result.get("author"),
|
||||
(result.get("comment") or "")[:2000]))
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||
" idempotency_key, depends_on, priority, created_by,"
|
||||
" created_at, updated_at, last_heartbeat)"
|
||||
" VALUES(?, ?, ?, 'queued', ?, '[]', 0, 'system',"
|
||||
" ?, ?, NULL)",
|
||||
" created_at, updated_at, last_heartbeat, executor)"
|
||||
" VALUES(?, ?, ?, 'queued', ?, '[]', 0, ?,"
|
||||
" ?, ?, NULL, 'docker')",
|
||||
(task_id, project_id, prompt,
|
||||
"webhook:%s" % delivery_id if delivery_id else None,
|
||||
now, now),
|
||||
project_owner, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||||
|
||||
Reference in new issue
Block a user