From b16ef31f7b130da07bad133bee2880b6100ffa1e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:27:06 +0100 Subject: [PATCH] Check the exact persistence destination before bootstrap Normalise the selected data directory with the storage path routine before inspection. Raw symlink/.. traversal and trailing separators must not probe an empty peer or hide a linked root while the encrypted importer writes elsewhere. Retain and validate the existing canonical agent credential. Change-source: pulse-maintainer --- .../v6/internal/subsystems/security-privacy.md | 5 +++++ pkg/server/server.go | 8 ++++++++ pkg/server/service_health_test.go | 10 +++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index c73164c8d2..08fc050f69 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -24,6 +24,11 @@ absent or empty resolved data directory; a regular deployment `.env` is the only allowed pre-start entry. Existing non-Proxmox settings, token/session records, encryption keys, tenant directories and unknown contents all block it. +Admission uses the same `NormalizeStorageDir` path as persistence before any +filesystem probe: raw symlink/`..` traversal must not inspect an empty peer while +the importer writes a different existing destination, and a trailing separator +must not hide a final directory link. Canonical destination credential validation +and live/dangling link spellings have explicit regression controls. Directory inspection errors, non-directories and symlinks are not absence. Admission reads no file contents and creates no persistence/key. The importer rechecks before creating persistence or writing and audits a classified refusal diff --git a/pkg/server/server.go b/pkg/server/server.go index c5a8b8d730..5aced1c6ba 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -29,6 +29,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rcourtman/pulse-go-rewrite/internal/telemetry" "github.com/rcourtman/pulse-go-rewrite/internal/updates" @@ -1094,6 +1095,13 @@ var ( // A regular .env is the only exception for pre-start deployment overrides; // keep the input archive outside the destination, not alongside retained state. func checkAutoImportDestination(dataDir string) error { + // Inspect the exact path persistence will use. Raw symlink/.. traversal or + // trailing separators can otherwise inspect a different directory, or hide + // the final directory link from Lstat. + dataDir, err := securityutil.NormalizeStorageDir(dataDir) + if err != nil { + return errAutoImportDestinationUnknown + } info, err := os.Lstat(dataDir) if os.IsNotExist(err) { return nil diff --git a/pkg/server/service_health_test.go b/pkg/server/service_health_test.go index 05865feeb7..2352519e7d 100644 --- a/pkg/server/service_health_test.go +++ b/pkg/server/service_health_test.go @@ -95,7 +95,7 @@ func TestStartupImportUsesPersistenceDestination(t *testing.T) { canonical := filepath.Join(base, "destination") target := config.NewConfigPersistence(canonical) if err := target.SaveAPITokens([]config.APITokenRecord{{ - ID: "synthetic-existing-record", Hash: "synthetic-existing-digest", + ID: "synthetic-existing-record", Hash: auth.HashAPIToken("synthetic-existing-credential"), Scopes: []string{config.ScopeAgentReport}, }}); err != nil { t.Fatal(err) @@ -122,6 +122,14 @@ func TestStartupImportUsesPersistenceDestination(t *testing.T) { if after := snapshotAutoImportDestination(t, canonical); !reflect.DeepEqual(before, after) { t.Error("canonical destination was modified") } + records, err := target.LoadAPITokens() + if err != nil { + t.Fatal(err) + } + loaded := &config.Config{APITokens: records} + if record, ok := loaded.ValidateAPIToken("synthetic-existing-credential"); !ok || record.ID != "synthetic-existing-record" { + t.Error("canonical destination credential no longer validates") + } if entries, err := os.ReadDir(filepath.Join(peer, "destination")); err != nil || len(entries) != 0 { t.Error("the peer used by raw traversal was modified") }