fix(chv-muse-r11fix): authd 层 3 socket 用 group 权限、层 4 裸 .git 当 pull

层 3:AuthdServer 不再 0700/0600 一刀切。socket 共享组取显式
socket_group 或第一个非 root 放行 uid 的主组(build_server 透
传;生产默认 allowed_uids=(0,1000) 派生 chv 主组),目录 0750
+ socket 0660 + chgrp;解析不出(uid 无 passwd 条目)回落
root-only,绝不放 0666(探针 fixprobe8 用的 0666 不上生产)。
层 4:_operation_for_path 加裸 .git → GET/pull;Sentinel
gitea 规则加 (GET, suffix, .git),POST 裸 .git 仍拒。语义已定:
裸 .git 一律当 pull(只读默认),push 只走显式 git-receive-pack。
测试:Sentinel 允许 GET/拒绝 POST 裸 .git、helper 端到端裸路径
取凭证、组 gid 派生与回落、显式组落地 0750/0660。
This commit is contained in:
host committed 2026-11-07 03:58:45 +00:00
1 parent 2d12179b9a
commit 9f593d62ef
3 files changed
+165 -15

No files matched your search

+67 -13
View File
@@ -14,7 +14,10 @@ socket 与 seed 文件)。它只做三件事:
GITEA_TOKEN 持在内存里(cloud.chv.authd.CredentialVault),
除此之外不落盘、不进日志;
2. 在 Unix socket /run/chv-authd/authd.sock 上接受 git credential
helper 的请求(SO_PEERCRED 只放行 root 与 chv 用户);
helper 的请求(SO_PEERCRED 只放行 root 与 chv 用户;socket
用 group 权限——目录 0750、socket 0660、组为第一个非 root
放行 uid 的主组,round-11 真机修复层 3,探针用的 0666
不上生产);
3. 每次放凭证前先过 Sentinel 评估(cloud.chv.sentinel,主机/
方法/路径/解析后 IP/SSRF),再按次释放(单次、300 秒 TTL),
全程审计(审计里无值)。
@@ -51,6 +54,46 @@ SEED_KEYS = frozenset({
DEFAULT_ALLOWED_UIDS = (0, 1000)
def _socket_group_gid(allowed_uids, socket_group=None):
"""socket 共享组的 gid:socket_group 显式给时优先(int 即 gid,
str 即组名);否则取第一个非 root 放行 uid 的主组。解析不出
(uid 无 passwd 条目、组名不存在)即 None——调用方回落
root-only 权限(目录 0700、socket 0600),绝不把 socket 放给
全员可读(round-11 真机修复层 3:探针用 0666,生产用 group
权限——chv 在组内,目录 0750 + socket 0660 + chgrp)。"""
import grp
import pwd
if socket_group is not None:
if isinstance(socket_group, int):
return socket_group
try:
return grp.getgrnam(socket_group).gr_gid
except (KeyError, OverflowError):
return None
for uid in sorted(allowed_uids or ()):
if uid == 0:
continue
try:
return pwd.getpwuid(uid).pw_gid
except (KeyError, OverflowError):
continue
return None
def _set_group_perms(path, gid, mode):
"""目录/socket 的组权限:先 chgrp(失败只放过,不中断启动),
再 chmod。gid 为 None 时只 chmod(root-only 模式)。"""
if gid is not None:
try:
os.chown(path, -1, gid)
except OSError:
pass
try:
os.chmod(path, mode)
except OSError:
pass
def _load_seed(path):
"""读 seed 文件(KEY=VALUE,每行一项)→ 白名单后的 dict。
文件不存在即 FileNotFoundError(调用方 fail-fast,绝不带着
@@ -87,13 +130,18 @@ def _operation_for_path(path):
推断不到的一律拒绝):
- .../git-receive-pack → push(写)
- .../git-upload-pack → pull(读)
- .../info/refs → pull(读,clone 也走这条)"""
- .../info/refs → pull(读,clone 也走这条)
- 裸 .../ → pull(读;round-11 真机修复层 4:git 智能
HTTP 的发现请求只发裸 .git 路径,裸路径一律当 pull——
只读默认;push 只走显式的 git-receive-pack)"""
if path.endswith(".git/git-receive-pack"):
return "POST", "push"
if path.endswith(".git/git-upload-pack"):
return "POST", "pull"
if path.endswith(".git/info/refs"):
return "GET", "pull"
if path.endswith(".git"):
return "GET", "pull"
return None, None
@@ -227,7 +275,8 @@ class AuthdServer(socketserver.ThreadingUnixStreamServer):
allow_reuse_address = False
def __init__(self, socket_path, vault, policy, gitea_user,
allowed_uids, log_path, resolve=None):
allowed_uids, log_path, resolve=None,
socket_group=None):
self.vault = vault
self.policy = policy
self.gitea_user = gitea_user
@@ -236,20 +285,24 @@ class AuthdServer(socketserver.ThreadingUnixStreamServer):
# 解析器可注入(测试用假解析器;不给时 Sentinel 用真实
# getaddrinfo)。与执行器的 sentinel_resolver 同款口径。
self.resolve = resolve
# round-11 真机修复(层 3):socket 用 group 权限。
# gid 取 socket_group 显式值或第一个非 root 放行 uid 的
# 主组;解析不出即 None,回落 root-only(0700/0600)。
self.socket_gid = _socket_group_gid(allowed_uids, socket_group)
directory = os.path.dirname(socket_path)
if directory:
os.makedirs(directory, exist_ok=True)
try:
os.chmod(directory, 0o700)
except OSError:
pass
if self.socket_gid is not None:
_set_group_perms(directory, self.socket_gid, 0o750)
else:
_set_group_perms(directory, None, 0o700)
if os.path.exists(socket_path):
os.unlink(socket_path)
super().__init__(socket_path, AuthdHandler)
try:
os.chmod(socket_path, 0o600)
except OSError:
pass
if self.socket_gid is not None:
_set_group_perms(socket_path, self.socket_gid, 0o660)
else:
_set_group_perms(socket_path, None, 0o600)
def audit(self, record):
_audit(self.log_path, record)
@@ -260,7 +313,8 @@ def build_server(seed_path=DEFAULT_SEED_PATH,
log_path=DEFAULT_LOG_PATH,
lib_dir=DEFAULT_LIB_DIR,
allowed_uids=None,
resolve=None):
resolve=None,
socket_group=None):
"""建服务(不启动):读 seed → 入库 → 建策略 → 建 socket 服务。
seed 缺必填键(GITEA_URL/GITEA_USER/GITEA_TOKEN/SYNC_REPO)
@@ -295,7 +349,7 @@ def build_server(seed_path=DEFAULT_SEED_PATH,
if raw.strip() else list(DEFAULT_ALLOWED_UIDS)
server = AuthdServer(socket_path, vault, policy,
seed["GITEA_USER"], uids, log_path,
resolve=resolve)
resolve=resolve, socket_group=socket_group)
return server
+4 -1
View File
@@ -66,13 +66,16 @@ _BLOCKED_NETWORKS = tuple(
# 每连接器的 L7 路径策略:(方法, 匹配方式, 模式)。
# gitea:API 只放 /api/v1/;git 智能 HTTP 只放三条(clone/pull/push
# 的实际路径,见 git http-protocol)。其它路径(Web UI、登录页、
# 的实际路径,见 git http-protocol)加裸 .git(round-11 真机修复
# 层 4:git 智能 HTTP 的发现请求只发裸 .git 路径,裸路径只放 GET、
# 一律当 pull;POST 裸 .git 仍拒绝)。其它路径(Web UI、登录页、
# 任意管理接口)一律不在本连接器的能力内。
_CONNECTOR_PATH_RULES = {
"gitea": (
("GET", "prefix", "/api/v1/"),
("POST", "prefix", "/api/v1/"),
("GET", "suffix", ".git/info/refs"),
("GET", "suffix", ".git"),
("POST", "suffix", ".git/git-upload-pack"),
("POST", "suffix", ".git/git-receive-pack"),
),
+94 -1
View File
@@ -271,14 +271,18 @@ class SentinelTest(unittest.TestCase):
(self._req(port=80), "port_not_allowed"),
(self._req(host="bad.example"), "host_denied"),
(self._req(path="/login"), "path_not_allowed"),
# round-11 真机修复(层 4):裸 .git 只放 GET,POST 仍拒。
(self._req(method="POST", path="/admin/r.git"),
"path_not_allowed"),
]
for req, reason in cases:
dec = policy.evaluate(req, resolve=_public_resolver)
self.assertEqual(dec.verdict, "deny", reason)
self.assertEqual(dec.reasons, [reason])
# git 智能 HTTP 三条路径放行。
# git 智能 HTTP 三条路径放行,裸 .git 只放 GET(层 4)。
for method, path in (
("GET", "/admin/r.git/info/refs"),
("GET", "/admin/r.git"),
("POST", "/admin/r.git/git-upload-pack"),
("POST", "/admin/r.git/git-receive-pack")):
dec = policy.evaluate(
@@ -598,6 +602,28 @@ class GuestAuthdTest(unittest.TestCase):
self.assertEqual(resp["password"], TOKEN)
self.assertGreater(resp["expires_at"], 0)
def test_bare_git_path_is_pull(self):
# round-11 真机修复(层 4):裸 .git 路径一律当 pull。
resp = self._ask({"connector": "gitea",
"host": "gitea.tose.top",
"path": "/admin/chv-sync-test.git"})
self.assertTrue(resp["ok"])
self.assertEqual(resp["username"], "admin")
self.assertEqual(resp["password"], TOKEN)
def test_socket_uses_group_perms(self):
# round-11 真机修复(层 3):socket 用 group 权限(目录
# 0750、socket 0660),不用探针的 0666;放行 uid 无
# passwd 条目时才回落 root-only(见 SocketGroupTest)。
import stat
mod = _load_guest_authd()
if mod._socket_group_gid([os.getuid()]) is None:
self.skipTest("放行 uid 无 passwd 条目,组权限不可解析")
self.assertEqual(
stat.S_IMODE(os.stat(self.socket_path).st_mode), 0o660)
self.assertEqual(
stat.S_IMODE(os.stat(self.dir).st_mode), 0o750)
def test_second_request_gets_fresh_release(self):
for _ in range(2):
resp = self._ask({"connector": "gitea",
@@ -655,6 +681,60 @@ class GuestAuthdTest(unittest.TestCase):
resolve=_public_resolver)
class SocketGroupTest(unittest.TestCase):
"""round-11 真机修复(层 3):socket 共享组 gid 的派生与回落。"""
def test_explicit_group_and_derivation(self):
mod = _load_guest_authd()
# 显式 int 即 gid;显式组名解析;不存在的组名 → None。
self.assertEqual(mod._socket_group_gid([0, 1000], 1234), 1234)
self.assertEqual(mod._socket_group_gid([0, 1000], "root"), 0)
self.assertIsNone(
mod._socket_group_gid([0, 1000], "no-such-group-xyz"))
# 只剩 root → 无组可派,回落 root-only。
self.assertIsNone(mod._socket_group_gid([0]))
self.assertIsNone(mod._socket_group_gid([]))
# 派生:第一个非 root 放行 uid 的主组(用 passwd 里第一个
# 非 root 条目,不依赖当前测试进程的 uid——本机测试以 root
# 跑,os.getuid() 本身就是 0,派生不到组)。
import pwd
others = [p for p in pwd.getpwall() if p.pw_uid != 0]
if not others:
self.skipTest("passwd 里无非 root 用户")
first = sorted(others, key=lambda p: p.pw_uid)[0]
self.assertEqual(
mod._socket_group_gid([0, first.pw_uid]), first.pw_gid)
def test_server_applies_group_perms_with_explicit_group(self):
# 显式给组时服务器落地 0750/0660(本机以 root 跑测试,
# allowed_uids 只有 root,派生不到组,须显式给才能覆到
# AuthdServer 的落盘路径)。
import stat
mod = _load_guest_authd()
with tempfile.TemporaryDirectory() as tmp:
seed_path = os.path.join(tmp, "authd-seed")
with open(seed_path, "w") as fh:
fh.write("GITEA_URL=https://gitea.tose.top\n"
"GITEA_USER=admin\n"
"GITEA_TOKEN=" + TOKEN + "\n"
"SYNC_REPO=admin/chv-sync-test\n")
socket_path = os.path.join(tmp, "authd.sock")
log_path = os.path.join(tmp, "authd.log")
server = mod.build_server(
seed_path=seed_path, socket_path=socket_path,
log_path=log_path, lib_dir=str(REPO_ROOT),
allowed_uids=[os.getuid()], resolve=_public_resolver,
socket_group=os.getgid())
try:
self.assertEqual(server.socket_gid, os.getgid())
self.assertEqual(
stat.S_IMODE(os.stat(socket_path).st_mode), 0o660)
self.assertEqual(
stat.S_IMODE(os.stat(tmp).st_mode), 0o750)
finally:
server.server_close()
class HelperE2ETest(unittest.TestCase):
"""helper + 真 git credential fill(假 authd socket 在后台)。"""
@@ -699,6 +779,19 @@ class HelperE2ETest(unittest.TestCase):
self.assertEqual(proc.returncode, 1)
self.assertIn("path_not_allowed", proc.stderr)
def test_helper_returns_credential_for_bare_git_path(self):
# round-11 真机修复(层 4):裸 .git 路径一律当 pull,
# helper 须照常返回凭证(git 智能 HTTP 发现请求只发裸路径)。
proc = subprocess.run(
[sys.executable, str(self.HELPER)],
input="protocol=https\nhost=gitea.tose.top\n"
"path=/admin/chv-sync-test.git\n\n",
capture_output=True, text=True, env=self._helper_env(),
check=False)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("username=admin", proc.stdout)
self.assertIn("password=" + TOKEN, proc.stdout)
def test_git_credential_fill_end_to_end(self):
env = self._helper_env()
env["GIT_CONFIG_NOSYSTEM"] = "1"