From 76635274fa00c4ef230afe09512d0f9f822c0d65 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 31 Aug 2026 18:27:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=8B=E5=8A=A8=E8=AF=81?= =?UTF-8?q?=E4=B9=A6=E8=B7=AF=E5=BE=84=E5=88=9D=E5=A7=8B=E5=8C=96=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fe/src/i18n/i18n.js | 4 +- fe/src/views/Setup/Setup.vue | 4 +- server/controllers/setup.go | 3 + server/services/setup/ssl/ssl.go | 149 +++++++++++++----- .../setup/ssl/user_certificate_test.go | 37 ++++- 5 files changed, 147 insertions(+), 50 deletions(-) diff --git a/fe/src/i18n/i18n.js b/fe/src/i18n/i18n.js index c5a5d24..3c8f177 100644 --- a/fe/src/i18n/i18n.js +++ b/fe/src/i18n/i18n.js @@ -133,8 +133,8 @@ const lang = { "ssl_auto_certificate_path": "证书:./config/ssl/managed/default/current/public.crt", "ssl_auto_private_key_path": "私钥:./config/ssl/managed/default/current/private.key", "ssl_auto_account_key_path": "ACME 账户密钥:./config/ssl/account_private.pem", - "ssl_manual_key_desc": "填写运行目录下的私钥相对路径,例如 ./config/ssl/private.key。", - "ssl_manual_crt_desc": "填写运行目录下的证书相对路径,例如 ./config/ssl/public.crt;证书必须与私钥匹配。", + "ssl_manual_key_desc": "填写 ./config/ssl 下的相对路径,或 /ssl/域名 下的绝对路径;系统校验后会导入受控目录。", + "ssl_manual_crt_desc": "填写 ./config/ssl 下的相对路径,或 /ssl/域名 下的绝对路径;证书必须与私钥匹配。", "ssl_dns_title": "DNS验证记录", "ssl_dns_desc": "选择DNS验证后,点击“下一步”生成本次申请所需的TXT记录。添加记录并等待DNS生效后,再继续验证。", "ssl_dns_waiting": "正在生成DNS验证记录,请稍候……", diff --git a/fe/src/views/Setup/Setup.vue b/fe/src/views/Setup/Setup.vue index ca0d675..2ecc1b8 100644 --- a/fe/src/views/Setup/Setup.vue +++ b/fe/src/views/Setup/Setup.vue @@ -291,12 +291,12 @@ - +
{{ lang.ssl_manual_key_desc }}
- +
{{ lang.ssl_manual_crt_desc }}
diff --git a/server/controllers/setup.go b/server/controllers/setup.go index 03251dc..1ffb000 100644 --- a/server/controllers/setup.go +++ b/server/controllers/setup.go @@ -80,6 +80,9 @@ func writeSetupError(ctx *context.Context, w http.ResponseWriter, err error) { // 保留底层错误原因,便于定位数据库驱动、网络和权限问题;响应仍只返回安全的业务提示。 log.WithContext(ctx).WithError(err).Errorf("setup request failed: %v", err) message := setup.DatabaseSetupErrorMessage(err) + if message == "" { + message = ssl.UserCertificateErrorMessage(err) + } if message == "" { message = "\u521d\u59cb\u5316\u64cd\u4f5c\u5931\u8d25" } diff --git a/server/services/setup/ssl/ssl.go b/server/services/setup/ssl/ssl.go index 380b96b..5d5ae96 100644 --- a/server/services/setup/ssl/ssl.go +++ b/server/services/setup/ssl/ssl.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" + stderrors "errors" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/services/setup" "git.pcux.cn/xuans/Smail/signal" @@ -17,6 +18,7 @@ import ( "github.com/spf13/cast" "os" "path/filepath" + "runtime" "strings" "time" @@ -31,6 +33,29 @@ type MyUser struct { key crypto.PrivateKey } +var externalUserCertificateRoot = filepath.Join(string(os.PathSeparator), "ssl") + +type userCertificateError struct { + message string +} + +func (e *userCertificateError) Error() string { + return e.message +} + +func newUserCertificateError(message string) error { + return &userCertificateError{message: message} +} + +// UserCertificateErrorMessage 只返回可以安全展示给初始化页面的证书错误。 +func UserCertificateErrorMessage(err error) string { + var certificateError *userCertificateError + if stderrors.As(err, &certificateError) { + return certificateError.message + } + return "" +} + func (u *MyUser) GetEmail() string { return u.Email } @@ -72,9 +97,6 @@ func SetSSL(sslType, priKey, crtKey string) error { } } if cfg.SSLType == config.SSLTypeUser { - if isAbsoluteUserCertificatePath(priKey) || isAbsoluteUserCertificatePath(crtKey) { - return errors.New("用户证书路径必须使用受控相对路径") - } privatePath, publicPath, err := validateUserCertificatePaths(priKey, crtKey) if err != nil { return err @@ -132,10 +154,14 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef func isAbsoluteUserCertificatePath(value string) bool { value = strings.TrimSpace(value) - if value == "" || filepath.IsAbs(value) || filepath.VolumeName(value) != "" { + if value == "" { + return false + } + if filepath.IsAbs(value) { return true } - return strings.HasPrefix(value, `\\`) || strings.HasPrefix(value, "//") + volume := filepath.VolumeName(value) + return volume != "" || strings.HasPrefix(value, `\\`) || strings.HasPrefix(value, "//") } func validateUserCertificatePaths(privatePath, publicPath string) (string, string, error) { @@ -143,86 +169,123 @@ func validateUserCertificatePaths(privatePath, publicPath string) (string, strin if err != nil { return "", "", err } - base := filepath.Join(rootPath, "config", "ssl") - baseRelative, err := filepath.Rel(rootPath, base) + managedRoot := filepath.Join(rootPath, "config", "ssl") + externalRoot, err := filepath.Abs(filepath.Clean(externalUserCertificateRoot)) if err != nil { return "", "", err } - workspaceRoot, err := os.OpenRoot(rootPath) - if err != nil { - return "", "", errors.Wrap(err) - } - defer workspaceRoot.Close() - baseInfo, err := workspaceRoot.Lstat(baseRelative) - if err != nil || baseInfo.Mode()&os.ModeSymlink != 0 || !baseInfo.IsDir() { - return "", "", errors.New("SSL directory is invalid") - } - sslRoot, err := workspaceRoot.OpenRoot(baseRelative) - if err != nil { - return "", "", errors.New("SSL directory is invalid") - } - defer sslRoot.Close() - resolve := func(value string) (string, string, []byte, error) { + resolve := func(value string) (string, []byte, error) { if strings.TrimSpace(value) == "" { - return "", "", nil, errors.New("SSL path is required") + return "", nil, newUserCertificateError("请填写 SSL 证书和私钥路径") } - path := value - if !filepath.IsAbs(path) { + path := strings.TrimSpace(value) + allowedRoots := []string{managedRoot} + if isAbsoluteUserCertificatePath(path) { + if filepath.VolumeName(path) != "" && !filepath.IsAbs(path) { + return "", nil, newUserCertificateError("SSL 路径必须是完整的绝对路径") + } + allowedRoots = append(allowedRoots, externalRoot) + } else { path = filepath.Join(rootPath, path) } path, err = filepath.Abs(filepath.Clean(path)) if err != nil { - return "", "", nil, err + return "", nil, err } - relativePath, err := filepath.Rel(base, path) - if err != nil || relativePath == "." || filepath.IsAbs(relativePath) || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(os.PathSeparator)) { - return "", "", nil, errors.New("SSL path must stay inside config/ssl") + + var selectedRoot string + var relativePath string + for _, allowedRoot := range allowedRoots { + candidate, relativeErr := filepath.Rel(allowedRoot, path) + if relativeErr == nil && candidate != "." && !filepath.IsAbs(candidate) && candidate != ".." && !strings.HasPrefix(candidate, ".."+string(os.PathSeparator)) { + selectedRoot = allowedRoot + relativePath = candidate + break + } } + if selectedRoot == "" { + return "", nil, newUserCertificateError("SSL 文件仅允许放在 ./config/ssl 或 /ssl/域名 目录中") + } + if filepath.Clean(selectedRoot) == filepath.Clean(externalRoot) { + parts := strings.Split(filepath.Clean(relativePath), string(os.PathSeparator)) + if len(parts) < 2 || strings.TrimSpace(parts[0]) == "" { + return "", nil, newUserCertificateError("外部 SSL 文件必须放在 /ssl/域名 目录中") + } + selectedRoot = filepath.Join(externalRoot, parts[0]) + relativePath = filepath.Join(parts[1:]...) + } + + rootInfo, err := os.Lstat(selectedRoot) + if err != nil || rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() { + return "", nil, newUserCertificateError("SSL 证书目录不存在或不可访问") + } + sslRoot, err := os.OpenRoot(selectedRoot) + if err != nil { + return "", nil, newUserCertificateError("SSL 证书目录不可访问") + } + defer sslRoot.Close() + + currentPath := "" + for _, part := range strings.Split(filepath.Clean(relativePath), string(os.PathSeparator)) { + currentPath = filepath.Join(currentPath, part) + info, statErr := sslRoot.Lstat(currentPath) + if statErr != nil { + return "", nil, newUserCertificateError("SSL 文件不存在或无权读取") + } + if info.Mode()&os.ModeSymlink != 0 { + return "", nil, newUserCertificateError("SSL 路径不能包含符号链接") + } + } + info, err := sslRoot.Lstat(relativePath) if err != nil { - return "", "", nil, errors.Wrap(err) + return "", nil, newUserCertificateError("SSL 文件不存在或无权读取") } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 1<<20 { - return "", "", nil, errors.New("SSL file is invalid") + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 1<<20 { + return "", nil, newUserCertificateError("SSL 文件无效或大小超过限制") } content, err := sslRoot.ReadFile(relativePath) if err != nil || len(content) == 0 || len(content) > 1<<20 { - return "", "", nil, errors.New("SSL file is invalid") + return "", nil, newUserCertificateError("SSL 文件读取失败或内容无效") } - return filepath.Join(base, relativePath), relativePath, content, nil + return filepath.Join(selectedRoot, relativePath), content, nil } - privatePath, privateRelative, privatePEM, err := resolve(privatePath) + privatePath, privatePEM, err := resolve(privatePath) if err != nil { return "", "", err } - publicPath, _, publicPEM, err := resolve(publicPath) + publicPath, publicPEM, err := resolve(publicPath) if err != nil { return "", "", err } keyPair, err := tls.X509KeyPair(publicPEM, privatePEM) if err != nil || len(keyPair.Certificate) == 0 { - return "", "", errors.New("certificate and private key do not match") + return "", "", newUserCertificateError("SSL 证书与私钥不匹配") } certificate, err := x509.ParseCertificate(keyPair.Certificate[0]) if err != nil || time.Now().Before(certificate.NotBefore) || !time.Now().Before(certificate.NotAfter) { - return "", "", errors.New("certificate is invalid") + return "", "", newUserCertificateError("SSL 证书无效、尚未生效或已经过期") } switch publicKey := certificate.PublicKey.(type) { case *rsa.PublicKey: if publicKey.N.BitLen() < 2048 { - return "", "", errors.New("certificate key is too weak") + return "", "", newUserCertificateError("SSL 证书密钥强度不能低于 2048 位") } case *ecdsa.PublicKey: if publicKey.Curve == nil || publicKey.Curve.Params().BitSize < 256 { - return "", "", errors.New("certificate key is too weak") + return "", "", newUserCertificateError("SSL 证书密钥强度不能低于 256 位 ECDSA") } default: - return "", "", errors.New("certificate key type is unsupported") + return "", "", newUserCertificateError("不支持当前 SSL 证书密钥类型") } - if err := sslRoot.Chmod(privateRelative, 0600); err != nil { - return "", "", errors.New("private key permissions are invalid") + privateInfo, err := os.Stat(privatePath) + if err != nil { + return "", "", newUserCertificateError("SSL 私钥不存在或无权读取") + } + if runtime.GOOS != "windows" && privateInfo.Mode().Perm()&0007 != 0 { + return "", "", newUserCertificateError("SSL 私钥不能允许其他用户读取或写入") } return privatePath, publicPath, nil } diff --git a/server/services/setup/ssl/user_certificate_test.go b/server/services/setup/ssl/user_certificate_test.go index f4a5933..2e53784 100644 --- a/server/services/setup/ssl/user_certificate_test.go +++ b/server/services/setup/ssl/user_certificate_test.go @@ -64,8 +64,8 @@ func TestIsAbsoluteUserCertificatePathRejectsWindowsPaths(t *testing.T) { } func TestIsAbsoluteUserCertificatePathRejectsEmptyAndAcceptsRelative(t *testing.T) { - if !isAbsoluteUserCertificatePath("") { - t.Fatal("empty path must be rejected") + if isAbsoluteUserCertificatePath("") { + t.Fatal("empty path must not be treated as absolute") } if isAbsoluteUserCertificatePath(`config/ssl/upload/private.key`) { t.Fatal("controlled relative path must not be treated as absolute") @@ -100,14 +100,45 @@ func TestApplyManagedSSLReferenceDoesNotPersistLegacyPaths(t *testing.T) { func TestValidateUserCertificatePaths(t *testing.T) { oldRoot := config.ROOT_PATH + oldExternalRoot := externalUserCertificateRoot root := t.TempDir() + externalRoot := t.TempDir() config.ROOT_PATH = root + string(os.PathSeparator) - t.Cleanup(func() { config.ROOT_PATH = oldRoot }) + externalUserCertificateRoot = externalRoot + t.Cleanup(func() { + config.ROOT_PATH = oldRoot + externalUserCertificateRoot = oldExternalRoot + }) keyPath, certPath := writeTestCertificate(t, root, 2048) if _, _, err := validateUserCertificatePaths(keyPath, certPath); err != nil { t.Fatalf("valid certificate was rejected: %v", err) } + externalSourceRoot := t.TempDir() + externalKey, externalCert := writeTestCertificate(t, externalSourceRoot, 2048) + externalDirectory := filepath.Join(externalRoot, "example.com") + if err := os.MkdirAll(externalDirectory, 0700); err != nil { + t.Fatal(err) + } + externalKeyTarget := filepath.Join(externalDirectory, "private.key") + externalCertTarget := filepath.Join(externalDirectory, "public.crt") + keyPEM, err := os.ReadFile(externalKey) + if err != nil { + t.Fatal(err) + } + certPEM, err := os.ReadFile(externalCert) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(externalKeyTarget, keyPEM, 0640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(externalCertTarget, certPEM, 0644); err != nil { + t.Fatal(err) + } + if _, _, err := validateUserCertificatePaths(externalKeyTarget, externalCertTarget); err != nil { + t.Fatalf("external /ssl certificate was rejected: %v", err) + } weakKey, weakCert := writeTestCertificate(t, root, 1024) if _, _, err := validateUserCertificatePaths(weakKey, weakCert); err == nil { t.Fatal("weak certificate was accepted")