- 数据库
- 灾备
【免费下载链接】databasus
PostgreSQL backup tool with Point-In-Time-Recovery and restore verification
Databasus 是一个自带元数据存储的 PostgreSQL 备份工具,其容器内嵌了一个仅供应用使用的 PostgreSQL 实例,工作区用户可控的连接与备份操作若未加防护,可能通过 Unix Socket、libpq 连接串解析等路径读取元数据库,甚至把其他工作区保存的凭据发送到调用者控制的服务器。本文以 openspec/specs/internal-postgresql-protection/spec.md 为核心,结合仓库源码(目标分类器、凭据构建器、服务层授权、容器启动脚本)完整讲解 Databasus 如何在应用层目标校验与容器层身份验证两道边界上杜绝这类越权访问,并给出连接测试 API 的授权语义、启动凭据轮换机制与升级迁移操作要点。
威胁模型:工作区可控的 PostgreSQL 连接为何危险
Databasus 把 PostgreSQL 与自身应用运行在同一个容器进程树中(见 Dockerfile),应用使用内嵌实例作为元数据库。与此同时,用户可以在工作区里配置任意 PostgreSQL 数据库作为备份源或连接测试目标,而这些配置最终都会进入统一的连接创建路径。
openspec/changes/archive/2026-09-04-protect-internal-postgresql-access/proposal.md 给出了两条已确认的攻击路径:
- 本地回环绕过:已有实现只检查
localhost,而 Unix Socket 目录、抽象 Socket(@前缀)、host.docker.internal、172.17.0.1以及 libc 兼容的多种数字 IPv4 写法(127.1、2130706433、0x7f000001、0177.0.0.1)都能让连接绕过旧的检查点直指容器内嵌实例; - 跨工作区凭据窃取:直接连接测试端点会加载一个已保存数据库并合并请求中携带的字段,如果先合并后鉴权,调用者就能把自己控制的 host 与别人工作区保存的密码组合起来,把凭据发给自己的服务器。
此外,libpq 连接串本身就是一种迷你语言:如果用户把dbname=databasus之类的文本塞进数据库名或密码字段,而代码不做转义直接拼进 conninfo,就可能被解析成新的参数从而重定向连接。
防护策略因此分三层:识别并拒绝指向内嵌实例的目标(应用层)、把所有连接字段当字面量处理(解析层)、让容器内的 PostgreSQL 自身拒绝任何未经生成凭据的访问(运行时层)。下文的每一条规格都能在这三层中找到对应的实现。
逻辑备份的目标分类:识别一切指向内嵌元数据库的本地端点
分类规则与判定逻辑
规格要求:当逻辑备份源的数据库名是databasus且任一有效 host 指向容器本身时,系统必须在打开连接之前拒绝该源。所谓"指向容器本身",判定的是 host 是否属于本地端点(local endpoint),而不只是字符串相等。
核心实现在 backend/internal/features/databases/databases/postgresql/shared/embedded_target.go 的ValidateNotEmbeddedTarget与isLocalEndpointHostEntry:
const embeddedPostgresPort = 5437 type EmbeddedTargetSpec struct { Host string Port int DatabaseName string IsPhysical bool IsSSHTunnelEnabled bool SSHBastionHost string }判定本地端点的完整集合如下:
| Host 形式 | 示例 | 判定依据(源码) |
|---|---|---|
| 文件系统 Unix Socket | /tmp、/databasus-data/pgsocket | filepath.IsAbs(host)为真 |
| 抽象 Unix Socket | @abstract | strings.HasPrefix(host, "@") |
| 回环 / 未指定 IP | 127.42.0.9、::1、0.0.0.0、:: | net.ParseIP+IsLoopback()/IsUnspecified() |
| 容器别名 | localhost、host.docker.internal、172.17.0.1 | 显式 switch 命中 |
| libc 兼容数字 IPv4 | 127.1、127.0.1、2130706433、0177.0.0.1、0x7f000001、0 | parseIPv4AddressAcceptedByLibc解析后命中回环/未指定范围 |
| 逗号分隔列表中的空项 | remote.invalid, | 空条目按本地端点处理 |
| 尾部带点的域名 | localhost. | 先TrimSuffix(".", host)再比较 |
其中parseIPv4AddressAcceptedByLibc值得单独说明:它支持 1 到 4 段的 IPv4 写法,每段用strconv.ParseUint(part, 0, 32)以0为基数解析(从而接受十进制、八进制、十六进制),并按段数做不同的位组装后还原成net.IPv4。这意味着用户写127.1(2 段)、127.0.1(3 段)乃至单个2130706433(十进制)、0177.0.0.1(八进制)、0x7f000001(十六进制),只要最终地址落在回环或未指定范围,一律按本地端点处理。对判定集合最完整的验证在 embedded_target_test.go 中,它一次性覆盖了 20 种应拒绝的 host 写法(含"libpq host list with default socket"即remote.invalid,)。
多 host 列表:逐项检查,不允许"远端掩护本地"
libpq 允许用逗号分隔提供多个候选 host,按顺序尝试连接,前一个失败就 fallback 到下一个。规格明确要求检查列表中的每一项——远端条目不能使后续的本地条目合法。实现上isLocalEndpointHost对strings.Split(host, ",")的结果做slices.ContainsFunc(isLocalEndpointHostEntry):只要任意一项被判定为本地端点,整个目标即视为本地。这样即使列表写成remote.invalid,127.0.0.1,也会被整体拒绝,杜绝了 libpq 失败后 fallback 到内嵌实例的可能。
数据库名校验:大小写不敏感,且仅限逻辑备份
对逻辑备份,判定条件为strings.EqualFold(spec.DatabaseName, "databasus")——大小写不敏感,DATABASUS、Databasus都会命中。本地端点 + 该库名即返回错误:
errors.New("backing up Databasus internal database is not allowed. To backup Databasus itself, see ...")注意规格与实现都允许两类"同名但安全"的合法场景(Test_ValidateNotEmbeddedTarget_AllowsNonEmbeddedLogicalDatabase用例):
- 本地 PostgreSQL 源但库名不是
databasus(如application); - 远端 PostgreSQL 源,即使库名就叫
databasus。
这是有意为之的取舍:保守拒绝"本地 + 同名"组合,但远端同库名仍可正常备份。规格中的风险条目明确记录了这个权衡("A legitimate local database nameddatabasusis rejected")。
SSH 隧道:远端堡垒机上的回环地址保持"远端语义"
通过 SSH 隧道访问数据库时,回环地址的语义随堡垒机位置而变:堡垒机在远端时,localhost指的是远端主机上的地址,而不是容器自身。ValidateNotEmbeddedTarget的第一条分支就处理了这一点:
if spec.IsSSHTunnelEnabled && !isLocalEndpointHost(spec.SSHBastionHost) { return nil }只要 SSH 隧道启用且堡垒机本身不是本地端点,整个目标直接放行;反之,如果堡垒机是127.0.0.1之类的本地地址、数据库目标又命中内嵌实例,则照常拒绝(对应Test_ValidateNotEmbeddedTarget_RejectsLocalBastionAndPhysicalBackup用例)。规格也明确:"本地 SSH 堡垒机不得放宽内嵌目标规则。"
物理备份规则:不看库名,只看本地端口 5437
物理备份(基于pg_basebackup/pg_receivewal的复制连接)面向的是整个集群而非某个逻辑库,所以规格要求"不得依赖数据库名"来判定。ValidateNotEmbeddedTarget对IsPhysical: true的目标采用端口判定:
if spec.IsPhysical { if spec.Port == embeddedPostgresPort { return errors.New("backing up Databasus internal PostgreSQL cluster is not allowed") } return nil }也就是说:本地端点 + 端口 5437 = 内嵌集群,直接拒绝;本地端点但端口不是 5437(例如用户自己的本地 PostgreSQL 实例),或者目标不在本地,均放行。对应测试用例"Different local PostgreSQL instance"与"Embedded cluster through a Unix socket"。
两类数据库模型的接入点分别在 logical/model.go(逻辑,校验库名)与 physical/model.go(物理,仅端口),它们把模型字段组装成EmbeddedTargetSpec后复用同一个共享分类器。
双重校验:模型校验之外,连接前再查一次
规格强调:模型校验不能是唯一的执行点。已持久化的行可能早于该策略存在,或进入不走完整模型校验的连接路径,因此系统必须在"打开 PostgreSQL 连接之前"再次执行内嵌目标检查。
实现上,除了Validate()阶段的调用(logical/model.go:115、physical/model.go:94),逻辑数据库的隧道分发器在建立连接前还会主动复核:
// backend/internal/features/databases/databases/postgresql/logical/tunnel.go:29 if err := spec.Database.ValidateNotEmbeddedTarget(); err != nil { ... }这两道检查共享同一个分类器实现,保证"曾经通过校验的历史配置在连接时依然被拦截"。设计文档(design.md)在决策 1 中同时解释了为什么不在校验阶段解析 DNS:解析既引入延迟和可用性依赖,又可能因 DNS 重绑定让校验与连接之间的结果不一致——所以分类只处理可以确定判定的本地形式,剩余风险交给容器运行时层兜底(详见后文)。
conninfo 字面量处理:让每个字段只作为一个值
libpq 连接串按空格和引号规则切分参数,dbname=databasus这样的文本若原样进入连接串,会被解析成新的dbname参数,覆盖原本的数据库名。规格要求:所有用户可控字段(host、用户名、密码、TLS 模式、证书路径、数据库名)在进入 conninfo 前必须作为一个字面量处理,即使它们包含空白、引号、反斜杠、等号或形似其他参数名的文本。
实现位于 credentials.go,核心是quoteConninfoValue:
func quoteConninfoValue(value string) string { value = strings.ReplaceAll(value, `\`, `\\`) value = strings.ReplaceAll(value, `'`, `\'`) return "'" + value + "'" }每个字段先转义反斜杠与单引号,再用单引号包裹。buildConnString对 host、username、password、dbname、sslmode 全部套用该规则:
connStr := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", quoteConninfoValue(getConnectHost(spec)), spec.Port, quoteConninfoValue(spec.Username), quoteConninfoValue(password), quoteConninfoValue(dbName), quoteConninfoValue(string(sslModeOrDefault(spec))), )物理复制连接串(buildPhysicalReplicationConnString)走同样的字面量策略并追加replication=true。把"内容当作数据"而不是"对选中的子串做拒绝",比黑名单更健壮——合法标识符本来就可以包含奇怪字符,黑名单也无法覆盖所有参数名与转义形式。
逻辑转储的库名:单字段 conninfo,防pg_dump二次解析
pg_dump的-d参数接受的是 conninfo 串而不仅是纯库名,因此库名必须单独包装。BuildDatabaseNameConninfo生成一个单字段表达式:
func BuildDatabaseNameConninfo(databaseName string) string { return "dbname=" + quoteConninfoValue(databaseName) }逻辑备份用例在 create_backup_uc.go 中这样传参:
"-d", postgresql_shared.BuildDatabaseNameConninfo(*pg.Database),验证测试Test_BuildDatabaseNameConninfo_TreatsDuplicateParameterAsLiteralDatabaseName用postgres dbname=databasus作为库名,经pgx.ParseConfig解析后connConfig.Database仍等于完整输入串;Test_BuildConnConfig_TreatsCredentialFieldsAsLiteralValues则把db host=attacker\socket'one、user name=postgres\role'one等恶意文本塞进 host/用户名/密码,最终connConfig.Host/User/Database/Password均保持原值,没有任何字段被拆解成新参数。
直接连接测试:先鉴权,后合并保存的凭据
端点与授权顺序
POST /api/v1/databases/test-connection-direct端点(路由注册见 controller.go)同时接受未保存的临时配置(ad hoc)和带保存 ID 的部分更新两种请求。旧的实现会先加载并合并已保存数据库,再检查访问权限——这意味着被保存密码在鉴权前就已经与调用者控制的 host 结合,调用者还可以声称自己控制的workspaceId来引用别人工作区的库。
现在的服务层顺序是先解析出已被授权的目标,再执行连接测试(service.go):
func (s *DatabaseService) TestDatabaseConnectionDirect( ctx context.Context, user *users_models.User, database *Database, ) error { usingDatabase, err := s.resolveAuthorizedConnectionTarget(ctx, user, database) if err != nil { return err } return s.testDatabaseConnection(ctx, usingDatabase) }resolveAuthorizedConnectionTarget(service.go)对两类请求分别处理:
- ad hoc(ID 为空):必须有
workspaceId,否则返回 "workspaceId is required"(HTTP 400);随后用workspaceService.CanUserManageDBs校验调用者在该工作区的数据库管理权限(interface定义见 interfaces.go)。 - 保存的 ID:先用
dbRepository.FindByID加载持久化行,按该行存储的工作区做权限校验;只有通过后才把请求字段existingDatabase.Update(database)叠加到已加载记录上,再执行Validate()。若请求携带的workspaceId与存储的不一致(database does not belong to this workspace),同样拒绝。
因此,被保存的密码在权限确认之前不会被合并进任何连接配置;即使调用者传入了不同的 host,系统也绝不与这个 host 建立连接。规格中"Saved database from another workspace"与"Authorized workspace member tests a saved database"两个场景正是对这两条分支的行为约束。
错误语义:未知 ID 与无权限 ID 返回同一个 403
端点刻意把"未知的保存 ID"与"无权访问的保存 ID"映射为同一个HTTP 403 响应,避免调用者通过响应差异探测 UUID 是否存在。内部故障则使用独立的、脱敏的500 消息。对应错误哨兵在 errors.go 中:
var ErrDatabaseConnectionTargetLookup = errors.New("failed to load database connection target") // "failed to verify database connection permissions" 对应 ErrDatabaseConnectionAuthorizationLookup // ErrInsufficientPermissionsToTestDatabaseConnection 触发 HTTP 403在resolveAuthorizedConnectionTarget中,gorm.ErrRecordNotFound被折叠成 403 权限哨兵;仓库层其他错误用errors.Join(ErrDatabaseConnectionTargetLookup, err)包装——底层错误保留在服务端日志上下文里,但响应体只暴露固定文本。控制器测试完整覆盖了这些语义(controller_test.go):{"error":"failed to load database connection target"}、{"error":"failed to verify database connection permissions"}、无 workspace 时的 400、未知 ID 的 403 等。
设计文档明确否定了"全部按 400 返回err.Error()"(会泄露存储与成员关系细节)、"未知 ID 返回 404"(会让调用者区分受保护 UUID 与未使用 UUID)两个替代方案,也否定了"先合并、拒绝时再清空密码"(不安全凭据已跨越授权边界)。
可信健康检查路径:系统内部连接不走用户端点
定时健康检查需要测试的是系统已经加载好的数据库,而非模拟某个终端用户,因此服务层拆出了两条路径:
- 用户面:
TestDatabaseConnectionDirect(ctx, user, database)—— 需要认证用户,走resolveAuthorizedConnectionTarget的完整授权流程; - 可信面:
TestTrustedDatabaseConnection(ctx, database)—— 直接testDatabaseConnection,没有用户主体,但仍与用户面共享连接实现与连接前的内嵌目标运行时检查(service.go)。
设计文档记录了为什么不把两者并成一个"可空用户"的方法:缺失的 principal 会静默变成任何调用者都能利用的授权绕过;也不给健康检查发明一个合成管理员身份(会让审计行为失真)。规格同时要求用户面端点不得暴露可信路径:HTTP 调用者只能走 workspace 授权路径。
容器运行时隔离:让 PostgreSQL 自己拒绝越权访问
应用层分类器无法覆盖"未来才会出现的本地 hostname 形式"或"未被识别的名称最终解析到容器自身",因此规格要求运行时层作为第二道边界,让认证失败兜底。这部分全部实现在 docker/start.sh,要点如下。
私有 Socket 目录与启动期 HBA
启动脚本创建/databasus-data/pgsocket作为内嵌实例的 Socket 目录,chmod 0700且所有权归运行时账号(见create_required_data_directories/normalize_data_permissions)。PostgreSQL 以-k /databasus-data/pgsocket启动,端口固定 5437,listen_addresses = 'localhost'(initialize_postgresql_cluster)。
引导阶段(bootstrap)写入一份临时的pg_hba.conf(write_bootstrap_postgresql_authentication):
local all postgres peer map=databasus_bootstrap local all all reject local replication all reject host all all 127.0.0.1/32 scram-sha-256 host all all ::1/128 scram-sha-256 host replication all 127.0.0.1/32 reject host replication all ::1/128 reject配合pg_ident.conf中的databasus_bootstrap databasus postgres映射:引导阶段只允许共享运行账号以postgres角色通过 peer 认证访问本机 Socket,其他本地用户一律reject。
运行时阶段在完成密码设置后切换 HBA(activate_runtime_postgresql_authentication):
local all all reject local replication all reject host all all 127.0.0.1/32 scram-sha-256 host all all ::1/128 scram-sha-256 host replication all 127.0.0.1/32 reject host replication all ::1/128 reject即:bootstrap 之后 Unix Socket 数据库登录一律拒绝(应用本身也走 TCP),回环 TCP 一律要求 SCRAM-SHA-256 认证,复制连接(replication)无论 Socket 还是 TCP 全部拒绝——内嵌集群不接受任何物理复制订阅。verify_postgresql_runtime_authentication在切换后立即自检:以应用账号走 Socket 登录必须失败,用生成密码走回环 TCP 必须成功,否则启动报错退出。
共享运行账号:不再用usermod -o制造重复 UID
镜像把 PostgreSQL 的 OS 账号改名为databasus并与应用共用(Dockerfile),同时仍支持自定义PUID/PGID。因为文件系统权限边界依赖 UID 唯一,启动脚本在重映射账号前用ensure_id_is_available检查目标 UID/GID 是否已被其他账号占用(getent passwd/group),一旦冲突就确定性报错退出,而不是用usermod -o硬造重复 UID。设计文档指出:重复 UID 会直接抹掉postgres与databasus之间的文件系统边界,而完全移除自定义 UID 支持又会破坏绑定挂载的 NAS/主机目录场景。
启动凭据轮换与 DSN 发布:无固定密码、无落盘秘密
每次启动生成随机密码
configure_postgresql_database从/dev/urandom读取 32 字节十六进制串作为内嵌角色密码,通过引导期的 peer Socket 连接执行ALTER USER postgres WITH PASSWORD,并确保databasus元数据库存在。容器每次启动都会轮换密码——旧密码立即失效,镜像内不再有任何可用的固定默认密码。
Dockerfile 还专门从烘焙进镜像的/.env里删除了DATABASE_DSN默认值,并注释说明了原因:"its development password cannot authenticate against the embedded database, whose password is generated at every start. Keeping it would turn a missing configuration into an authentication failure。"——这条处理直接对应规格中"被拒绝的凭据与缺失的配置必须产生可区分的消息"的要求。
DSN 通过/dev/shm在同容器进程间传递
docker exec启动的进程继承的是容器的配置环境,而不是 PID 1 的环境,所以生成的 DSN 不能只靠export传递。启动脚本把连接串写入内存文件系统:
readonly published_database_dsn_path="/dev/shm/databasus-database-dsn"- 写入:
configure_application_database_dsn在未显式提供DATABASE_DSN时export生成 DSN,并由运行时账号自己以umask 077写入该路径(无需 root 写文件,也避免额外权限需求); - 读取:后端配置加载(backend/internal/config/config.go)中
databaseDsnEnvVariable = "DATABASE_DSN"与publishedDatabaseDsnPath = "/dev/shm/databasus-database-dsn"同路径读取,使得容器内后续启动的控制台命令与主应用使用同一凭据; - 生命周期:
/dev/shm是内存后端,容器停止即消失——生成值不会进入持久卷或镜像,且不会出现在日志里。规格要求的"该值仅存在于运行中容器的生命周期内"由此落地。
外部元数据库:显式 DSN 优先且不被轮换
DATABASE_DSN环境变量是最高优先级来源。若操作者显式提供连接串,启动脚本不生成、不发布任何值(configure_application_database_dsn首行if [ -n "${DATABASE_DSN+x}" ]; then return; fi),应用与控制台命令全部使用该外部连接串,绝不 fallback 到镜像内置默认值。
这里有一个破坏性行为需要注意:如果操作者显式提供了指向内嵌数据库的旧固定密码 DSN,启动会保留该显式值、同时轮换内嵌角色密码——结果应用无法认证,且不会自动回退到生成凭据。因此规格要求这种配置"不被视为受支持的嵌入式数据库配置",升级时必须移除这类显式值,让启动脚本注入生成凭据。
控制台命令访问运行中容器的元数据库
规格要求:在已运行容器内执行的控制台命令(例如密码重置)必须与应用以相同方式解析元数据库连接,且不需要任何额外参数或文件。这正是/dev/shm/databasus-database-dsn存在的意义——docker exec进程通过 config.go 的DatabaseDsn字段与主进程一样读取发布路径。规格的三个场景随之满足:
- 密码重置命令连上内嵌库并写入新密码;
- 显式外部 DSN 下,命令连接的是外部库(显式值优先级更高);
- 应用持有自己连接的同时,控制台命令用同一凭据再开连接,互不干扰。
缺失配置与错误的可区分报告
规格要求"缺失元数据库配置"与"凭据被拒绝"必须报告为两种可区分的错误:前者不能伪装成认证失败。启动脚本删除镜像内默认DATABASE_DSN的行为(上一节)从根源上消除了"缺配置时拿默认密码去试"的路径;后端还专门豁免了--test-storage存储探针进程——config.go 的isStorageProbeProcess注明:"storage probe never opens the metadata database, and container startup runs it before the embedded database exists",因此探针不受缺失连接串约束,可以独立完成自身检查。这对应规格的"Storage probe before the database exists"场景。
操作者迁移与回滚要点
该能力涉及端点语义与容器启动行为的破坏性变更(详见 proposal.md 的 breaking 列表),升级前需注意:
- 移除指向内嵌库的显式
DATABASE_DSN,仅保留指向外部 PostgreSQL 的 DSN,让启动脚本注入生成凭据; - 更新直接连接测试的调用方:ad hoc 请求必须携带
workspaceId(缺失即 HTTP 400);接受已保存目标无权/未知均返回 403、内部查找失败返回 500 的新语义; - 检查
PUID/PGID:若配置的 UID/GID 已被容器内其他账号占用,启动会确定性失败,需改为未占用的 ID; - 升级前对
/databasus-data拍快照(设计文档的迁移计划第 4 步); - 回滚:新镜像启动时会重写 HBA、轮换内嵌密码,若需回退到旧镜像,要么恢复卷快照,要么先用当前镜像的 peer 认证 Socket 把内嵌角色重置为旧镜像期望的凭据,再回滚。
小结
Databasus 的内嵌 PostgreSQL 防护由两道互补的边界构成:应用层的共享目标分类器在模型校验与连接打开两个时机拒绝一切能触达内嵌实例的本地端点(含 Unix Socket、回环与未指定地址、容器别名、libc 数字 IPv4 变体、多 host fallback、本地 SSH 堡垒机),同时把 conninfo 的每个字段都作为字面量处理;容器层的 PostgreSQL 以私有 Socket、引导期 peer 映射、运行时 SCRAM 认证、复制拒绝和每次启动轮换的随机密码,让任何绕过分类的访问都认证失败。配合"先鉴权后合并凭据"的连接测试授权顺序、可信健康检查路径与脱敏错误语义,整个体系在不需要解析 DNS 的前提下,把工作区用户能够读取元数据库或窃取他人凭据的路径逐一封死。相关实现均可在此仓库中对照阅读:目标分类器、凭据构建、授权解析、容器启动脚本 与 规格原文。
- 数据库
- 灾备
【免费下载链接】databasus
PostgreSQL backup tool with Point-In-Time-Recovery and restore verification
相关推荐
Databasus 内部 PostgreSQL 防护机制解析:conninfo 引号、嵌入目标分类、连接测试授权与容器运行时隔离
Databasus 内部 PostgreSQL 防护机制解析:conninfo 引号、嵌入目标分类、连接测试授权与容器运行时隔离 本篇技术文章以 OpenSpe
数据库灾备Databasus 内置 PostgreSQL 访问隔离:目标分类器、conninfo 引号与容器认证三层防线
Databasus 内置 PostgreSQL 访问隔离:目标分类器、conninfo 引号与容器认证三层防线 Databasus 把自身的元数据库(Postg
数据库灾备Databasus 内嵌 PostgreSQL 防护设计:阻止工作区连接请求访问元数据库
Databasus 内嵌 PostgreSQL 防护设计:阻止工作区连接请求访问元数据库 在 Databasus 这种"备份工具 + 自带元数据库"的单体容器里
数据库灾备
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考