fix: refuse the sandbox rather than quietly running it as the superuser
Both reviews landed on the same line independently. _as_role swaps the credentials in the DSN with a regex, and when the pattern does not match it returned the string unchanged. Two shapes miss it: no inline credentials, and a unix socket URL. Either one is a legal DSN. What that costs is not a broken test. The sandbox builds, every assertion still passes, and bare_dsn is now the admin connection, so the worst-case case runs the real script with --apply as a superuser against the shared table. The verifier ran that command as a dry run to see what it would have done: target public.llm_calls, 11 rows to delete. The case would still have gone red on the exit code, after the rows were gone. It raises now. There is also a second check that connects and compares current_user, because a successful string substitution is not the same as connecting as that role -- PGUSER and friends still override. The whole design rests on that connection having no grant on the shared table; a string comparison is too thin a thing to rest it on. That check has to stay inside the try. Past it the cleanup statements have already been merged into the fixture-level stack, and unwinding again runs DROP OWNED BY twice, which has no IF EXISTS. The catalog probe took any SQL and ran it on the admin connection. The design claims withholding the DSN makes the boundary structural; that was only true of the connection string, not of the capability. It takes SELECT now. --table's schema half is restricted to plain identifiers. Not a security fix, since the name goes through a parameter and _quote: the help text says complex identifiers are unsupported and the code was accepting them anyway.
This commit is contained in:
@@ -73,8 +73,22 @@ def _with_search_path(dsn: str, schema: str) -> str:
|
||||
|
||||
|
||||
def _as_role(dsn: str, role: str) -> str:
|
||||
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。"""
|
||||
return re.sub(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1)
|
||||
"""把 DSN 的用户名口令段换成沙箱角色的,其余(主机/库/参数)原样保留。
|
||||
|
||||
**换不掉就报错,绝不原样返回**: `postgresql://h:5432/db`(口令走 PGPASSWORD /
|
||||
.pgpass / trust)与 `postgresql:///db?host=/var/run/postgresql`(unix socket)
|
||||
都是合法 DSN,却没有可替换的内联凭据段。静默返回原串的后果不是测试报错,而是
|
||||
沙箱以**管理身份**建成、用例照常绿,同时 `bare_dsn` 变成超级用户连接——最坏
|
||||
情况用例会拿它跑真实 `--apply`,删空共享表之后才在退出码断言上红。
|
||||
这正是 P5"严禁默认值掩盖错误"要挡的形态。
|
||||
"""
|
||||
swapped, count = re.subn(r"//[^@/]+@", f"//{role}:{_SANDBOX_PASSWORD}@", dsn, count=1)
|
||||
if count != 1:
|
||||
raise RuntimeError(
|
||||
f"DSN 里没有可替换的内联凭据段,沙箱角色 {role} 无法生效,拒绝以管理身份继续。"
|
||||
"请把 PGW_TELEMETRY_PG_DSN 写成 postgresql://<用户>:<口令>@<主机>/<库> 的形态。"
|
||||
)
|
||||
return swapped
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -89,6 +103,10 @@ async def pg_catalog_probe():
|
||||
dsn = _require_admin_dsn()
|
||||
|
||||
async def probe(sql: str, *args: object) -> list[tuple]:
|
||||
# 只读校验不是形式主义: 这个闭包持的是管理连接,不设限就等于把"用例够不到
|
||||
# 管理能力"这句话降格成一句 docstring 里的请求。
|
||||
if not sql.lstrip().upper().startswith("SELECT"):
|
||||
raise RuntimeError(f"pg_catalog_probe 只接受 SELECT 语句,收到: {sql[:60]!r}")
|
||||
conn = await asyncpg.connect(dsn, timeout=10)
|
||||
try:
|
||||
return [tuple(r) for r in await conn.fetch(sql, *args)]
|
||||
@@ -194,18 +212,37 @@ async def pg_sandbox():
|
||||
f"GRANT {', '.join(grants)} ON ALL TABLES IN SCHEMA {schema} TO {role_name}"
|
||||
)
|
||||
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
|
||||
|
||||
used = bare if role_name is not None else admin_dsn
|
||||
sandbox = PgSandbox(
|
||||
schema=schema,
|
||||
role=role_name,
|
||||
dsn=_with_search_path(used, schema),
|
||||
bare_dsn=bare,
|
||||
)
|
||||
if role_name is not None:
|
||||
# 字符串替换成功不等于连上去就是那个角色(PGUSER 等环境变量仍可能
|
||||
# 盖掉 DSN 里的用户名)。这道校验按**实际身份**兜底: 整个设计的价值
|
||||
# 都压在"跑脚本的那个连接对共享表无权"上,不值得只用一次字符串比较
|
||||
# 来担保。它必须留在 try 之内——出了这个块,清理动作已经并进 fixture
|
||||
# 级的栈,再回滚一次就会对同一个角色跑两遍 DROP OWNED BY(它没有
|
||||
# IF EXISTS,第二遍必报错)。
|
||||
conn = await asyncpg.connect(sandbox.dsn, timeout=10)
|
||||
try:
|
||||
actual = await conn.fetchval("SELECT current_user")
|
||||
finally:
|
||||
await conn.close()
|
||||
if actual != role_name:
|
||||
raise RuntimeError(
|
||||
f"沙箱 DSN 连上去的身份是 {actual!r},不是预期的 {role_name!r};"
|
||||
"权限边界不成立,拒绝把这个沙箱交出去。"
|
||||
)
|
||||
except BaseException:
|
||||
await _unwind(local)
|
||||
raise
|
||||
cleanups.extend(local)
|
||||
|
||||
used = bare if role_name is not None else admin_dsn
|
||||
return PgSandbox(
|
||||
schema=schema,
|
||||
role=role_name,
|
||||
dsn=_with_search_path(used, schema),
|
||||
bare_dsn=bare,
|
||||
)
|
||||
return sandbox
|
||||
|
||||
yield make
|
||||
await _unwind(cleanups)
|
||||
|
||||
Reference in New Issue
Block a user