支持初始化自定义证书目录并校验写入权限

This commit is contained in:
xuans 2026-09-26 22:26:13 +08:00
parent 5d5068b20f
commit 77527c293b
11 changed files with 291 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
# 证书目录配置说明
首次初始化第 4 步的“证书部署根目录”支持自定义。未修改时保留安装器提供的默认目录;填写的目录位于 Scert 服务器上,不是浏览器所在电脑。
- 必须使用服务器操作系统支持的绝对路径,不能使用文件系统根目录。
- 目录必须独立,不能与配置、数据、日志、备份目录重叠,不能使用符号链接。
- 提交初始化时,Scert 会在申请证书之前检查目录,并使用自身运行身份验证创建、写入及清理权限。仅清理本次创建的随机探测文件,不清空目录内已有内容。
- 验证通过的自定义目录会写入正式配置,管理证书和后续证书存储使用该目录。目录可写不代表证书已经签发或生产部署已经验收通过。
## Linux 服务权限
服务运行账户的文件权限与 systemd 的可写目录限制都必须允许目标目录。仅在页面填写路径不能绕过这些限制。
安装时已开放的证书目录下可以使用独立子目录。若要使用其他目录,管理员应先创建目录、授予 Scert 运行账户权限,并在 `Scert.service` 的 `ReadWritePaths` 中追加该目录,随后重载并重启服务。不要为此取消整个服务沙箱,也不要给整个系统目录开放写权限。
若目录不可创建或不可写,页面会明确提示检查运行账户权限及服务可写目录限制,不会先申请证书再因目录权限失败。

View File

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

View File

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

View File

@ -758,12 +758,17 @@ const {
<legend><span>04</span>证书目录与剩余设置</legend>
<div class="setup-grid two-columns">
<label>
<span>证书部署根目录</span>
<span>
证书部署根目录
<HelpTip>
可填写服务器上的绝对路径。Scert 运行账户必须有写入权限;Linux 自定义目录还需位于服务的可写范围内。
</HelpTip>
</span>
<input
id="certificate-root"
name="certificate_root"
v-model.trim="form.certificate_root"
:readonly="isBootstrap"
maxlength="4096"
required
/>
</label>

View File

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

View File

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