From f530ab0078349546aab1620ac55e22fea0b5a07b Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 6 Sep 2026 22:39:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=9A=E8=A1=8C=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E9=BD=90=E8=BF=81=E7=A7=BB=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/runtime_validator.go | 4 + backend/config/webauthn.go | 72 ++++++++++++++++ backend/db/migration/registry_test.go | 2 +- backend/res_init/init.go | 5 +- .../ConfigSettings/ConfigForm/ConfigForm.js | 16 ++-- test/README.md | 2 +- test/backend/webauthn_config_test.go | 85 +++++++++++++++++++ test/backend/webauthn_runtime_test.go | 71 ++++++++++++++++ test/frontend/config-form.test.mjs | 22 ++++- test/run.mjs | 2 + 10 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 backend/config/webauthn.go create mode 100644 test/backend/webauthn_config_test.go create mode 100644 test/backend/webauthn_runtime_test.go diff --git a/backend/config/runtime_validator.go b/backend/config/runtime_validator.go index 5fef330..2e38c44 100644 --- a/backend/config/runtime_validator.go +++ b/backend/config/runtime_validator.go @@ -71,6 +71,10 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio if runtime.Root.HTTP.HTTPSEnabled && runtime.Root.HTTP.HTTPSPort == 0 { return ErrInvalidRuntimeConfig } + // 在保存候选配置时拒绝无效的安全密钥来源,避免保存成功却在重启时失败。 + if _, err := ResolveWebAuthnSecurity(runtime.Root); err != nil { + return err + } } if options.Production && runtime.SetupState == SetupStateDegraded && !runtime.Root.HTTP.HTTPSEnabled && runtime.Root.HTTP.TLSMode != HTTPSTLSModeProxy { return ErrInvalidRuntimeConfig diff --git a/backend/config/webauthn.go b/backend/config/webauthn.go new file mode 100644 index 0000000..9590b53 --- /dev/null +++ b/backend/config/webauthn.go @@ -0,0 +1,72 @@ +package config + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +// ResolveWebAuthnSecurity 为旧配置补足安全密钥站点信息,不修改持久化配置或用户开关。 +// 信赖方只能来自受控配置,不能根据请求 Host 或转发头自动扩展。 +func ResolveWebAuthnSecurity(root RootConfig) (SecurityConfig, error) { + security := root.Security + security.WebAuthnRPOrigins = append([]string(nil), security.WebAuthnRPOrigins...) + web, err := normalizeConfigDomain(root.Domains.Web) + if err != nil { + return SecurityConfig{}, fmt.Errorf("安全密钥缺少有效的站点域名") + } + if security.WebAuthnRPID == "" { + security.WebAuthnRPID = web + } + rpID, err := normalizeConfigDomain(security.WebAuthnRPID) + if err != nil || rpID != security.WebAuthnRPID { + return SecurityConfig{}, fmt.Errorf("安全密钥信赖方标识必须是规范域名") + } + if security.WebAuthnRPDisplayName == "" { + security.WebAuthnRPDisplayName = "Smail" + } + if strings.TrimSpace(security.WebAuthnRPDisplayName) == "" { + return SecurityConfig{}, fmt.Errorf("安全密钥站点显示名称不能为空") + } + if len(security.WebAuthnRPOrigins) == 0 { + origin := "https://" + web + // 反代的外部端口不等于内部 HTTP 端口,非标准外部端口需显式配置。 + if root.HTTP.TLSMode != HTTPSTLSModeProxy { + port := ResolveHTTPPortDefaults(root.HTTP, 0).HTTPSPort + if !validServicePort(port) { + return SecurityConfig{}, fmt.Errorf("安全密钥站点 HTTPS 端口无效") + } + if port != 443 { + origin += ":" + strconv.Itoa(port) + } + } + security.WebAuthnRPOrigins = []string{origin} + } + for _, origin := range security.WebAuthnRPOrigins { + parsed, err := url.Parse(origin) + if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Opaque != "" || + parsed.Path != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || + strings.ContainsAny(origin, "#?\\") { + return SecurityConfig{}, fmt.Errorf("安全密钥来源必须是完整且不含路径的站点地址") + } + host := parsed.Hostname() + normalizedHost, err := normalizeConfigDomain(host) + if err != nil || normalizedHost != host || strings.HasSuffix(parsed.Host, ":") { + return SecurityConfig{}, fmt.Errorf("安全密钥来源必须使用规范域名和有效端口") + } + if host != rpID && !strings.HasSuffix(host, "."+rpID) { + return SecurityConfig{}, fmt.Errorf("安全密钥来源不属于配置信赖方") + } + if parsed.Scheme != "https" && !(parsed.Scheme == "http" && host == "localhost") { + return SecurityConfig{}, fmt.Errorf("安全密钥来源必须使用 HTTPS,本机 localhost 除外") + } + if port := parsed.Port(); port != "" { + number, err := strconv.Atoi(port) + if err != nil || !validServicePort(number) { + return SecurityConfig{}, fmt.Errorf("安全密钥来源端口无效") + } + } + } + return security, nil +} diff --git a/backend/db/migration/registry_test.go b/backend/db/migration/registry_test.go index eed5303..5721a47 100644 --- a/backend/db/migration/registry_test.go +++ b/backend/db/migration/registry_test.go @@ -219,7 +219,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) { 13: "c51604f27347d0a1b93acad1e33af53463fceb7b6573960f881f95f29c5628ed", 14: "531398c2b8fc83a65753fae08666946bb253ac3da6596829e71cc8ccc4ddf62f", 15: "59f0ff9acfed3619c17f078d21a1362ddd5f1feacb9a2d77626f69040d25de65", - 16: "", + 16: "00cc275529e522d93b41e608692fb6e2271f1aa9300ebe020fcaed4d68d0ff76", } migrations := DefaultMigrations() if len(migrations) != len(expected) { diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 26cd0e9..10f624d 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -356,7 +356,10 @@ func startWebAuthnRuntime() error { return nil } snapshot := manager.Snapshot() - security := snapshot.Root.Security + security, err := config.ResolveWebAuthnSecurity(snapshot.Root) + if err != nil { + return fmt.Errorf("加载安全密钥站点配置失败: %w", err) + } service, err := webauthnservice.NewService(db.Instance, webauthnservice.Config{ RPID: security.WebAuthnRPID, RPDisplayName: security.WebAuthnRPDisplayName, diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index c3c20bd..b5cb87a 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -106,12 +106,16 @@ const baseGroups = [ hint: '默认使用 443;需要避开端口占用时可手动修改。' }) ]), - group('security', '全局安全选项', '只调整功能开关与站点信息,密码算法和加密密钥由系统维护。', [ - field('security.externalEmailEnabled', '启用外部邮箱功能', 'switch'), - field('security.webAuthnEnabled', '启用通行密钥', 'switch'), - field('security.webAuthnRpId', '通行密钥站点域名', 'text', { show: config => config.security?.webAuthnEnabled, placeholder: 'mail.example.com' }), - field('security.webAuthnRpDisplayName', '通行密钥显示名称', 'text', { show: config => config.security?.webAuthnEnabled, placeholder: 'Smail' }), - field('security.webAuthnRpOrigins', '通行密钥允许的来源', 'list', { show: config => config.security?.webAuthnEnabled, placeholder: 'https://mail.example.com' }) + group('security', '通行密钥站点信息', '外部邮箱与通行密钥服务默认可用,由用户在安全设置中自行选择。留空时使用当前站点的安全默认值;密码算法和加密密钥由系统维护。', [ + field('security.webAuthnRpId', '通行密钥站点域名', 'text', { + placeholder: '默认使用网页访问域名', + hint: '已绑定通行密钥后不要随意更换域名,否则原密钥可能无法使用。' + }), + field('security.webAuthnRpDisplayName', '通行密钥显示名称', 'text', { placeholder: 'Smail' }), + field('security.webAuthnRpOrigins', '通行密钥允许的来源', 'list', { + placeholder: 'https://mail.example.com', + hint: '每项填写不含路径的完整 HTTPS 来源;反代使用非标准外部端口时,请显式填写端口。' + }) ]) ] diff --git a/test/README.md b/test/README.md index a1d7b45..d7bb34a 100644 --- a/test/README.md +++ b/test/README.md @@ -3,7 +3,7 @@ ## 目录用途 - `frontend/`:前端自动测试及其构建路径约束测试,直接复用 `frontend/node_modules`。 -- `backend/`:集中保存邮件监听、账号协议隔离和凭据竞态回归,通过默认入口的 Go overlay 注入相应业务包;不复制测试文件到源码目录。 +- `backend/`:集中保存邮件监听、账号协议隔离、凭据竞态及 WebAuthn 站点配置和启动回归,通过默认入口的 Go overlay 注入相应业务包;不复制测试文件到源码目录。 - `tools/password-check/`:离线密码哈希校验工具,只通过环境变量接收输入,不输出密码和哈希。 - `manual/smtp/`:人工 SMTP 发信脚本,可能向外部发送真实邮件,不包含在自动测试中。运行前需自行检查收发件人和服务器配置。 - `plugins/spam_block/`:垃圾邮件模型测试,需要额外安装 TensorFlow 并准备相应模型;不包含在默认测试中。 diff --git a/test/backend/webauthn_config_test.go b/test/backend/webauthn_config_test.go new file mode 100644 index 0000000..3e50c8e --- /dev/null +++ b/test/backend/webauthn_config_test.go @@ -0,0 +1,85 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestWebAuthnDefaultsUseConfiguredSiteOnly(t *testing.T) { + t.Setenv("SMAIL_HTTPS_PORT", "") + for _, item := range []struct { + name string + http HTTPConfig + want string + }{ + {"legacy", HTTPConfig{}, "https://mail.example.com"}, + {"direct", HTTPConfig{HTTPSPort: 9443, HTTPSEnabled: true}, "https://mail.example.com:9443"}, + {"proxy", HTTPConfig{Port: 8080, TLSMode: HTTPSTLSModeProxy}, "https://mail.example.com"}, + } { + t.Run(item.name, func(t *testing.T) { + root := RootConfig{Domains: DomainConfig{Web: "mail.example.com", AccessHosts: []string{"untrusted.example.net"}}, HTTP: item.http} + got, err := ResolveWebAuthnSecurity(root) + if err != nil { + t.Fatal(err) + } + if got.WebAuthnRPID != "mail.example.com" || got.WebAuthnRPDisplayName != "Smail" || !reflect.DeepEqual(got.WebAuthnRPOrigins, []string{item.want}) { + t.Fatalf("站点默认值错误:%+v", got) + } + if root.Security.WebAuthnRPID != "" || len(root.Security.WebAuthnRPOrigins) != 0 { + t.Fatal("解析默认值改写了原始配置") + } + }) + } +} + +func TestWebAuthnExplicitConfigurationIsPreserved(t *testing.T) { + root := RootConfig{Domains: DomainConfig{Web: "mail.example.com"}, Security: SecurityConfig{ + WebAuthnRPID: "example.com", WebAuthnRPDisplayName: "企业邮箱", WebAuthnRPOrigins: []string{"https://mail.example.com:8443"}, + }} + got, err := ResolveWebAuthnSecurity(root) + if err != nil || !reflect.DeepEqual(got, root.Security) { + t.Fatalf("显式配置被覆盖:%v", err) + } + got.WebAuthnRPOrigins[0] = "https://changed.example.com" + if root.Security.WebAuthnRPOrigins[0] != "https://mail.example.com:8443" { + t.Fatal("解析结果与原始配置共用来源切片") + } +} + +func TestWebAuthnRejectsUnsafeConfiguredOrigins(t *testing.T) { + for _, origin := range []string{ + "http://mail.example.com", "https://attacker.example.net", "https://badexample.com", + "https://mail.example.com/path", "https://mail.example.com?", "https://mail.example.com#", + "https://user@mail.example.com", "https://mail.example.com:65536", "https://*.example.com", "", + "https://mail.example.com:", "https://mail.example.com:0", "https://mail.example.com:invalid", + } { + t.Run(origin, func(t *testing.T) { + root := RootConfig{Domains: DomainConfig{Web: "mail.example.com"}, Security: SecurityConfig{ + WebAuthnRPID: "example.com", WebAuthnRPOrigins: []string{origin}, + }} + if _, err := ResolveWebAuthnSecurity(root); err == nil { + t.Fatal("不安全的显式来源未被拒绝") + } + }) + } + if _, err := ResolveWebAuthnSecurity(RootConfig{}); err == nil { + t.Fatal("缺少站点配置时不应猜测信赖方") + } +} + +func TestWebAuthnDefaultsRespectExplicitLocalDevelopmentOrigin(t *testing.T) { + root := RootConfig{Domains: DomainConfig{Web: "localhost"}, Security: SecurityConfig{ + WebAuthnRPOrigins: []string{"http://localhost:15173"}, + }} + if _, err := ResolveWebAuthnSecurity(root); err != nil { + t.Fatalf("显式 localhost 来源被拒绝:%v", err) + } +} + +func TestRuntimeValidationRejectsInvalidWebAuthnBeforeSaving(t *testing.T) { + runtime := validRuntimeConfig() + runtime.Root.Security.WebAuthnRPOrigins = []string{"https://attacker.example.net"} + if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err == nil { + t.Fatal("候选配置接受了不属于站点信赖方的来源") + } +} diff --git a/test/backend/webauthn_runtime_test.go b/test/backend/webauthn_runtime_test.go new file mode 100644 index 0000000..3c0a5a7 --- /dev/null +++ b/test/backend/webauthn_runtime_test.go @@ -0,0 +1,71 @@ +package res_init + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/controllers" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + _ "modernc.org/sqlite" + "xorm.io/xorm" +) + +func TestWebAuthnStartsWithMigratedSiteDefaults(t *testing.T) { + root := t.TempDir() + previousRoot, previousDevelopment, previousDB := config.ROOT_PATH, config.DevelopmentMode, db.Instance + previousManager, previousService := config.CurrentManager(), webAuthnRuntimeService + config.ROOT_PATH, config.DevelopmentMode = root, false + t.Cleanup(func() { + stopConfigManager() + config.SetRuntimeManager(previousManager) + config.ROOT_PATH, config.DevelopmentMode, db.Instance = previousRoot, previousDevelopment, previousDB + webAuthnRuntimeService = previousService + controllers.ConfigureWebAuthnRuntime(previousService) + }) + if err := os.MkdirAll(filepath.Join(root, "config"), 0700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]any{ + "domain": "example.com", "webDomain": "mail.example.com", "dbType": "sqlite", + "dbDSN": "./config/existing.db", "isInit": true, "httpsEnabled": 1, "httpsPort": 443, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "config", "config.json"), data, 0600); err != nil { + t.Fatal(err) + } + if _, err := config.MigrateLegacyConfig(root, config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil { + t.Fatal(err) + } + if err := startConfigManager(); err != nil { + t.Fatal(err) + } + engine, err := xorm.NewEngine("sqlite", filepath.Join(root, "config", "existing.db")) + if err != nil { + t.Fatal(err) + } + defer engine.Close() + db.Instance = engine + if err := engine.Sync2(&models.User{}, &models.WebAuthnCredential{}); err != nil { + t.Fatal(err) + } + user := models.User{Account: "test", Name: "测试用户", Password: "unused", Role: "user"} + if _, err := engine.Insert(&user); err != nil { + t.Fatal(err) + } + if err := startWebAuthnRuntime(); err != nil { + t.Fatal(err) + } + if webAuthnRuntimeService == nil { + t.Fatal("未设置旧全局开关时安全密钥服务仍应可用") + } + options, _, err := webAuthnRuntimeService.BeginRegistration(user.ID) + if err != nil || options.Response.RelyingParty.ID != "mail.example.com" { + t.Fatalf("安全密钥注册未使用受控站点标识:%v", err) + } +} diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index ee76248..2d24f3f 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -10,6 +10,19 @@ const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } = source.replaceAll('export function ', 'function ') + '\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }' )() +test('站点安全配置不再依赖旧全局开关,用户权限仍由账号设置管理', () => { + const config = { security: { webAuthnEnabled: false, externalEmailEnabled: false } } + const groups = getFieldGroups('base', config) + const security = groups.find(group => group.id === 'security') + assert.ok(security) + const keys = security.fields.map(field => field.path.join('.')) + assert.deepEqual(keys, ['security.webAuthnRpId', 'security.webAuthnRpDisplayName', 'security.webAuthnRpOrigins']) + const updated = readFormConfig(updateFormConfig(JSON.stringify(config), 'base', ['security', 'webAuthnRpId'], 'mail.example.com')) + assert.equal(updated.security.webAuthnRpId, 'mail.example.com') + assert.equal(updated.security.webAuthnEnabled, false) + assert.equal(updated.security.externalEmailEnabled, false) +}) + test('七个页面使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => { const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc') @@ -167,7 +180,8 @@ test('基础配置隐藏额外访问主机、代理网段和数据库只读信 assert.equal(updateFormConfig(text, 'base', key.split('.'), 'changed'), text) } for (const key of ['security.externalEmailEnabled', 'security.webAuthnEnabled']) { - assert.ok(keys.includes(key)) + assert.ok(!keys.includes(key)) + assert.equal(updateFormConfig(text, 'base', key.split('.'), true), text) } const updated = readFormConfig(updateFormConfig(text, 'base', ['http', 'port'], 9080)) assert.deepEqual(updated, { ...config, http: { ...config.http, port: 9080 } }) @@ -188,8 +202,10 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正 text = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'proxy') assert.deepEqual(readFormConfig(text).http, { tlsMode: 'proxy', httpsEnabled: false, httpsPort: 0, port: 8080 }) assert.equal(updateFormConfig(text, 'base', ['http', 'httpsEnabled'], true), text) - text = updateFormConfig(text, 'base', ['security', 'externalEmailEnabled'], true) - assert.equal(readFormConfig(text).security.externalEmailEnabled, true) + const direct = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'direct') + const disabled = readFormConfig(updateFormConfig(direct, 'base', ['http', 'httpsEnabled'], false)) + assert.equal(disabled.http.httpsEnabled, false) + assert.equal(disabled.http.httpsPort, 0) text = updateFormConfig(text, 'base', ['http', 'port'], 8080) assert.equal(readFormConfig(text).http.port, 8080) text = updateFormConfig(text, 'base', ['http', 'port'], undefined) diff --git a/test/run.mjs b/test/run.mjs index e252b02..a484ed2 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -42,6 +42,8 @@ if (suite !== 'frontend' && result === 0) { const args = extra[0] === '--' ? extra.slice(1) : extra // 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。 const replacements = { + 'backend/config/webauthn_defaults_test.go': 'test/backend/webauthn_config_test.go', + 'backend/res_init/webauthn_runtime_test.go': 'test/backend/webauthn_runtime_test.go', 'backend/services/credentials/client_regression_test.go': 'test/backend/protocol_credentials_test.go', 'backend/controllers/client_regression_test.go': 'test/backend/protocol_access_test.go', 'backend/res_init/client_listeners_test.go': 'test/backend/mail_listeners_test.go',