From cc105e261b2e033e104f6630286baaf0f3e81632 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:04:14 +0000 Subject: [PATCH] Reject incomplete configuration backups before replacement Require the five sections emitted by every supported archive before starting a transaction. Preserve null webhook inventories, deliberate empty configurations, legacy optional fields and best-effort forward compatibility. Exercise encrypted rejected imports, byte-identical persistent state, unchanged runtime, failure audits and legacy recovery. Contract-Neutral: Rejects incomplete non-backup payloads before the existing replacement transaction; emitted archive formats, supported complete legacy backups, API response shape and tenant authority remain unchanged. Change-source: pulse-maintainer --- .../config_import_validation_test.go | 110 ++++++++++++++++++ internal/config/export.go | 6 + .../config/export_import_validation_test.go | 43 +++++++ internal/config/export_validation.go | 41 +++++++ 4 files changed, 200 insertions(+) create mode 100644 internal/api/configapi/config_import_validation_test.go create mode 100644 internal/config/export_validation.go diff --git a/internal/api/configapi/config_import_validation_test.go b/internal/api/configapi/config_import_validation_test.go new file mode 100644 index 0000000000..b8f2b356b6 --- /dev/null +++ b/internal/api/configapi/config_import_validation_test.go @@ -0,0 +1,110 @@ +package configapi + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "golang.org/x/crypto/pbkdf2" +) + +func TestHandleImportConfigRejectsIncompleteArchiveBeforeApply(t *testing.T) { + for _, plaintext := range []string{"null", "{}", `{"version":"4.4"}`, `{"nodes":null,"alerts":{},"email":{},"webhooks":null,"system":{}}`} { + t.Run(plaintext, func(t *testing.T) { + const passphrase = "synthetic-import-validation-passphrase" + cfg := &config.Config{DataPath: t.TempDir(), PublicURL: "https://keep.invalid", AuthUser: "keep-admin", AuthPass: "synthetic-auth-hash"} + p := config.NewConfigPersistence(cfg.DataPath) + settings := config.DefaultSystemSettings() + settings.PublicURL = cfg.PublicURL + if err := p.SaveSystemSettings(*settings); err != nil { + t.Fatal(err) + } + if err := p.SaveAPITokens([]config.APITokenRecord{{ID: "keep-token", Hash: "synthetic-token-hash", OrgID: "default"}}); err != nil { + t.Fatal(err) + } + beforeFiles := make(map[string]string) + entries, err := os.ReadDir(cfg.DataPath) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + data, err := os.ReadFile(filepath.Join(cfg.DataPath, entry.Name())) + if err != nil { + t.Fatal(err) + } + beforeFiles[entry.Name()] = string(data) + } + beforeConfig := cfg.DeepCopy() + reloads, audits := 0, 0 + h := &ConfigHandlers{defaultConfig: cfg, defaultPersistence: p, reloadFunc: func() error { reloads++; return nil }} + h.SetRuntimeDependencies(RuntimeDependencies{AuditEvent: func(_, event, _, _, _ string, success bool, _ string) { + audits++ + if event != "config_imported" || success { + t.Error("incomplete backup did not produce a failed import audit") + } + }}) + request := ImportConfigRequest{Data: encryptedImportValidationFixture(t, []byte(plaintext), passphrase), Passphrase: passphrase} + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.HandleImportConfig(rec, httptest.NewRequest(http.MethodPost, "/api/config/import", bytes.NewReader(body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("incomplete archive status = %d, want 400", rec.Code) + } + if rec.Body.String() != "Failed to import configuration. Verify the backup file and passphrase are correct.\n" { + t.Error("unexpected import failure envelope") + } + if reloads != 0 || audits != 1 || !reflect.DeepEqual(beforeConfig, cfg.DeepCopy()) { + t.Error("rejected archive changed the runtime or triggered apply") + } + afterEntries, err := os.ReadDir(cfg.DataPath) + if err != nil || len(afterEntries) != len(beforeFiles) { + t.Fatal("rejected archive created or removed persisted files") + } + for _, entry := range afterEntries { + data, err := os.ReadFile(filepath.Join(cfg.DataPath, entry.Name())) + if err != nil || beforeFiles[entry.Name()] != string(data) { + t.Error("rejected archive changed persisted settings or credentials") + } + } + // Only synthetic request/response bytes leave this control. Browser + // proof consumes this exact producer result in the existing dialog. + readback, err := json.Marshal(map[string]any{"request": request, "status": rec.Code, "contentType": rec.Header().Get("Content-Type"), "body": rec.Body.String()}) + if err != nil { + t.Fatal(err) + } + t.Logf("SYNTHETIC_IMPORT_REJECTION_READBACK %s", readback) + }) + } +} + +func encryptedImportValidationFixture(t *testing.T, plaintext []byte, passphrase string) string { + t.Helper() + // Fixed public salt/nonce keep this synthetic malformed fixture stable; + // production export encryption still uses independent crypto/rand bytes. + salt := sha256.Sum256([]byte("synthetic-import-validation-salt")) + key := pbkdf2.Key([]byte(passphrase), salt[:], 600000, 32, sha256.New) + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, gcm.NonceSize()) + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return base64.StdEncoding.EncodeToString(append(salt[:], ciphertext...)) +} diff --git a/internal/config/export.go b/internal/config/export.go index bc22736544..26da1b366d 100644 --- a/internal/config/export.go +++ b/internal/config/export.go @@ -156,6 +156,12 @@ func (c *ConfigPersistence) ImportConfig(encryptedData string, passphrase string return fmt.Errorf("failed to decrypt import data: %w", err) } + // Validate the archive envelope before zero-value decoding can turn a + // malformed backup into replacement settings, credentials or metadata. + if err := validateImportSections(decrypted); err != nil { + return err + } + // Unmarshal JSON var exportData ExportData if err := json.Unmarshal(decrypted, &exportData); err != nil { diff --git a/internal/config/export_import_validation_test.go b/internal/config/export_import_validation_test.go index de9778ecfd..e9e2a21cd7 100644 --- a/internal/config/export_import_validation_test.go +++ b/internal/config/export_import_validation_test.go @@ -132,3 +132,46 @@ func importValidationFiles(t *testing.T, dir string) map[string]importValidation } return files } + +func TestImportConfigAcceptsCompleteLegacyAndEmptyArchives(t *testing.T) { + for _, version := range []string{"4.0", "4.1", "4.2", "4.3", "4.4", "", "future"} { + t.Run("version_"+version, func(t *testing.T) { + cp := seedImportValidationState(t) + // All five sections have been emitted since 4.0. In particular, + // optional modern sections need not exist in a legitimate backup. + data := map[string]any{ + "nodes": map[string]any{}, "alerts": map[string]any{}, + "email": map[string]any{}, "system": map[string]any{}, + "webhooks": nil, "unknownFutureField": "must remain compatible", + } + if version != "" { + data["version"] = version + } + if version == "4.4" { + data["webhooks"] = []any{} + } + encoded, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + if err := cp.ImportConfig(encryptImportValidationData(t, encoded), "synthetic-import-validation-passphrase"); err != nil { + t.Fatalf("complete archive rejected: %v", err) + } + nodes, err := cp.LoadNodesConfig() + if err != nil || len(nodes.PVEInstances) != 0 { + t.Fatal("intentional empty node inventory was not applied") + } + tokens, err := cp.LoadAPITokens() + if err != nil { + t.Fatal(err) + } + if version == "4.0" { + if len(tokens) != 1 || tokens[0].ID != "keep-token" { + t.Error("4.0 import did not preserve the existing token inventory") + } + } else if len(tokens) != 0 { + t.Error("complete archive no longer applies an intentionally empty token inventory") + } + }) + } +} diff --git a/internal/config/export_validation.go b/internal/config/export_validation.go new file mode 100644 index 0000000000..0d1ceb3ff9 --- /dev/null +++ b/internal/config/export_validation.go @@ -0,0 +1,41 @@ +package config + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// validateImportSections distinguishes an actual configuration backup from JSON +// that would silently decode to a zero-value ExportData. Every emitted format, +// including 4.0, contains these sections. Later additions remain optional, and +// unknown fields/versions retain their existing best-effort compatibility. +func validateImportSections(data []byte) error { + var sections struct { + Nodes json.RawMessage `json:"nodes"` + Alerts json.RawMessage `json:"alerts"` + Email json.RawMessage `json:"email"` + Webhooks json.RawMessage `json:"webhooks"` + System json.RawMessage `json:"system"` + } + if err := json.Unmarshal(data, §ions); err != nil { + return fmt.Errorf("invalid configuration backup: %w", err) + } + for _, section := range []struct { + name string + data json.RawMessage + }{ + {"nodes", sections.Nodes}, {"alerts", sections.Alerts}, + {"email", sections.Email}, {"system", sections.System}, + } { + if len(section.data) == 0 || bytes.Equal(bytes.TrimSpace(section.data), []byte("null")) { + return fmt.Errorf("configuration backup is missing the %s section", section.name) + } + } + // An empty webhook inventory is legitimately encoded as null by older + // exports as well as by a fresh installation. Absence is not the same. + if len(sections.Webhooks) == 0 { + return fmt.Errorf("configuration backup is missing the webhooks section") + } + return nil +}