数据库灾备【免费下载链接】databasusPostgreSQL backup tool with Point-In-Time-Recovery and restore verification项目地址https://gitcode.com/gh_mirrors/po/databasus点击查看免费下载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 连接串本身就是一种迷你语言如果用户把dbnamedatabasus之类的文本塞进数据库名或密码字段而代码不做转义直接拼进 conninfo就可能被解析成新的参数从而重定向连接。防护策略因此分三层识别并拒绝指向内嵌实例的目标应用层、把所有连接字段当字面量处理解析层、让容器内的 PostgreSQL 自身拒绝任何未经生成凭据的访问运行时层。下文的每一条规格都能在这三层中找到对应的实现。逻辑备份的目标分类识别一切指向内嵌元数据库的本地端点分类规则与判定逻辑规格要求当逻辑备份源的数据库名是databasus且任一有效 host 指向容器本身时系统必须在打开连接之前拒绝该源。所谓指向容器本身判定的是 host 是否属于本地端点local endpoint而不只是字符串相等。核心实现在 backend/internal/features/databases/databases/postgresql/shared/embedded_target.go 的ValidateNotEmbeddedTarget与isLocalEndpointHostEntryconst embeddedPostgresPort 5437 type EmbeddedTargetSpec struct { Host string Port int DatabaseName string IsPhysical bool IsSSHTunnelEnabled bool SSHBastionHost string }判定本地端点的完整集合如下Host 形式示例判定依据源码文件系统 Unix Socket/tmp、/databasus-data/pgsocketfilepath.IsAbs(host)为真抽象 Unix Socketabstractstrings.HasPrefix(host, )回环 / 未指定 IP127.42.0.9、::1、0.0.0.0、::net.ParseIPIsLoopback()/IsUnspecified()容器别名localhost、host.docker.internal、172.17.0.1显式 switch 命中libc 兼容数字 IPv4127.1、127.0.1、2130706433、0177.0.0.1、0x7f000001、0parseIPv4AddressAcceptedByLibc解析后命中回环/未指定范围逗号分隔列表中的空项remote.invalid,空条目按本地端点处理尾部带点的域名localhost.先TrimSuffix(., host)再比较其中parseIPv4AddressAcceptedByLibc值得单独说明它支持 1 到 4 段的 IPv4 写法每段用strconv.ParseUint(part, 0, 32)以0为基数解析从而接受十进制、八进制、十六进制并按段数做不同的位组装后还原成net.IPv4。这意味着用户写127.12 段、127.0.13 段乃至单个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 连接串按空格和引号规则切分参数dbnamedatabasus这样的文本若原样进入连接串会被解析成新的dbname参数覆盖原本的数据库名。规格要求所有用户可控字段host、用户名、密码、TLS 模式、证书路径、数据库名在进入 conninfo 前必须作为一个字面量处理即使它们包含空白、引号、反斜杠、等号或形似其他参数名的文本。实现位于 credentials.go核心是quoteConninfoValuefunc 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走同样的字面量策略并追加replicationtrue。把内容当作数据而不是对选中的子串做拒绝比黑名单更健壮——合法标识符本来就可以包含奇怪字符黑名单也无法覆盖所有参数名与转义形式。逻辑转储的库名单字段 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 dbnamedatabasus作为库名经pgx.ParseConfig解析后connConfig.Database仍等于完整输入串Test_BuildConnConfig_TreatsCredentialFieldsAsLiteralValues则把db hostattacker\socketone、user namepostgres\roleone等恶意文本塞进 host/用户名/密码最终connConfig.Host/User/Database/Password均保持原值没有任何字段被拆解成新参数。直接连接测试先鉴权后合并保存的凭据端点与授权顺序POST /api/v1/databases/test-connection-direct端点路由注册见 controller.go同时接受未保存的临时配置ad hoc和带保存 ID 的部分更新两种请求。旧的实现会先加载并合并已保存数据库再检查访问权限——这意味着被保存密码在鉴权前就已经与调用者控制的 host 结合调用者还可以声称自己控制的workspaceId来引用别人工作区的库。现在的服务层顺序是先解析出已被授权的目标再执行连接测试service.gofunc (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) }resolveAuthorizedConnectionTargetservice.go对两类请求分别处理ad hocID 为空必须有workspaceId否则返回 workspaceId is requiredHTTP 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启动端口固定 5437listen_addresses localhostinitialize_postgresql_cluster。引导阶段bootstrap写入一份临时的pg_hba.confwrite_bootstrap_postgresql_authenticationlocal all postgres peer mapdatabasus_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。运行时阶段在完成密码设置后切换 HBAactivate_runtime_postgresql_authenticationlocal 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_DSNx} ]; 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 的前提下把工作区用户能够读取元数据库或窃取他人凭据的路径逐一封死。相关实现均可在此仓库中对照阅读目标分类器、凭据构建、授权解析、容器启动脚本 与 规格原文。赞分享数据库灾备【免费下载链接】databasusPostgreSQL backup tool with Point-In-Time-Recovery and restore verification项目地址https://gitcode.com/gh_mirrors/po/databasus点击查看免费下载相关推荐Databasus 内部 PostgreSQL 防护机制解析conninfo 引号、嵌入目标分类、连接测试授权与容器运行时隔离Databasus 内部 PostgreSQL 防护机制解析conninfo 引号、嵌入目标分类、连接测试授权与容器运行时隔离 本篇技术文章以 OpenSpe数据库灾备Databasus 内置 PostgreSQL 访问隔离目标分类器、conninfo 引号与容器认证三层防线Databasus 内置 PostgreSQL 访问隔离目标分类器、conninfo 引号与容器认证三层防线 Databasus 把自身的元数据库Postg数据库灾备Databasus 内嵌 PostgreSQL 防护设计阻止工作区连接请求访问元数据库Databasus 内嵌 PostgreSQL 防护设计阻止工作区连接请求访问元数据库 在 Databasus 这种备份工具 自带元数据库的单体容器里数据库灾备上一篇3天上线企业级供应链系统Ant Design Vue Pro实战指南下一篇10分钟上手gh_mirrors/bd/bds-files生物信息学新手必备的 Unix 命令速成指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考