From 2e520eb5a8166391f76b4cf846a6c712846775cc Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 21 Sep 2026 04:38:46 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=A0=B9=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E4=B8=8E=E6=B3=9B=E5=9F=9F=E5=90=8D=E8=AF=81=E4=B9=A6=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=B9=B6=E8=A1=A5=E5=85=85=E4=BA=91=E7=AB=AF=E5=8F=8D?= =?UTF-8?q?=E4=BE=8B=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/ssl_apex_test.go | 179 ++++++++++++++++++++++ backend/config/ssl_custom.go | 4 +- docs/根域名证书校验补充验收.md | 88 +++++++++++ docs/生产补充验收记录.md | 7 + docs/静态安全告警复核记录.md | 6 + 5 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 backend/config/ssl_apex_test.go create mode 100644 docs/根域名证书校验补充验收.md diff --git a/backend/config/ssl_apex_test.go b/backend/config/ssl_apex_test.go new file mode 100644 index 00000000..b128301e --- /dev/null +++ b/backend/config/ssl_apex_test.go @@ -0,0 +1,179 @@ +package config + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestProjectDNSRuntimeRequiresRootAndWildcard(t *testing.T) { + for _, test := range []struct { + name string + domains []string + valid bool + }{ + {"root_and_wildcard", []string{"example.com", "*.example.com"}, true}, + {"wildcard_only", []string{"*.example.com"}, false}, + {"root_only", []string{"example.com"}, false}, + } { + t.Run(test.name, func(t *testing.T) { + previousRoot := ROOT_PATH + ROOT_PATH = t.TempDir() + t.Cleanup(func() { ROOT_PATH = previousRoot }) + key, certificate := testManagedCertificate(t, test.domains[0], time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, test.domains[1:]...) + // 故意不在准备阶段检查域名,模拟已有证书文件的加载与配置复验。 + reference, err := stageFlatSSLAtRoot(ROOT_PATH, ManagedSSLModeDefault, key, certificate, nil, nil) + if err != nil { + t.Fatal(err) + } + defer ReleaseStagedSSL(reference) + candidate := RuntimeConfig{Root: RootConfig{Domains: DomainConfig{ + Primary: "example.com", Web: "web.example.com", Accepted: []string{"example.com"}, + }}} + candidate.SSL.TLS = SSLConfig{ + Management: SSLManagementProject, Challenge: SSLChallengeDNS, + Mode: reference.Mode, CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef, + } + check := func(name string, err error) { + t.Helper() + if test.valid && err != nil { + t.Errorf("%s 拒绝完整域名覆盖:%v", name, err) + } + if !test.valid && !errors.Is(err, ErrSSLCertificateDomain) { + t.Errorf("%s 未拒绝不完整域名覆盖:%v", name, err) + } + } + check("配置复验", validateCustomSSLReferences(ROOT_PATH, candidate)) + cfg := &Config{Domain: "example.com", WebDomain: "web.example.com", Domains: []string{"example.com"}, SSL: candidate.SSL.TLS} + _, err = loadValidatedRuntimeCertificate(cfg) + check("运行时证书", err) + cfg.SSL.Management, cfg.SSL.Challenge, cfg.SSLType = "", "", SSLTypeAutoDNS + _, err = loadValidatedRuntimeCertificate(cfg) + check("旧自动 DNS 配置", err) + }) + } +} + +func TestProjectDNSExportRejectsMissingCoverageAndKeepsBundle(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("证书导出需要 Linux 权限检查") + } + for _, domain := range []string{"*.example.com", "example.com"} { + t.Run(domain, func(t *testing.T) { + root, output := t.TempDir(), t.TempDir() + if err := os.Chmod(output, 0700); err != nil { + t.Fatal(err) + } + candidate := validRuntimeConfig() + candidate.Root.Domains = DomainConfig{Primary: "example.com", Web: "web.example.com", Accepted: []string{"example.com"}} + install := func(domains ...string) { + t.Helper() + key, certificate := testManagedCertificate(t, domains[0], time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, domains[1:]...) + reference, err := stageFlatSSLAtRoot(root, ManagedSSLModeDefault, key, certificate, nil, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { releaseManagedSSL(root, SSLConfig{CertificateRef: reference.CertificateRef}) }) + candidate.SSL.TLS = SSLConfig{ + Management: SSLManagementProject, Challenge: SSLChallengeDNS, Mode: ManagedSSLModeDefault, + DefaultDirectory: "ssl", CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef, + } + writeRuntimeFixture(t, root, candidate) + } + install("example.com", "*.example.com") + destination := filepath.Join(output, "tls.pem") + if err := ExportProjectTLS(root, destination); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(destination) + if err != nil { + t.Fatal(err) + } + install(domain) + if err := ExportProjectTLS(root, destination); err == nil { + t.Error("导出接受了不完整的根域名与泛域名覆盖") + } + after, err := os.ReadFile(destination) + if err != nil || !bytes.Equal(before, after) { + t.Error("失败导出替换了有效证书内容") + } + current, err := os.Stat(destination) + if err != nil || !os.SameFile(info, current) || current.Mode().Perm() != 0600 { + t.Error("失败导出改变了有效证书文件或权限") + } + }) + } +} + +func TestProjectDNSReloadKeepsValidCertificateOnMissingRoot(t *testing.T) { + previousRoot := ROOT_PATH + previousConfig, previousPublished := publishedConfig.Load(), hasPublishedConfig.Load() + runtimeManager.Lock() + previousManager := runtimeManager.value + runtimeManager.value = nil + runtimeManager.Unlock() + liveTLS.Lock() + previousLiveRoot, previousCertificate := liveTLS.root, liveTLS.certificate + previousChecked, previousWarned := liveTLS.checked, liveTLS.warned + liveTLS.Unlock() + ROOT_PATH = t.TempDir() + t.Cleanup(func() { + ROOT_PATH = previousRoot + publishedConfig.Store(previousConfig) + hasPublishedConfig.Store(previousPublished) + runtimeManager.Lock() + runtimeManager.value = previousManager + runtimeManager.Unlock() + liveTLS.Lock() + liveTLS.root, liveTLS.certificate = previousLiveRoot, previousCertificate + liveTLS.checked, liveTLS.warned = previousChecked, previousWarned + liveTLS.Unlock() + }) + install := func(domains ...string) { + t.Helper() + key, certificate := testManagedCertificate(t, domains[0], time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, domains[1:]...) + reference, err := stageFlatSSLAtRoot(ROOT_PATH, ManagedSSLModeDefault, key, certificate, nil, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ReleaseStagedSSL(reference) }) + PublishConfigSnapshot(&Config{ + Domain: "example.com", WebDomain: "web.example.com", Domains: []string{"example.com"}, + SSL: SSLConfig{Management: SSLManagementProject, Challenge: SSLChallengeDNS, Mode: reference.Mode, + CertificateRef: reference.CertificateRef, PrivateKeyRef: reference.PrivateKeyRef}, + }) + } + install("example.com", "*.example.com") + configuration, err := NewRuntimeTLSConfig() + if err != nil { + t.Fatal(err) + } + before, err := configuration.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + install("*.example.com") + if err := ReloadRuntimeTLSCertificate(); !errors.Is(err, ErrSSLCertificateDomain) { + t.Fatalf("热加载未拒绝缺失根域名的新证书:%v", err) + } + after, err := configuration.GetCertificate(nil) + if err != nil || after == nil || !bytes.Equal(before.Certificate[0], after.Certificate[0]) { + t.Fatal("无效新证书替换了仍有效的内存证书") + } + install("example.com", "*.example.com") + if err := ReloadRuntimeTLSCertificate(); err != nil { + t.Fatal(err) + } + replaced, err := configuration.GetCertificate(nil) + if err != nil || replaced == nil || bytes.Equal(before.Certificate[0], replaced.Certificate[0]) { + t.Fatal("后续有效证书未能热加载") + } +} diff --git a/backend/config/ssl_custom.go b/backend/config/ssl_custom.go index 12c6ef77..62614438 100644 --- a/backend/config/ssl_custom.go +++ b/backend/config/ssl_custom.go @@ -188,7 +188,9 @@ func validateCustomSSLReferences(root string, candidate RuntimeConfig) error { func validationCertificateDomains(candidate *RuntimeConfig) []string { if EffectiveSSLManagement(candidate.SSL.TLS) == SSLManagementProject && candidate.SSL.TLS.Challenge == SSLChallengeDNS { - domains := []string{"*." + strings.TrimPrefix(candidate.Root.Domains.Primary, "*.")} + primary := strings.TrimPrefix(candidate.Root.Domains.Primary, "*.") + // 加载、导出和配置复验与自动申请一致,同时要求根域名和泛域名。 + domains := []string{primary, "*." + primary} if candidate.Root.Domains.Web != "" { domains = append(domains, candidate.Root.Domains.Web) } diff --git a/docs/根域名证书校验补充验收.md b/docs/根域名证书校验补充验收.md new file mode 100644 index 00000000..d0ca9cb9 --- /dev/null +++ b/docs/根域名证书校验补充验收.md @@ -0,0 +1,88 @@ +# 根域名证书校验补充验收 + +## 结论与范围 + +北京时间 2026 年 9 月 21 日,在已提交并推送的 `37bb937` 基线上补充验收。发现并修复自动 DNS 证书的校验不一致:申请时要求根域名和泛域名,但运行时共用的域名列表遗漏根域名。正式程序没有替换,其他项目没有停止,用户原有云端测试报告没有修改。 + +**这是一个已实机复现并修复的证书覆盖缺口,不代表真实 DNS 续期或整体验收通过。** + +## 需求与任务清单 + +- [✓] 自动 DNS 证书在申请、配置复验、加载和导出阶段均要求根域名与泛域名。 +- [✓] 在云服务器保留整改前失败反例,再用新编译测试程序验证修复。 +- [✓] 无效新证书不替换有效导出文件;热加载失败时保留尚有效的内存证书。 +- [✓] 后续完整证书仍可热加载,外部证书管理模式的既有兼容测试通过。 +- [✓] 云端配置证书专项 21 个顶层测试、SSL 初始化包 26 个顶层测试,均零跳过通过。 +- [✓] 本地运行时校验与热加载专项竞态回归通过。 +- [✓] 最新前端、插件和 Linux 程序已编译,三库空数据实机回归通过。 +- [✓] 配置包静态安全复扫完成,原有七条告警未增加;扫描退出 1,不计为安全门禁通过。 +- [!] 公网 DNS 挑战、自动续期、真实 CA 换证和全协议热加载仍需单独实机验收。 + +## 整改前反例 + +测试通过托管存储准备有效期内、密钥配对正确但仅包含 `*.example.com` 的测试证书,故意不在准备阶段校验域名,模拟已有文件的加载场景。没有使用正式私钥或修改正式证书。 + +云端目录:`/var/tmp/smail-review-ssl-apex-20260921/20260921-042413-before/`。 + +- 配置引用复验错误接受缺少根域名的证书。 +- 当前及旧自动 DNS 配置的运行时加载均错误接受。 +- 导出错误接受该证书,并替换已导出的有效证书包。 +- 根域名加泛域名的正向用例通过;只有根域名的反例已能正确拒绝。 + +本轮两个顶层测试失败,零跳过,云端测试退出 1。日志完整保留在 `test-log/production-remediation-20260921/ssl-apex-before/`。这是一个共享校验列表缺口的多路径表现,不按断言数宣传漏洞数量。 + +## 修复和回归 + +`backend/config/ssl_custom.go` 的 `validationCertificateDomains` 在自动 DNS 项目托管模式明确加入根域名,继续要求泛域名及已配置的服务域名。保留既有文件安全、证书密钥匹配、有效期和强度校验,不改变手动证书入口、管理责任或 DNS 记录。 + +新增 `backend/config/ssl_apex_test.go`,覆盖配置复验、当前及旧配置加载、导出文件内容和身份保留,以及内存证书的拒绝与后续恢复。热加载测试调用实际运行时证书回调,但不是外部客户端对全部邮件协议的换证演练。 + +| 云端轮次 | 目录后缀 | 结果 | +| --- | --- | --- | +| 最终配置证书专项 | `20260921-042721-final` | 21 个顶层测试通过,零跳过,退出零 | +| SSL 初始化完整包 | `20260921-042935-setup-all` | 26 个顶层测试通过,零跳过,退出零 | + +测试使用非特权 `smail`、独立临时目录及私有网络,正式目录不可访问;内存上限 256 MiB、CPU 配额 50%、禁用交换空间、运行时间上限 300 秒。最终配置证书专项执行输出记录内存峰值 8.1 MiB,不据此推导生产容量。 + +本地命令为 `node test/run.mjs backend -race -count=1 -p 1 -run '^TestProjectDNS(Runtime|Reload)' ./config`,通过。日志目录 `test-log/run-2026-09-20T20-28-01-801Z-38632/` 使用 UTC 文件名,不是北京时间日期。 + +## 候选产物 + +前端、Telegram 界面、三个 Linux 插件、Linux 主程序与管理员工具重新编译。版本为 `review-apex-20260921`,内嵌源码标识为 `37bb937c4040ae35a9393c418b9d7679f7bd436d-dirty`,不冒充已发布版本。 + +| 产物 | SHA-256 | +| --- | --- | +| 主程序 | `bb451caf67f6421253c5b4edddc6fce9961ac04613c4fe3166db36a465291939` | +| 管理员工具 | `e5b540a23f598f892f23b17f59e2f97c7560f8c3dca317ba6b6cdf2d67485821` | +| 最终配置测试程序 | `6d2cda0c4f3483bb05de86b8067588e27689f80043bd17b3e501e38cbbedacb4` | +| SSL 初始化测试程序 | `263126767560b5e09eccbc415eff5de4192c9c5babc6b16d90417f904a81db4d` | + +构建日志和摘要位于 `test-log/production-remediation-20260921/build-apex.log`、`apex-build-manifest.json`;配置目录 94 个 Go 文件的摘要记录在 `apex-config-source-manifest.json`。 + +## 最新候选三库实机回归 + +同一主程序上传至 `/var/tmp/smail-review-apex-20260921/`,每轮创建新的空数据隔离实例,以 192 MiB 内存限额验证直接 TLS 拓扑。 + +| 数据库 | 实例目录后缀 | 结果 | +| --- | --- | --- | +| SQLite | `smail-review-runtime-20260921-043114` | 通过;只有 `smail.db`,完整性检查正常 | +| MySQL | `smail-review-runtime-20260921-043229` | 通过;配置历史与业务同库,SQLite 文件零个 | +| PostgreSQL | `smail-review-runtime-20260921-043411` | 通过;配置历史与业务同库,SQLite 文件零个 | + +覆盖空库初始化、中文邮件和附件、IMAP APPEND/EXPUNGE、HTTP 旧会话撤销、协议码及客户端码撤销后的旧连接拒绝、过载恢复。三轮均无 OOM、资源采集错误或清理错误;SQL 测试账号已锁定,桥接已停止。 + +证据为 `apex-runtime-results.json`、`apex-runtime-evidence/` 和 `apex-evidence-index.json`,共十二份下载文件的摘要已经逐一复核。`apex-live-final.json` 再次确认六个保护服务和正式程序摘要未变,没有运行中的本轮隔离服务。该回归使用隔离域名和手动测试证书,不冒充公网域名验收、真实自动续期或新候选的 Nginx 正向复验。 + +## 静态扫描边界 + +Windows 主机设置 Linux/amd64、禁用 CGO,对 `./config` 运行原有 `gosec v2.22.10`。55 个文件、11,378 行,包加载错误零,原有 `nosec` 为 4;七条告警均为中等级,G304 五条、G302 两条,退出码 1。逐条按规则、文件、行号和代码片段与上一轮全项目报告中的配置包比较,集合完全一致。 + +原始日志为 `gosec-apex-config.log`,完整提取的 JSON 为 `gosec-apex-config.json`,SHA-256 为 `7c0eed5bd0cb48ab5fd926c431a8155671c5f8418aaeaa9830e49512c2ca7db9`。扫描后配置目录九十四个源码文件与构建时清单一致。这里只复扫变更包,不把它说成又一次全项目扫描;上一候选全项目三十八条仍是未通过记录,其他待办没有关闭。 + +## 保留的工具失败与证据边界 + +首次修复后,两个测试已经退出零,但验收辅助工具无条件再次停止已被 systemd 回收的短时单元,收到退出 5,将该轮判为失败;原始记录保留在 `ssl-apex-after/`,没有改成通过。 + +后续辅助工具额外读取 `LoadState`、`ActiveState`、`MainPID`,仅在进程为零、状态不活动且明确 `not-found` 时接受该停止返回值;其他停止错误仍拒绝。新建隔离轮次重新执行后通过。失败记录没有重置,正式服务进程标识和正式主程序摘要均保持不变。 + +原有浏览器、凭据轮换、外部邮件最终收件、迁移进程重启、容量和异机灾备等门禁仍保留,详见 `生产补充验收记录.md`。 diff --git a/docs/生产补充验收记录.md b/docs/生产补充验收记录.md index 05566810..832948cb 100644 --- a/docs/生产补充验收记录.md +++ b/docs/生产补充验收记录.md @@ -29,6 +29,7 @@ - [✓] 新候选在三种数据库下验证协议码和客户端码撤销:三协议旧连接操作及新登录均被拒绝。 - [✓] HTTP 退出后原 Cookie 重放、账号禁用及修改密码后的旧会话拒绝;浏览器操作另行验收。 - [✓] 管理员恢复整数边界及系统程序路径整改,云端 Linux 包 41 个顶层测试零跳过通过;新候选三库空数据实机复验通过。 +- [✓] 修复自动 DNS 证书加载与导出遗漏根域名校验,保留整改前云端失败反例;新候选的证书专项与三库回归通过。 - [ ] 真正应用进程切库重启后的业务验收、故障注入和中断恢复。 - [ ] 最新候选浏览器初始化、账号恢复及完整用户操作闭环。 - [!] Windows 提权本地通道验收需要管理员会话,当前终端未提权。 @@ -291,5 +292,11 @@ Windows 受保护本地通道验收需要提权会话,不得降级成公开 TC 全项目静态复扫实际结果为 38 条,退出码仍为 1;不增加 `nosec`,不以降低六条告警替代生产放行。独立浏览器启动被执行策略拒绝,准备实例和隧道已经停止,既未复用 Scert 浏览器,也没有浏览器通过证据。正式程序、六个保护服务及用户原有报告均保持不变。 +### 9 月 21 日根域名证书校验补充 + +详见 `根域名证书校验补充验收.md`。云端反例证实仅有泛域名的证书会被自动 DNS 配置复验、加载及导出错误接受;现统一加入根域名要求,并验证失败时保留有效导出文件及内存证书。云端配置证书专项 21 项、SSL 初始化包 26 项零跳过通过,最新主程序 `bb451caf67f6421253c5b4edddc6fce9961ac04613c4fe3166db36a465291939` 的三库空数据回归也已通过。 + +本次配置包静态复扫仍为原有七条,退出码 1;不是再次全项目复扫。测试仍不替代真实 DNS 自动续期、全协议换证、浏览器或正式实例升级。 + 本轮整理明确列出的应用整改、回归测试、验收工具、持续集成和本记录;原有 `docs/云端全项目测试报告.md` 的用户修改不编辑、不加入本轮提交。 本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。 diff --git a/docs/静态安全告警复核记录.md b/docs/静态安全告警复核记录.md index 020af944..e63c8ee2 100644 --- a/docs/静态安全告警复核记录.md +++ b/docs/静态安全告警复核记录.md @@ -43,6 +43,12 @@ JSON 从上述日志末尾完整提取至同目录 `gosec-revocation-linux.json` 原始输出为 `test-log/production-remediation-20260921/gosec-admin-final-linux.log`,完整提取 JSON 为同名 `.json`。管理员恢复的三条 G115、一条 G204 和两条 G104 已不再出现;其中关闭错误的显式忽略仅澄清意图,不能宣传为修复了两个已确认漏洞。旧七条专项证据仍有效,但扫描本身没有消除它们;其余三十一条仍未关闭。 +### 根域名证书校验修复后的配置包复扫 + +随后在 `37bb937` 基线上修复共享证书校验遗漏根域名的问题,详见 `根域名证书校验补充验收.md`。本次仅重新扫描变更的 `./config` 包:55 个文件、11,378 行、七条中等级告警(G304 五条、G302 两条),包加载错误零,原有 `nosec` 为 4,退出码 1。 + +原始文件为 `gosec-apex-config.log`,JSON 从同一完整日志提取。七条告警按规则、文件、行号、代码片段与上述全项目报告的配置包逐项比较完全一致;不增加忽略、不更改失败码。不能把变更包复扫描述为又一次全项目扫描,也不能认为未变告警已经排除。 + ## 已形成专项证据的七条告警 下表行号相对当前候选的 `backend/`。结论限于所列风险和代码路径,不涵盖整个迁移事务、所有输入来源、并发一致性或插件隔离能力。