支持任意手动SSL证书路径
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-02 16:23:22 +08:00
parent d5d1fa27d1
commit ed949f0ee8
4 changed files with 21 additions and 84 deletions

View File

@ -134,8 +134,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 下的相对路径,或 /ssl/域名 下的绝对路径;系统校验后会导入受控目录。",
"ssl_manual_crt_desc": "填写 ./config/ssl 下的相对路径,或 /ssl/域名 下的绝对路径;证书必须与私钥匹配。",
"ssl_manual_key_desc": "可填写服务器上任意可读取的完整路径,也可填写相对于 Smail 目录的路径;系统校验后会导入受控目录。",
"ssl_manual_crt_desc": "可填写服务器上任意可读取的完整路径,也可填写相对于 Smail 目录的路径;证书必须与私钥匹配。",
"ssl_dns_title": "DNS验证记录",
"ssl_dns_desc": "选择DNS验证后,点击“下一步”生成本次申请所需的TXT记录。添加记录并等待DNS生效后,再继续验证。",
"ssl_dns_waiting": "正在生成DNS验证记录,请稍候……",

View File

@ -324,12 +324,12 @@
<el-form-item :label="lang.ssl_key_path" v-if="sslSettings.type === '1'">
<el-input placeholder="/ssl/example.com/example.com.key" v-model="sslSettings.key_path"></el-input>
<el-input placeholder="/etc/letsencrypt/live/example.com/privkey.pem" v-model="sslSettings.key_path"></el-input>
<div class="ssl-manual-desc">{{ lang.ssl_manual_key_desc }}</div>
</el-form-item>
<el-form-item :label="lang.ssl_crt_path" v-if="sslSettings.type === '1'">
<el-input placeholder="/ssl/example.com/fullchain.pem" v-model="sslSettings.crt_path"></el-input>
<el-input placeholder="/etc/letsencrypt/live/example.com/fullchain.pem" v-model="sslSettings.crt_path"></el-input>
<div class="ssl-manual-desc">{{ lang.ssl_manual_crt_desc }}</div>
</el-form-item>
</el-form>

View File

@ -33,8 +33,6 @@ type MyUser struct {
key crypto.PrivateKey
}
var externalUserCertificateRoot = filepath.Join(string(os.PathSeparator), "ssl")
type userCertificateError struct {
message string
}
@ -169,23 +167,15 @@ func validateUserCertificatePaths(privatePath, publicPath string) (string, strin
if err != nil {
return "", "", err
}
managedRoot := filepath.Join(rootPath, "config", "ssl")
externalRoot, err := filepath.Abs(filepath.Clean(externalUserCertificateRoot))
if err != nil {
return "", "", err
}
resolve := func(value string) (string, []byte, error) {
if strings.TrimSpace(value) == "" {
return "", nil, newUserCertificateError("请填写 SSL 证书和私钥路径")
}
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)
}
@ -194,58 +184,18 @@ func validateUserCertificatePaths(privatePath, publicPath string) (string, strin
return "", nil, err
}
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 证书目录不存在或不可访问")
}
candidatePath := filepath.Join(selectedRoot, relativePath)
currentPath := selectedRoot
for _, part := range strings.Split(filepath.Clean(relativePath), string(os.PathSeparator)) {
currentPath = filepath.Join(currentPath, part)
info, statErr := os.Lstat(currentPath)
if statErr != nil {
return "", nil, newUserCertificateError("SSL 文件不存在或无权读取")
}
if info.Mode()&os.ModeSymlink != 0 {
return "", nil, newUserCertificateError("SSL 路径不能包含符号链接")
}
}
info, err := os.Lstat(candidatePath)
info, err := os.Stat(path)
if err != nil {
return "", nil, newUserCertificateError("SSL 文件不存在或无权读取")
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 1<<20 {
return "", nil, newUserCertificateError("SSL 文件无效或大小超过限制")
}
content, err := os.ReadFile(candidatePath)
content, err := os.ReadFile(path)
if err != nil || len(content) == 0 || len(content) > 1<<20 {
return "", nil, newUserCertificateError("SSL 文件读取失败或内容无效")
}
return filepath.Join(selectedRoot, relativePath), content, nil
return path, content, nil
}
privatePath, privatePEM, err := resolve(privatePath)

View File

@ -49,7 +49,7 @@ func writeTestCertificate(t *testing.T, root string, bits int) (string, string)
return keyPath, certPath
}
func TestIsAbsoluteUserCertificatePathRejectsWindowsPaths(t *testing.T) {
func TestIsAbsoluteUserCertificatePathRecognizesWindowsPaths(t *testing.T) {
cases := []string{
`C:\outside\private.key`,
`D:/outside/public.crt`,
@ -100,14 +100,10 @@ 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)
externalUserCertificateRoot = externalRoot
t.Cleanup(func() {
config.ROOT_PATH = oldRoot
externalUserCertificateRoot = oldExternalRoot
})
keyPath, certPath := writeTestCertificate(t, root, 2048)
@ -116,43 +112,34 @@ func TestValidateUserCertificatePaths(t *testing.T) {
}
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)
if _, _, err := validateUserCertificatePaths(externalKey, externalCert); err != nil {
t.Fatalf("certificate in an arbitrary directory was rejected: %v", err)
}
externalKeyTarget := filepath.Join(externalDirectory, "private.key")
externalCertTarget := filepath.Join(externalDirectory, "public.crt")
keyPEM, err := os.ReadFile(externalKey)
relativeKey, err := filepath.Rel(root, externalKey)
if err != nil {
t.Fatal(err)
}
certPEM, err := os.ReadFile(externalCert)
relativeCert, err := filepath.Rel(root, 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)
if _, _, err := validateUserCertificatePaths(relativeKey, relativeCert); err != nil {
t.Fatalf("certificate in an arbitrary relative directory was rejected: %v", err)
}
weakKey, weakCert := writeTestCertificate(t, root, 1024)
if _, _, err := validateUserCertificatePaths(weakKey, weakCert); err == nil {
t.Fatal("weak certificate was accepted")
}
outside := filepath.Join(t.TempDir(), "outside.key")
if err := os.WriteFile(outside, []byte("invalid"), 0600); err != nil {
invalid := filepath.Join(t.TempDir(), "invalid.key")
if err := os.WriteFile(invalid, []byte("invalid"), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := validateUserCertificatePaths(outside, certPath); err == nil {
t.Fatal("outside path was accepted")
if _, _, err := validateUserCertificatePaths(invalid, certPath); err == nil {
t.Fatal("invalid certificate data was accepted")
}
}
func TestValidateUserCertificatePathsRejectsEscapingParentSymlink(t *testing.T) {
func TestValidateUserCertificatePathsAcceptsParentSymlink(t *testing.T) {
oldRoot := config.ROOT_PATH
root := t.TempDir()
config.ROOT_PATH = root + string(os.PathSeparator)
@ -170,7 +157,7 @@ func TestValidateUserCertificatePathsRejectsEscapingParentSymlink(t *testing.T)
}
linkedKey := filepath.Join(linkPath, filepath.Base(outsideKey))
linkedCert := filepath.Join(linkPath, filepath.Base(outsideCert))
if _, _, err := validateUserCertificatePaths(linkedKey, linkedCert); err == nil {
t.Fatal("certificate paths escaped through a parent symlink")
if _, _, err := validateUserCertificatePaths(linkedKey, linkedCert); err != nil {
t.Fatalf("certificate paths through a parent symlink were rejected: %v", err)
}
}