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
This commit is contained in:
pulse-triage[bot]
2026-10-25 05:04:14 +00:00
parent 54aead3afc
commit cc105e261b
4 changed files with 200 additions and 0 deletions
@@ -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...))
}
+6
View File
@@ -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 {
@@ -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")
}
})
}
}
+41
View File
@@ -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, &sections); 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
}