mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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:
@@ -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...))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user