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:
1 parent
2d12179b9a
commit
9f593d62ef
3 files changed
+165
-15
No files matched your search
+67
-13
@@ -14,7 +14,10 @@ socket 与 seed 文件)。它只做三件事:
|
|||||||
GITEA_TOKEN 持在内存里(cloud.chv.authd.CredentialVault),
|
GITEA_TOKEN 持在内存里(cloud.chv.authd.CredentialVault),
|
||||||
除此之外不落盘、不进日志;
|
除此之外不落盘、不进日志;
|
||||||
2. 在 Unix socket /run/chv-authd/authd.sock 上接受 git credential
|
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,主机/
|
3. 每次放凭证前先过 Sentinel 评估(cloud.chv.sentinel,主机/
|
||||||
方法/路径/解析后 IP/SSRF),再按次释放(单次、300 秒 TTL),
|
方法/路径/解析后 IP/SSRF),再按次释放(单次、300 秒 TTL),
|
||||||
全程审计(审计里无值)。
|
全程审计(审计里无值)。
|
||||||
@@ -51,6 +54,46 @@ SEED_KEYS = frozenset({
|
|||||||
DEFAULT_ALLOWED_UIDS = (0, 1000)
|
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):
|
def _load_seed(path):
|
||||||
"""读 seed 文件(KEY=VALUE,每行一项)→ 白名单后的 dict。
|
"""读 seed 文件(KEY=VALUE,每行一项)→ 白名单后的 dict。
|
||||||
文件不存在即 FileNotFoundError(调用方 fail-fast,绝不带着
|
文件不存在即 FileNotFoundError(调用方 fail-fast,绝不带着
|
||||||
@@ -87,13 +130,18 @@ def _operation_for_path(path):
|
|||||||
推断不到的一律拒绝):
|
推断不到的一律拒绝):
|
||||||
- .../git-receive-pack → push(写)
|
- .../git-receive-pack → push(写)
|
||||||
- .../git-upload-pack → pull(读)
|
- .../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"):
|
if path.endswith(".git/git-receive-pack"):
|
||||||
return "POST", "push"
|
return "POST", "push"
|
||||||
if path.endswith(".git/git-upload-pack"):
|
if path.endswith(".git/git-upload-pack"):
|
||||||
return "POST", "pull"
|
return "POST", "pull"
|
||||||
if path.endswith(".git/info/refs"):
|
if path.endswith(".git/info/refs"):
|
||||||
return "GET", "pull"
|
return "GET", "pull"
|
||||||
|
if path.endswith(".git"):
|
||||||
|
return "GET", "pull"
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -227,7 +275,8 @@ class AuthdServer(socketserver.ThreadingUnixStreamServer):
|
|||||||
allow_reuse_address = False
|
allow_reuse_address = False
|
||||||
|
|
||||||
def __init__(self, socket_path, vault, policy, gitea_user,
|
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.vault = vault
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.gitea_user = gitea_user
|
self.gitea_user = gitea_user
|
||||||
@@ -236,20 +285,24 @@ class AuthdServer(socketserver.ThreadingUnixStreamServer):
|
|||||||
# 解析器可注入(测试用假解析器;不给时 Sentinel 用真实
|
# 解析器可注入(测试用假解析器;不给时 Sentinel 用真实
|
||||||
# getaddrinfo)。与执行器的 sentinel_resolver 同款口径。
|
# getaddrinfo)。与执行器的 sentinel_resolver 同款口径。
|
||||||
self.resolve = resolve
|
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)
|
directory = os.path.dirname(socket_path)
|
||||||
if directory:
|
if directory:
|
||||||
os.makedirs(directory, exist_ok=True)
|
os.makedirs(directory, exist_ok=True)
|
||||||
try:
|
if self.socket_gid is not None:
|
||||||
os.chmod(directory, 0o700)
|
_set_group_perms(directory, self.socket_gid, 0o750)
|
||||||
except OSError:
|
else:
|
||||||
pass
|
_set_group_perms(directory, None, 0o700)
|
||||||
if os.path.exists(socket_path):
|
if os.path.exists(socket_path):
|
||||||
os.unlink(socket_path)
|
os.unlink(socket_path)
|
||||||
super().__init__(socket_path, AuthdHandler)
|
super().__init__(socket_path, AuthdHandler)
|
||||||
try:
|
if self.socket_gid is not None:
|
||||||
os.chmod(socket_path, 0o600)
|
_set_group_perms(socket_path, self.socket_gid, 0o660)
|
||||||
except OSError:
|
else:
|
||||||
pass
|
_set_group_perms(socket_path, None, 0o600)
|
||||||
|
|
||||||
def audit(self, record):
|
def audit(self, record):
|
||||||
_audit(self.log_path, record)
|
_audit(self.log_path, record)
|
||||||
@@ -260,7 +313,8 @@ def build_server(seed_path=DEFAULT_SEED_PATH,
|
|||||||
log_path=DEFAULT_LOG_PATH,
|
log_path=DEFAULT_LOG_PATH,
|
||||||
lib_dir=DEFAULT_LIB_DIR,
|
lib_dir=DEFAULT_LIB_DIR,
|
||||||
allowed_uids=None,
|
allowed_uids=None,
|
||||||
resolve=None):
|
resolve=None,
|
||||||
|
socket_group=None):
|
||||||
"""建服务(不启动):读 seed → 入库 → 建策略 → 建 socket 服务。
|
"""建服务(不启动):读 seed → 入库 → 建策略 → 建 socket 服务。
|
||||||
|
|
||||||
seed 缺必填键(GITEA_URL/GITEA_USER/GITEA_TOKEN/SYNC_REPO)
|
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)
|
if raw.strip() else list(DEFAULT_ALLOWED_UIDS)
|
||||||
server = AuthdServer(socket_path, vault, policy,
|
server = AuthdServer(socket_path, vault, policy,
|
||||||
seed["GITEA_USER"], uids, log_path,
|
seed["GITEA_USER"], uids, log_path,
|
||||||
resolve=resolve)
|
resolve=resolve, socket_group=socket_group)
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,13 +66,16 @@ _BLOCKED_NETWORKS = tuple(
|
|||||||
|
|
||||||
# 每连接器的 L7 路径策略:(方法, 匹配方式, 模式)。
|
# 每连接器的 L7 路径策略:(方法, 匹配方式, 模式)。
|
||||||
# gitea:API 只放 /api/v1/;git 智能 HTTP 只放三条(clone/pull/push
|
# 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 = {
|
_CONNECTOR_PATH_RULES = {
|
||||||
"gitea": (
|
"gitea": (
|
||||||
("GET", "prefix", "/api/v1/"),
|
("GET", "prefix", "/api/v1/"),
|
||||||
("POST", "prefix", "/api/v1/"),
|
("POST", "prefix", "/api/v1/"),
|
||||||
("GET", "suffix", ".git/info/refs"),
|
("GET", "suffix", ".git/info/refs"),
|
||||||
|
("GET", "suffix", ".git"),
|
||||||
("POST", "suffix", ".git/git-upload-pack"),
|
("POST", "suffix", ".git/git-upload-pack"),
|
||||||
("POST", "suffix", ".git/git-receive-pack"),
|
("POST", "suffix", ".git/git-receive-pack"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -271,14 +271,18 @@ class SentinelTest(unittest.TestCase):
|
|||||||
(self._req(port=80), "port_not_allowed"),
|
(self._req(port=80), "port_not_allowed"),
|
||||||
(self._req(host="bad.example"), "host_denied"),
|
(self._req(host="bad.example"), "host_denied"),
|
||||||
(self._req(path="/login"), "path_not_allowed"),
|
(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:
|
for req, reason in cases:
|
||||||
dec = policy.evaluate(req, resolve=_public_resolver)
|
dec = policy.evaluate(req, resolve=_public_resolver)
|
||||||
self.assertEqual(dec.verdict, "deny", reason)
|
self.assertEqual(dec.verdict, "deny", reason)
|
||||||
self.assertEqual(dec.reasons, [reason])
|
self.assertEqual(dec.reasons, [reason])
|
||||||
# git 智能 HTTP 三条路径放行。
|
# git 智能 HTTP 三条路径放行,裸 .git 只放 GET(层 4)。
|
||||||
for method, path in (
|
for method, path in (
|
||||||
("GET", "/admin/r.git/info/refs"),
|
("GET", "/admin/r.git/info/refs"),
|
||||||
|
("GET", "/admin/r.git"),
|
||||||
("POST", "/admin/r.git/git-upload-pack"),
|
("POST", "/admin/r.git/git-upload-pack"),
|
||||||
("POST", "/admin/r.git/git-receive-pack")):
|
("POST", "/admin/r.git/git-receive-pack")):
|
||||||
dec = policy.evaluate(
|
dec = policy.evaluate(
|
||||||
@@ -598,6 +602,28 @@ class GuestAuthdTest(unittest.TestCase):
|
|||||||
self.assertEqual(resp["password"], TOKEN)
|
self.assertEqual(resp["password"], TOKEN)
|
||||||
self.assertGreater(resp["expires_at"], 0)
|
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):
|
def test_second_request_gets_fresh_release(self):
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
resp = self._ask({"connector": "gitea",
|
resp = self._ask({"connector": "gitea",
|
||||||
@@ -655,6 +681,60 @@ class GuestAuthdTest(unittest.TestCase):
|
|||||||
resolve=_public_resolver)
|
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):
|
class HelperE2ETest(unittest.TestCase):
|
||||||
"""helper + 真 git credential fill(假 authd socket 在后台)。"""
|
"""helper + 真 git credential fill(假 authd socket 在后台)。"""
|
||||||
|
|
||||||
@@ -699,6 +779,19 @@ class HelperE2ETest(unittest.TestCase):
|
|||||||
self.assertEqual(proc.returncode, 1)
|
self.assertEqual(proc.returncode, 1)
|
||||||
self.assertIn("path_not_allowed", proc.stderr)
|
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):
|
def test_git_credential_fill_end_to_end(self):
|
||||||
env = self._helper_env()
|
env = self._helper_env()
|
||||||
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||||
|
|||||||
Reference in new issue
Block a user