This commit is contained in:
parent
57f1dab894
commit
76635274fa
@ -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验证记录,请稍候……",
|
||||
|
||||
@ -291,12 +291,12 @@
|
||||
|
||||
|
||||
<el-form-item :label="lang.ssl_key_path" v-if="sslSettings.type === '1'">
|
||||
<el-input placeholder="./config/ssl/private.key" v-model="sslSettings.key_path"></el-input>
|
||||
<el-input placeholder="/ssl/example.com/example.com.key" 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="./config/ssl/public.crt" v-model="sslSettings.crt_path"></el-input>
|
||||
<el-input placeholder="/ssl/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>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user