diff --git a/chv-guest/authd.py b/chv-guest/authd.py index 6e0e473..d33b43f 100644 --- a/chv-guest/authd.py +++ b/chv-guest/authd.py @@ -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 diff --git a/cloud/chv/sentinel.py b/cloud/chv/sentinel.py index 04ea3b2..a6268b8 100644 --- a/cloud/chv/sentinel.py +++ b/cloud/chv/sentinel.py @@ -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"), ), diff --git a/cloud/tests/test_chv_muse_p3.py b/cloud/tests/test_chv_muse_p3.py index 1b88993..3bb692b 100644 --- a/cloud/tests/test_chv_muse_p3.py +++ b/cloud/tests/test_chv_muse_p3.py @@ -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"