修复通行密钥默认配置启动并补齐迁移校验
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-06 22:39:07 +08:00
parent 20a6eea3b3
commit f530ab0078
10 changed files with 269 additions and 12 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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) {

View File

@ -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,

View File

@ -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 来源;反代使用非标准外部端口时,请显式填写端口。'
})
])
]

View File

@ -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 并准备相应模型;不包含在默认测试中。

View File

@ -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("候选配置接受了不属于站点信赖方的来源")
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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',