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