From 77527c293bc8c791e6880f747a12a914b296c20c Mon Sep 17 00:00:00 2001 From: xuans Date: Sat, 26 Sep 2026 22:26:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E8=AF=81=E4=B9=A6=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=B9=B6=E6=A0=A1=E9=AA=8C=E5=86=99=E5=85=A5=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/bootstrap/certificate_root.go | 80 ++++++++++ .../bootstrap/certificate_root_test.go | 146 ++++++++++++++++++ backend/internal/bootstrap/complete.go | 17 +- backend/internal/bootstrap/complete_test.go | 2 +- backend/internal/bootstrap/router.go | 4 + doc/证书目录配置说明.md | 16 ++ .../src/views/SetupWizard/BootstrapPayload.js | 2 + .../SetupWizard/BootstrapPayload.test.js | 2 +- .../src/views/SetupWizard/SetupWizard.vue | 9 +- .../SetupWizard/SetupWizardBootstrap.test.js | 12 +- .../SetupWizard/SetupWizardLayout.test.js | 11 ++ 11 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 backend/internal/bootstrap/certificate_root.go create mode 100644 backend/internal/bootstrap/certificate_root_test.go create mode 100644 doc/证书目录配置说明.md diff --git a/backend/internal/bootstrap/certificate_root.go b/backend/internal/bootstrap/certificate_root.go new file mode 100644 index 0000000..f39e210 --- /dev/null +++ b/backend/internal/bootstrap/certificate_root.go @@ -0,0 +1,80 @@ +package bootstrap + +import ( + "crypto/rand" + "errors" + "os" + "path/filepath" + "strings" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +// prepareCertificateRoot 在申请证书前验证路径,并以实际运行身份探测创建、写入和清理权限。 +func prepareCertificateRoot(paths config.PathsConfig, requested string) (string, error) { + root := strings.TrimSpace(requested) + if root == "" { + root = paths.Certificate + } + root = filepath.Clean(root) + invalid := func(err error) (string, error) { + return "", completeError(CompleteErrorCertificatePath, "证书部署根目录无效", err) + } + unwritable := func(err error) (string, error) { + return "", completeError(CompleteErrorCertificateAccess, "证书部署根目录不可写", err) + } + if !filepath.IsAbs(root) || filepath.Dir(root) == root || len(root) > 4096 || strings.ContainsAny(root, "\x00\r\n") { + return invalid(nil) + } + // 允许安装目录下独立的证书子目录,但不能覆盖安装目录本身或其他运行数据。 + if pathWithinCertificateRoot(root, paths.Install) { + return invalid(nil) + } + for _, protected := range []string{paths.Config, paths.Data, paths.Log, paths.Backup} { + if pathWithinCertificateRoot(root, protected) || pathWithinCertificateRoot(protected, root) { + return invalid(nil) + } + } + for current := root; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) { + return invalid(nil) + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return unwritable(err) + } + if filepath.Dir(current) == current { + break + } + } + if err := os.MkdirAll(root, 0o700); err != nil { + return unwritable(err) + } + directory, err := os.OpenRoot(root) + if err != nil { + return unwritable(err) + } + defer directory.Close() + // 探测文件使用随机名称和排他创建,只清理本次创建的文件,不修改已有文件。 + name := ".scert-write-check-" + rand.Text() + probe, err := directory.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return unwritable(err) + } + _, writeErr := probe.Write([]byte("scert")) + syncErr := probe.Sync() + closeErr := probe.Close() + removeErr := directory.Remove(name) + if err := errors.Join(writeErr, syncErr, closeErr, removeErr); err != nil { + return unwritable(err) + } + return root, nil +} + +func pathWithinCertificateRoot(parent, child string) bool { + if strings.TrimSpace(parent) == "" || strings.TrimSpace(child) == "" { + return false + } + relative, err := filepath.Rel(parent, child) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) +} diff --git a/backend/internal/bootstrap/certificate_root_test.go b/backend/internal/bootstrap/certificate_root_test.go new file mode 100644 index 0000000..f03bb83 --- /dev/null +++ b/backend/internal/bootstrap/certificate_root_test.go @@ -0,0 +1,146 @@ +package bootstrap + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestCompleterPersistsAndUsesCustomCertificateRoot(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + request := fixture.request("admin.example.test") + request.CertificateRoot = filepath.Join(t.TempDir(), "custom-certificates") + result, err := fixture.completer(nil).Complete(context.Background(), request) + if err != nil || !result.Completed { + t.Fatalf("completion failed: %v", err) + } + stored, err := config.Load(fixture.configPath) + if err != nil { + t.Fatal(err) + } + if stored.Paths.Certificate != request.CertificateRoot || !pathWithinCertificateRoot(request.CertificateRoot, stored.Server.TLSCert) { + t.Fatalf("custom directory not used: root=%s certificate=%s", stored.Paths.Certificate, stored.Server.TLSCert) + } + if _, err := os.Stat(stored.Server.TLSCert); err != nil { + t.Fatal(err) + } +} + +func TestCertificateRootDefaultsAndRemovesOnlyItsProbe(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + root := fixture.base.Paths.Certificate + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(root, "existing-file") + if err := os.WriteFile(marker, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + resolved, err := prepareCertificateRoot(fixture.base.Paths, "") + if err != nil || resolved != root { + t.Fatalf("default directory failed: %s %v", resolved, err) + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 1 || entries[0].Name() != "existing-file" { + t.Fatalf("probe leaked or existing data changed: %v %v", entries, err) + } + data, err := os.ReadFile(marker) + if err != nil || string(data) != "keep" { + t.Fatal("existing data changed") + } +} + +func TestCertificateRootRejectsUnsafePaths(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + paths := fixture.base.Paths + root := filepath.VolumeName(paths.Install) + string(filepath.Separator) + for _, path := range []string{ + "relative/certificates", root, paths.Install, filepath.Dir(paths.Install), + paths.Config, paths.Data, paths.Log, paths.Backup, + filepath.Join(paths.Config, "certificates"), filepath.Join(paths.Data, "certificates"), + paths.Certificate + "\ninvalid", paths.Certificate + "\x00invalid", + } { + t.Run(path, func(t *testing.T) { + _, err := prepareCertificateRoot(paths, path) + var failure *CompleteError + if !errors.As(err, &failure) || failure.Kind != CompleteErrorCertificatePath { + t.Fatalf("unsafe path accepted: %v", err) + } + }) + } +} + +func TestCertificateRootRejectsFilesAndSymlinks(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + root := t.TempDir() + file := filepath.Join(root, "file") + if err := os.WriteFile(file, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := prepareCertificateRoot(fixture.base.Paths, file); err == nil { + t.Fatal("file accepted as certificate directory") + } + link := filepath.Join(root, "link") + if err := os.Symlink(t.TempDir(), link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + for _, path := range []string{link, filepath.Join(link, "nested")} { + if _, err := prepareCertificateRoot(fixture.base.Paths, path); err == nil { + t.Fatal("symbolic link accepted") + } + } +} + +func TestCertificateRootRejectsUnwritableDirectory(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("requires Unix file permissions without root privileges") + } + fixture := newCompletionFixture(t, "admin.example.test") + root := t.TempDir() + if err := os.Chmod(root, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o700) }) + _, err := prepareCertificateRoot(fixture.base.Paths, root) + var failure *CompleteError + if !errors.As(err, &failure) || failure.Kind != CompleteErrorCertificateAccess { + t.Fatalf("unwritable directory accepted: %v", err) + } +} + +func TestCompleterRejectsCertificateRootBeforeIssuing(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + request := fixture.request("admin.example.test") + request.CertificateRoot = fixture.base.Paths.Config + _, err := fixture.completer(nil).Complete(context.Background(), request) + if err == nil || fixture.issuer.calls != 0 { + t.Fatalf("unsafe directory reached certificate issuer: %v", err) + } + if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("unsafe directory committed configuration") + } +} + +func TestCertificateRootErrorsDoNotExposeUnderlyingDetails(t *testing.T) { + for _, item := range []struct { + kind CompleteErrorKind + status int + }{ + {CompleteErrorCertificatePath, http.StatusBadRequest}, + {CompleteErrorCertificateAccess, http.StatusUnprocessableEntity}, + } { + response := httptest.NewRecorder() + writeBootstrapCompletionError(response, completeError(item.kind, "secret-value", errors.New("secret-value"))) + if response.Code != item.status || strings.Contains(response.Body.String(), "secret-value") { + t.Fatalf("unsafe response: %d %s", response.Code, response.Body.String()) + } + } +} diff --git a/backend/internal/bootstrap/complete.go b/backend/internal/bootstrap/complete.go index a6bd839..246bce4 100644 --- a/backend/internal/bootstrap/complete.go +++ b/backend/internal/bootstrap/complete.go @@ -29,10 +29,12 @@ var ( type CompleteErrorKind string const ( - CompleteErrorInput CompleteErrorKind = "input" - CompleteErrorVerification CompleteErrorKind = "verification" - CompleteErrorDependency CompleteErrorKind = "dependency" - CompleteErrorInternal CompleteErrorKind = "internal" + CompleteErrorInput CompleteErrorKind = "input" + CompleteErrorVerification CompleteErrorKind = "verification" + CompleteErrorDependency CompleteErrorKind = "dependency" + CompleteErrorInternal CompleteErrorKind = "internal" + CompleteErrorCertificatePath CompleteErrorKind = "certificate_path" + CompleteErrorCertificateAccess CompleteErrorKind = "certificate_access" ) // CompleteError 只向 HTTP 层公开安全分类和通用说明,底层错误不得直接返回浏览器。 @@ -72,6 +74,7 @@ type CompleteRequest struct { PropagationTimeoutSecs int `json:"propagation_timeout_secs"` PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"` Notification *NotificationInput `json:"notification,omitempty"` + CertificateRoot string `json:"certificate_root,omitempty"` } type CompleteResult struct { @@ -243,6 +246,10 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques candidate := completer.base candidate.Normalize() + certificateRoot, err := prepareCertificateRoot(candidate.Paths, request.CertificateRoot) + if err != nil { + return result, err + } candidate.ACME.Environment = request.ACMEEnvironment notification, discardNotification, err := completer.prepareNotification(ctx, candidate.Notification, request.Notification) defer func() { @@ -260,7 +267,7 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques DNSCredentialRef: dnsSession.CredentialRef, DNSVerified: true, PropagationTimeoutSecs: request.PropagationTimeoutSecs, PropagationPollingIntervalSecs: request.PropagationPollingIntervalSecs, - CertificateRoot: candidate.Paths.Certificate, + CertificateRoot: certificateRoot, WebHostname: request.WebHostname, WebPublicIP: request.WebPublicIP, WebEnabled: request.WebEnabled, HTTPSMode: request.HTTPSMode, ProxyHTTPSPort: request.ProxyHTTPSPort, } diff --git a/backend/internal/bootstrap/complete_test.go b/backend/internal/bootstrap/complete_test.go index 30b894f..b694085 100644 --- a/backend/internal/bootstrap/complete_test.go +++ b/backend/internal/bootstrap/complete_test.go @@ -96,7 +96,7 @@ func (issuer *completionCertificateIssuer) IssueForSetup(ctx context.Context, re return certificate.SavedResource{}, issuer.err } resource := completionDomainResource(request.Domains[0]) - store := certificate.FileStore{Root: issuer.root, BackupRoot: filepath.Join(filepath.Dir(issuer.root), "backups")} + store := certificate.FileStore{Root: request.CertificateRoot, BackupRoot: filepath.Join(filepath.Dir(issuer.root), "backups")} saved, err := store.SaveForType(ctx, request.Domains[0], "single", resource) issuer.saved = saved return saved, err diff --git a/backend/internal/bootstrap/router.go b/backend/internal/bootstrap/router.go index 465ba8e..595a25f 100644 --- a/backend/internal/bootstrap/router.go +++ b/backend/internal/bootstrap/router.go @@ -278,6 +278,10 @@ func writeBootstrapCompletionError(writer http.ResponseWriter, err error) { case errors.As(err, &completionErr): // 不转发底层错误或可注入的说明,防止密码、凭据引用及验证令牌泄露。 switch completionErr.Kind { + case CompleteErrorCertificatePath: + writeBootstrapError(writer, http.StatusBadRequest, "证书部署根目录必须是服务器上的独立绝对路径,不能使用根目录、符号链接或与配置、数据、日志、备份目录重叠") + case CompleteErrorCertificateAccess: + writeBootstrapError(writer, http.StatusUnprocessableEntity, "证书部署根目录无法创建或写入,请检查 Scert 运行账户权限及服务的可写目录限制") case CompleteErrorInput: writeBootstrapError(writer, http.StatusBadRequest, "初始化参数无效,请检查管理员、管理入口和服务配置") case CompleteErrorVerification: diff --git a/doc/证书目录配置说明.md b/doc/证书目录配置说明.md new file mode 100644 index 0000000..3a69eaa --- /dev/null +++ b/doc/证书目录配置说明.md @@ -0,0 +1,16 @@ +# 证书目录配置说明 + +首次初始化第 4 步的“证书部署根目录”支持自定义。未修改时保留安装器提供的默认目录;填写的目录位于 Scert 服务器上,不是浏览器所在电脑。 + +- 必须使用服务器操作系统支持的绝对路径,不能使用文件系统根目录。 +- 目录必须独立,不能与配置、数据、日志、备份目录重叠,不能使用符号链接。 +- 提交初始化时,Scert 会在申请证书之前检查目录,并使用自身运行身份验证创建、写入及清理权限。仅清理本次创建的随机探测文件,不清空目录内已有内容。 +- 验证通过的自定义目录会写入正式配置,管理证书和后续证书存储使用该目录。目录可写不代表证书已经签发或生产部署已经验收通过。 + +## Linux 服务权限 + +服务运行账户的文件权限与 systemd 的可写目录限制都必须允许目标目录。仅在页面填写路径不能绕过这些限制。 + +安装时已开放的证书目录下可以使用独立子目录。若要使用其他目录,管理员应先创建目录、授予 Scert 运行账户权限,并在 `Scert.service` 的 `ReadWritePaths` 中追加该目录,随后重载并重启服务。不要为此取消整个服务沙箱,也不要给整个系统目录开放写权限。 + +若目录不可创建或不可写,页面会明确提示检查运行账户权限及服务可写目录限制,不会先申请证书再因目录权限失败。 diff --git a/frontend/src/views/SetupWizard/BootstrapPayload.js b/frontend/src/views/SetupWizard/BootstrapPayload.js index 3fb5a7e..307aa03 100644 --- a/frontend/src/views/SetupWizard/BootstrapPayload.js +++ b/frontend/src/views/SetupWizard/BootstrapPayload.js @@ -1,5 +1,6 @@ // 首次初始化只提交验证令牌,不接受浏览器指定敏感凭据引用或服务端路径。 export function bootstrapPayload(legacy, databaseToken, environment) { + // 证书目录是允许自定义的路径例外,由服务端检查目录隔离和写入权限。 const notification = {}; for (const [key, value] of Object.entries(legacy)) { if (key.startsWith("email_") && key !== "email_http_credential_ref") { @@ -19,6 +20,7 @@ export function bootstrapPayload(legacy, databaseToken, environment) { web_enabled: legacy.web_enabled, https_mode: legacy.https_mode, proxy_https_port: legacy.proxy_https_port, + certificate_root: legacy.certificate_root, acme_environment: environment, propagation_timeout_secs: legacy.propagation_timeout_secs, propagation_polling_interval_secs: legacy.propagation_polling_interval_secs, diff --git a/frontend/src/views/SetupWizard/BootstrapPayload.test.js b/frontend/src/views/SetupWizard/BootstrapPayload.test.js index 9de6707..7dffa94 100644 --- a/frontend/src/views/SetupWizard/BootstrapPayload.test.js +++ b/frontend/src/views/SetupWizard/BootstrapPayload.test.js @@ -14,7 +14,7 @@ test("初始化只提交服务端验证令牌,通知字段保持兼容且不 assert.equal(payload.database_verification_token, "test-database-token"); assert.equal(payload.dns_verification_token, "test-dns-token"); assert.equal(payload.acme_environment, "staging"); - assert.equal("certificate_root" in payload, false); + assert.equal(payload.certificate_root, "/untrusted"); assert.equal("dns_credential_ref" in payload, false); assert.equal("http_credential_ref" in payload.notification, false); assert.deepEqual(payload.notification.http_credentials, { token: "test-notification-token" }); diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue index fc84fbc..a126c55 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.vue +++ b/frontend/src/views/SetupWizard/SetupWizard.vue @@ -758,12 +758,17 @@ const { 04证书目录与剩余设置
diff --git a/frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js b/frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js index 924440c..5505a81 100644 --- a/frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js +++ b/frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js @@ -143,7 +143,8 @@ for (const driver of ["sqlite", "mysql", "postgres"]) { for (const key of ["endpoint", "http_auth_mode", "http_credentials", "http_path", "http_headers"]) { assert.equal(Object.hasOwn(submitted[0].notification, key), false); } - for (const key of ["dns_credential_ref", "certificate_root", "database", "confirm_password"]) { + assert.equal(submitted[0].certificate_root, "/srv/scert/ssl"); + for (const key of ["dns_credential_ref", "database", "confirm_password"]) { assert.equal(Object.hasOwn(submitted[0], key), false); } assert.equal(Object.hasOwn(submitted[0].notification, "http_credential_ref"), false); @@ -307,6 +308,15 @@ test("bootstrap service port defaults to 80 independently of listener port", asy assert.equal(submitted[0].server_port, 80); }); +test("bootstrap certificate root accepts a custom server path", async (t) => { + const { state, submitted } = await setup(t); + state.form.value.certificate_root = "/srv/scert/custom-certificates"; + state.currentStep.value = 4; + await state.handleSubmit(); + assert.equal(state.error.value, ""); + assert.equal(submitted[0].certificate_root, "/srv/scert/custom-certificates"); +}); + test("bootstrap service port accepts a custom port", async (t) => { const { state, submitted } = await setup(t); state.form.value.server_port = 8443; diff --git a/frontend/src/views/SetupWizard/SetupWizardLayout.test.js b/frontend/src/views/SetupWizard/SetupWizardLayout.test.js index 43a477f..c6847f5 100644 --- a/frontend/src/views/SetupWizard/SetupWizardLayout.test.js +++ b/frontend/src/views/SetupWizard/SetupWizardLayout.test.js @@ -22,6 +22,17 @@ function hasClass(name) { && prop.value?.content.split(/\s+/).includes(name)); } +test("certificate root remains editable during bootstrap", () => { + const root = template("./SetupWizard.vue"); + const input = find(root, (node) => node.props?.some((prop) => + prop.name === "id" && prop.value?.content === "certificate-root")); + assert.ok(input); + assert.equal(input.props.some((prop) => prop.name === "readonly" + || (prop.name === "bind" && prop.arg?.content === "readonly")), false); + assert.ok(input.props.some((prop) => prop.name === "model" + && prop.exp?.content === "form.certificate_root")); +}); + test("notification step exposes SMTP only and keeps sender fields in its panel", () => { const root = template("./SetupWizard.vue"); const step = find(root, hasClass("setup-notification-step"));