diff --git a/docs/PBS.md b/docs/PBS.md index 2c8affcab3..8f31f3cda4 100644 --- a/docs/PBS.md +++ b/docs/PBS.md @@ -86,27 +86,67 @@ for monitoring. On the PBS host, enter an administrator root shell before preparing the private file below. In the editor, save only the Pulse agent token, with no header or -quotes; never put the secret in a command argument: +quotes; never put the secret in a command argument. The preparation preserves +an existing file and refuses symlinked or non-regular credential paths. Stop +if it fails; do not continue to the installer: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -chmod 700 "$HOME/.config/pulse" -touch "$HOME/.config/pulse/pbs-agent-token" -chmod 600 "$HOME/.config/pulse/pbs-agent-token" -vi "$HOME/.config/pulse/pbs-agent-token" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-agent-token" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$credential_file" + chmod 600 "$credential_file" + vi "$credential_file" +) ``` Download the agent installer from **your Pulse server's HTTPS address**, then inspect the saved script before running it. Replace the example Pulse URL in both commands. Stop if the download fails; run the next command only after a -successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs -the Pulse server, not the agent. +successful HTTP **200** download and inspection. This ignores curl configuration +and saves only a successful download to the installer path. It refuses an +existing installer rather than overwriting it; review that file locally before +deciding whether a new download is needed. Do not substitute GitHub's top-level +`install.sh`: that installs the Pulse server, not the agent. ```bash -curl --fail --silent --show-error \ - --output "$HOME/.config/pulse/pbs-agent-install.sh" \ - https://pulse.example.com/install.sh +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + installer_file="$config_dir/pbs-agent-install.sh" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ ! -d "$config_dir" ]; then + printf 'Prepare the private token directory first.\n' >&2 + exit 1 + fi + if [ -e "$installer_file" ] || [ -L "$installer_file" ]; then + printf 'Refusing to replace an existing installer path.\n' >&2 + exit 1 + fi + chmod 700 "$config_dir" + download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX") + curl_exit=0 + status=$(curl --disable --fail --silent --show-error --proto '=https' \ + --connect-timeout 5 --max-time 60 --output "$download_file" \ + --write-out '%{http_code}' https://pulse.example.com/install.sh) || curl_exit=$? + printf 'HTTP %s\n' "$status" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + [ "$status" = 200 ] + mv -n "$download_file" "$installer_file" + [ ! -e "$download_file" ] +) ``` ```bash @@ -117,7 +157,10 @@ bash "$HOME/.config/pulse/pbs-agent-install.sh" \ ``` Do not bypass certificate checks to fetch or run the installer. Use a trusted -Pulse certificate or a CA file you verified separately. See +Pulse certificate or add `--cacert` with a CA file you verified separately. +A failed or redirected download is not an installer: do not run its temporary +file. Keep `--disable` first in both curl examples on this page; it ignores +local settings that could enable tracing, bypass TLS or follow redirects. See [Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles. After installation, check a fresh agent report and the host's `pulse-agent --version`; installation or hardware capacity alone does not prove @@ -177,15 +220,30 @@ another machine does not prove that Pulse can reach PBS. ``` - **Authentication/permissions:** test a datastore request, not just `/version`. - Prepare a private header file on the machine running curl: + Prepare a private header file on the machine running curl. Stop if preparation + fails; an existing file is preserved, and symlinked or non-regular credential + paths are refused before opening the editor: ```bash - umask 077 - mkdir -p "$HOME/.config/pulse" - chmod 700 "$HOME/.config/pulse" - touch "$HOME/.config/pulse/pbs-header" - chmod 600 "$HOME/.config/pulse/pbs-header" - vi "$HOME/.config/pulse/pbs-header" + ( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-header" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$credential_file" + chmod 600 "$credential_file" + vi "$credential_file" + ) ``` In the editor, save this line, replacing `` with the secret @@ -195,12 +253,33 @@ another machine does not prove that Pulse can reach PBS. Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token: ``` - Then use curl 7.76 or later, with your PBS hostname: + Then use curl 7.76 or later, with your PBS hostname. The request prints only + the HTTP status and a new private response-file path; inspect that file locally, + not in a public terminal recording or thread: ```bash - curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \ - --header "@$HOME/.config/pulse/pbs-header" \ - https://pbs.example.com:8007/api2/json/admin/datastore + ( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-header" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ] || [ ! -f "$credential_file" ]; then + printf 'Prepare the private header file first.\n' >&2 + exit 1 + fi + chmod 700 "$config_dir" + chmod 600 "$credential_file" + result_file=$(mktemp "$config_dir/pbs-response.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + curl_exit=0 + status=$(curl --disable --fail-with-body --silent --show-error --proto '=https' \ + --connect-timeout 5 --max-time 15 --header "@$credential_file" \ + --output "$result_file" --write-out '%{http_code}' \ + https://pbs.example.com:8007/api2/json/admin/datastore) || curl_exit=$? + printf 'HTTP %s\n' "$status" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + [ "$status" = 200 ] + ) ``` For curl with a private CA or self-signed certificate, add @@ -209,7 +288,9 @@ another machine does not prove that Pulse can reach PBS. use `--insecure` or `-k`. `401` means authentication was rejected; `403` means the requested access was - refused. Both return a non-zero curl exit while retaining the error body. + refused. Both return a non-zero curl exit while retaining the error body + privately. A redirect or `000` is not successful access; this command neither + follows redirects nor retries. Inspect the existing token and both `Audit` grants before replacing anything. A `200` response listing the expected datastore establishes access to that list, not successful collection of every backup, job or History graph. An diff --git a/frontend-modern/public/docs/PBS.md b/frontend-modern/public/docs/PBS.md index 2c8affcab3..8f31f3cda4 100644 --- a/frontend-modern/public/docs/PBS.md +++ b/frontend-modern/public/docs/PBS.md @@ -86,27 +86,67 @@ for monitoring. On the PBS host, enter an administrator root shell before preparing the private file below. In the editor, save only the Pulse agent token, with no header or -quotes; never put the secret in a command argument: +quotes; never put the secret in a command argument. The preparation preserves +an existing file and refuses symlinked or non-regular credential paths. Stop +if it fails; do not continue to the installer: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -chmod 700 "$HOME/.config/pulse" -touch "$HOME/.config/pulse/pbs-agent-token" -chmod 600 "$HOME/.config/pulse/pbs-agent-token" -vi "$HOME/.config/pulse/pbs-agent-token" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-agent-token" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$credential_file" + chmod 600 "$credential_file" + vi "$credential_file" +) ``` Download the agent installer from **your Pulse server's HTTPS address**, then inspect the saved script before running it. Replace the example Pulse URL in both commands. Stop if the download fails; run the next command only after a -successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs -the Pulse server, not the agent. +successful HTTP **200** download and inspection. This ignores curl configuration +and saves only a successful download to the installer path. It refuses an +existing installer rather than overwriting it; review that file locally before +deciding whether a new download is needed. Do not substitute GitHub's top-level +`install.sh`: that installs the Pulse server, not the agent. ```bash -curl --fail --silent --show-error \ - --output "$HOME/.config/pulse/pbs-agent-install.sh" \ - https://pulse.example.com/install.sh +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + installer_file="$config_dir/pbs-agent-install.sh" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ ! -d "$config_dir" ]; then + printf 'Prepare the private token directory first.\n' >&2 + exit 1 + fi + if [ -e "$installer_file" ] || [ -L "$installer_file" ]; then + printf 'Refusing to replace an existing installer path.\n' >&2 + exit 1 + fi + chmod 700 "$config_dir" + download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX") + curl_exit=0 + status=$(curl --disable --fail --silent --show-error --proto '=https' \ + --connect-timeout 5 --max-time 60 --output "$download_file" \ + --write-out '%{http_code}' https://pulse.example.com/install.sh) || curl_exit=$? + printf 'HTTP %s\n' "$status" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + [ "$status" = 200 ] + mv -n "$download_file" "$installer_file" + [ ! -e "$download_file" ] +) ``` ```bash @@ -117,7 +157,10 @@ bash "$HOME/.config/pulse/pbs-agent-install.sh" \ ``` Do not bypass certificate checks to fetch or run the installer. Use a trusted -Pulse certificate or a CA file you verified separately. See +Pulse certificate or add `--cacert` with a CA file you verified separately. +A failed or redirected download is not an installer: do not run its temporary +file. Keep `--disable` first in both curl examples on this page; it ignores +local settings that could enable tracing, bypass TLS or follow redirects. See [Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles. After installation, check a fresh agent report and the host's `pulse-agent --version`; installation or hardware capacity alone does not prove @@ -177,15 +220,30 @@ another machine does not prove that Pulse can reach PBS. ``` - **Authentication/permissions:** test a datastore request, not just `/version`. - Prepare a private header file on the machine running curl: + Prepare a private header file on the machine running curl. Stop if preparation + fails; an existing file is preserved, and symlinked or non-regular credential + paths are refused before opening the editor: ```bash - umask 077 - mkdir -p "$HOME/.config/pulse" - chmod 700 "$HOME/.config/pulse" - touch "$HOME/.config/pulse/pbs-header" - chmod 600 "$HOME/.config/pulse/pbs-header" - vi "$HOME/.config/pulse/pbs-header" + ( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-header" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then + printf 'Credential file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$credential_file" + chmod 600 "$credential_file" + vi "$credential_file" + ) ``` In the editor, save this line, replacing `` with the secret @@ -195,12 +253,33 @@ another machine does not prove that Pulse can reach PBS. Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token: ``` - Then use curl 7.76 or later, with your PBS hostname: + Then use curl 7.76 or later, with your PBS hostname. The request prints only + the HTTP status and a new private response-file path; inspect that file locally, + not in a public terminal recording or thread: ```bash - curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \ - --header "@$HOME/.config/pulse/pbs-header" \ - https://pbs.example.com:8007/api2/json/admin/datastore + ( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + credential_file="$config_dir/pbs-header" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ] || [ ! -f "$credential_file" ]; then + printf 'Prepare the private header file first.\n' >&2 + exit 1 + fi + chmod 700 "$config_dir" + chmod 600 "$credential_file" + result_file=$(mktemp "$config_dir/pbs-response.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + curl_exit=0 + status=$(curl --disable --fail-with-body --silent --show-error --proto '=https' \ + --connect-timeout 5 --max-time 15 --header "@$credential_file" \ + --output "$result_file" --write-out '%{http_code}' \ + https://pbs.example.com:8007/api2/json/admin/datastore) || curl_exit=$? + printf 'HTTP %s\n' "$status" + [ "$curl_exit" -eq 0 ] || exit "$curl_exit" + [ "$status" = 200 ] + ) ``` For curl with a private CA or self-signed certificate, add @@ -209,7 +288,9 @@ another machine does not prove that Pulse can reach PBS. use `--insecure` or `-k`. `401` means authentication was rejected; `403` means the requested access was - refused. Both return a non-zero curl exit while retaining the error body. + refused. Both return a non-zero curl exit while retaining the error body + privately. A redirect or `000` is not successful access; this command neither + follows redirects nor retries. Inspect the existing token and both `Audit` grants before replacing anything. A `200` response listing the expected datastore establishes access to that list, not successful collection of every backup, job or History graph. An diff --git a/scripts/installtests/pbs_api_guidance_test.go b/scripts/installtests/pbs_api_guidance_test.go new file mode 100644 index 0000000000..ddeca21d09 --- /dev/null +++ b/scripts/installtests/pbs_api_guidance_test.go @@ -0,0 +1,448 @@ +package installtests + +import ( + "context" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// Extract the actual copied commands, including the old guide for an exact +// parent control. These tests contact only synthetic peers in the proof VM. +func pbsAPIBlocks(doc string) ([4]string, error) { + var blocks [4]string + for _, match := range regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(doc, -1) { + block := match[1] + index := -1 + switch { + case strings.Contains(block, "pbs-agent-token") && strings.Contains(block, "vi "): + index = 0 + case strings.Contains(block, "pbs-header") && strings.Contains(block, "vi "): + index = 1 + case strings.Contains(block, "pbs-agent-install.sh") && strings.Contains(block, "curl "): + index = 2 + case strings.Contains(block, "https://pbs.example.com:8007/api2/json/admin/datastore"): + index = 3 + } + if index >= 0 { + if blocks[index] != "" { + return blocks, fmt.Errorf("duplicate PBS recipe %d", index) + } + blocks[index] = block + } + } + for i, block := range blocks { + if block == "" { + return blocks, fmt.Errorf("missing PBS recipe %d", i) + } + } + return blocks, nil +} + +func pbsAPISafety(blocks [4]string) error { + for i, block := range blocks { + required := []string{"set -eu", "umask 077", `[ -L "$HOME/.config" ]`, `[ -L "$config_dir" ]`, `chmod 700 "$config_dir"`} + if i < 2 { + required = append(required, `[ -L "$credential_file" ]`, `[ ! -f "$credential_file" ]`, `chmod 600 "$credential_file"`, `vi "$credential_file"`) + } else { + required = append(required, "curl --disable ", "--silent --show-error --proto '=https'", "--connect-timeout 5", + `--write-out '%{http_code}'`, `printf 'HTTP %s\n' "$status"`, `[ "$curl_exit" -eq 0 ] || exit "$curl_exit"`, `[ "$status" = 200 ]`) + if i == 2 { + required = append(required, "--fail ", "--max-time 60", `download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX")`, + `[ -e "$installer_file" ] || [ -L "$installer_file" ]`, `--output "$download_file"`, `mv -n "$download_file" "$installer_file"`, `[ ! -e "$download_file" ]`) + } else { + required = append(required, "--fail-with-body ", "--max-time 15", `[ -L "$credential_file" ]`, `[ ! -f "$credential_file" ]`, + `chmod 600 "$credential_file"`, `result_file=$(mktemp "$config_dir/pbs-response.XXXXXX")`, `--header "@$credential_file"`, `--output "$result_file"`) + } + for _, forbidden := range []string{"--insecure", "--location", "--config", "--trace", "--verbose", "--retry", "Authorization:", " | "} { + if strings.Contains(block, forbidden) { + return fmt.Errorf("PBS recipe %d contains %s", i, forbidden) + } + } + if strings.Count(block, "curl ") != 1 { + return fmt.Errorf("PBS recipe %d must make one request", i) + } + } + for _, text := range required { + if !strings.Contains(block, text) { + return fmt.Errorf("PBS recipe %d lacks %s", i, text) + } + } + } + return nil +} + +func pbsAPIGuidance(t *testing.T) [4]string { + t.Helper() + doc, err := os.ReadFile(repoFile("docs", "PBS.md")) + if err != nil { + t.Fatal(err) + } + blocks, err := pbsAPIBlocks(string(doc)) + if err != nil { + t.Fatal(err) + } + if err := pbsAPISafety(blocks); err != nil { + t.Fatal(err) + } + mirror, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "PBS.md")) + if err != nil || string(mirror) != string(doc) { + t.Fatal("shipped PBS guide differs from the verified recipes") + } + return blocks +} + +func runPBSRecipe(t *testing.T, recipe, home, bin string, extraEnv ...string) (string, int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-c", recipe) + cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+bin+":"+os.Getenv("PATH"), + "http_proxy=", "HTTP_PROXY=", "https_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "all_proxy=", "NO_PROXY=", "no_proxy=") + cmd.Env = append(cmd.Env, extraEnv...) + output, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatal("copied PBS recipe exceeded its bounded test deadline") + } + if err == nil { + return string(output), 0 + } + failure, ok := err.(*exec.ExitError) + if !ok { + t.Fatal(err) + } + return string(output), failure.ExitCode() +} + +func TestPBSAPIGuidancePreparationIsPrivateAndFailClosed(t *testing.T) { + for index, filename := range []string{"pbs-agent-token", "pbs-header"} { + for _, mode := range []string{"new", "existing", "config symlink", "directory symlink", "file symlink", "dangling symlink", "non-regular", "mkdir failure", "chmod failure", "touch failure", "editor failure"} { + t.Run(filename+"/"+mode, func(t *testing.T) { + home := t.TempDir() + bin := filepath.Join(home, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + edited := filepath.Join(home, "editor-started") + editor := "#!/bin/sh\nprintf started > \"$EDITOR_STARTED\"\n" + if mode == "editor failure" { + editor += "exit 1\n" + } + if err := os.WriteFile(filepath.Join(bin, "vi"), []byte(editor), 0700); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".config", "pulse") + file := filepath.Join(dir, filename) + if mode == "existing" || strings.Contains(mode, "file symlink") || mode == "dangling symlink" || mode == "non-regular" { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + target := filepath.Join(home, "untouched") + if strings.Contains(mode, "symlink") { + link := file + switch mode { + case "file symlink": + if err := os.WriteFile(target, []byte("synthetic-existing-secret"), 0644); err != nil { + t.Fatal(err) + } + case "config symlink", "directory symlink": + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + link = filepath.Join(home, ".config") + if mode == "directory symlink" { + if err := os.Mkdir(link, 0755); err != nil { + t.Fatal(err) + } + link = dir + } + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + } + if mode == "existing" { + if err := os.WriteFile(file, []byte("synthetic-existing-secret"), 0644); err != nil { + t.Fatal(err) + } + } + if mode == "non-regular" { + if err := os.Mkdir(file, 0700); err != nil { + t.Fatal(err) + } + } + if strings.HasSuffix(mode, " failure") && mode != "editor failure" { + if err := os.WriteFile(filepath.Join(bin, strings.TrimSuffix(mode, " failure")), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + } + output, exit := runPBSRecipe(t, pbsAPIGuidance(t)[index], home, bin, "EDITOR_STARTED="+edited) + _, editorErr := os.Stat(edited) + if mode != "new" && mode != "existing" { + if exit == 0 || (mode != "editor failure" && !os.IsNotExist(editorErr)) { + t.Fatal("failed or unsafe preparation was accepted or reached the editor") + } + if strings.Contains(mode, "symlink") && mode != "dangling symlink" { + info, err := os.Stat(target) + wantMode := os.FileMode(0755) + if mode == "file symlink" { + wantMode = 0644 + content, err := os.ReadFile(target) + if err != nil || string(content) != "synthetic-existing-secret" { + t.Fatal("refused credential path modified its target") + } + } + if err != nil || info.Mode().Perm() != wantMode { + t.Fatal("refused path modified target permissions") + } + } + } else { + if exit != 0 || editorErr != nil { + t.Fatalf("safe preparation failed: exit %d, %s", exit, output) + } + for path, permissions := range map[string]os.FileMode{dir: 0700, file: 0600} { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != permissions { + t.Fatal("credential path is not private") + } + } + content, err := os.ReadFile(file) + if err != nil || (mode == "existing" && string(content) != "synthetic-existing-secret") { + t.Fatal("existing credential was not preserved") + } + } + if strings.Contains(output, "synthetic-existing-secret") { + t.Fatal("preparation disclosed a credential") + } + }) + } + } +} + +func TestPBSAPIGuidanceRejectsUnsafeRecipesAndExactParent(t *testing.T) { + blocks := pbsAPIGuidance(t) + for name, edit := range map[string][2]string{ + "curl config": {"curl --disable ", "curl "}, "body disclosure": {`--output "$result_file" `, ""}, + "deadline": {"--max-time 15 ", ""}, "retry": {"curl --disable ", "curl --disable --retry 2 "}, + "redirect": {"curl --disable ", "curl --disable --location "}, "TLS bypass": {"curl --disable ", "curl --disable --insecure "}, + "inline token": {`"@$credential_file"`, `'Authorization: synthetic-inline-secret'`}, + "fail open": {"set -eu", ""}, "symlink": {`[ -L "$credential_file" ]`, "false"}, + "installer overwrite": {`mv -n "$download_file"`, `mv "$download_file"`}, + } { + t.Run(name, func(t *testing.T) { + changed := blocks + for i, block := range changed { + changed[i] = strings.ReplaceAll(block, edit[0], edit[1]) + } + if pbsAPISafety(changed) == nil { + t.Fatal("unsafe mutation was accepted") + } + }) + } + if inputs := os.Getenv("PULSE_PROOF_INPUTS"); inputs != "" { + parent, err := os.ReadFile(filepath.Join(inputs, "pbs-parent.md")) + if os.IsNotExist(err) { + return // Unrelated proof inputs do not require a PBS parent. + } + if err != nil { + t.Fatal(err) + } + old, err := pbsAPIBlocks(string(parent)) + if err != nil { + t.Fatal(err) + } + if err := pbsAPISafety(old); err == nil { + t.Fatal("unchanged exact parent passed the new safety controls") + } else { + t.Logf("exact parent rejected: %s", err) + } + } +} + +// Actual curl sees hostile local defaults and peers that echo a fixture token +// and identifying data. This proves copied transport/file safety, not native +// PBS permissions, installed collection, backup recovery or agent installation. +func TestPBSAPIGuidanceCopiedRequestsKeepSecretsPrivate(t *testing.T) { + blocks := pbsAPIGuidance(t) + for index, recipe := range blocks[2:] { + for _, scenario := range []string{"success", "401", "403", "redirect", "wrong success status", "untrusted HTTPS", "truncated installer", "existing installer", "symlinked header"} { + if (index == 0 && scenario == "symlinked header") || (index == 1 && strings.Contains(scenario, "installer")) { + continue + } + t.Run(fmt.Sprintf("%d/%s", index, scenario), func(t *testing.T) { + recipe := recipe + realCurl, err := exec.LookPath("curl") + if err != nil { + t.Fatal("curl is required to verify copied PBS guidance") + } + home := t.TempDir() + bin := filepath.Join(home, "bin") + dir := filepath.Join(home, ".config", "pulse") + for _, path := range []string{bin, dir} { + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + } + const token = "PBSAPIToken=pulse-monitor@pbs!pulse-token:synthetic-pbs-secret" + const body = "synthetic-pbs-secret private-datastore-name" + header := filepath.Join(dir, "pbs-header") + if scenario == "symlinked header" { + target := filepath.Join(home, "outside-header") + if err := os.WriteFile(target, []byte("Authorization: "+token+"\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, header); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(header, []byte("Authorization: "+token+"\n"), 0600); err != nil { + t.Fatal(err) + } + installer := filepath.Join(dir, "pbs-agent-install.sh") + if scenario == "existing installer" { + if err := os.WriteFile(installer, []byte("original-installer"), 0600); err != nil { + t.Fatal(err) + } + } + capture := filepath.Join(home, "argv") + trace := filepath.Join(home, "must-not-trace") + wrapper := "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$PBS_ARGV\"\nexec \"$PBS_CURL\" \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "curl"), []byte(wrapper), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".curlrc"), []byte("insecure\nlocation\ntrace = "+trace+"\n"), 0600); err != nil { + t.Fatal(err) + } + requests := make(chan string, 4) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- strings.Join([]string{r.Method, r.URL.Path, r.Header.Get("Authorization")}, "\n") + code := 200 + switch scenario { + case "401": + code = 401 + case "403": + code = 403 + case "redirect": + w.Header().Set("Location", "/must-not-follow") + code = 307 + case "wrong success status": + code = 206 + case "truncated installer": + w.Header().Set("Content-Length", "1024") + } + w.WriteHeader(code) + fmt.Fprint(w, body) + })) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.StartTLS() + t.Cleanup(server.Close) + originalURL := "https://pulse.example.com" + if index == 1 { + originalURL = "https://pbs.example.com:8007" + } + recipe = strings.Replace(recipe, originalURL, server.URL, 1) + if scenario != "untrusted HTTPS" { + ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(filepath.Join(home, "ca.pem"), ca, 0600); err != nil { + t.Fatal(err) + } + recipe = strings.Replace(recipe, "curl --disable ", `curl --disable --cacert "$HOME/ca.pem" `, 1) + } + output, exit := runPBSRecipe(t, recipe, home, bin, "PBS_ARGV="+capture, "PBS_CURL="+realCurl) + wantExit := 0 + switch scenario { + case "401", "403": + wantExit = 22 + case "untrusted HTTPS": + wantExit = 60 + case "truncated installer": + wantExit = 18 + case "redirect", "wrong success status", "existing installer", "symlinked header": + wantExit = 1 + } + if exit != wantExit { + t.Fatalf("copied request exit %d, want %d: %s", exit, wantExit, output) + } + if strings.Contains(output, "synthetic-pbs-secret") || strings.Contains(output, "private-datastore-name") { + t.Fatal("copied request disclosed a credential or response") + } + if _, err := os.Stat(trace); !os.IsNotExist(err) { + t.Fatal("curl configuration enabled tracing") + } + if scenario == "existing installer" || scenario == "symlinked header" { + if _, err := os.Stat(capture); !os.IsNotExist(err) || len(requests) != 0 { + t.Fatal("unsafe local path reached curl") + } + path, expected := installer, "original-installer" + if scenario == "symlinked header" { + path, expected = filepath.Join(home, "outside-header"), "Authorization: "+token+"\n" + } + content, err := os.ReadFile(path) + if err != nil || string(content) != expected { + t.Fatal("refused local path modified existing content") + } + return + } + argv, err := os.ReadFile(capture) + if err != nil || strings.Contains(string(argv), "synthetic-pbs-secret") || !strings.HasPrefix(string(argv), "--disable\x00") { + t.Fatal("curl arguments disclosed credentials or did not disable local configuration first") + } + if !strings.Contains(output, "HTTP ") { + t.Fatal("request failed to expose its safe HTTP result") + } + if scenario == "untrusted HTTPS" { + if len(requests) != 0 { + t.Fatal("untrusted TLS peer received a request") + } + } else { + if len(requests) != 1 { + t.Fatal("request was missing, redirected or retried") + } + path, auth := "/install.sh", "" + if index == 1 { + path, auth = "/api2/json/admin/datastore", token + } + if got := <-requests; got != strings.Join([]string{"GET", path, auth}, "\n") { + t.Fatal("copied request used the wrong method, endpoint or credential") + } + } + if index == 0 { + content, err := os.ReadFile(installer) + if scenario == "success" { + info, statErr := os.Stat(installer) + if err != nil || string(content) != body || statErr != nil || info.Mode().Perm() != 0600 { + t.Fatal("successful installer was not saved completely and privately") + } + } else if !os.IsNotExist(err) { + t.Fatal("failed or redirected download became an executable installer path") + } + } else { + responses, err := filepath.Glob(filepath.Join(dir, "pbs-response.??????")) + if err != nil || len(responses) != 1 { + t.Fatal("request did not retain one new private response") + } + info, err := os.Stat(responses[0]) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("response file is not private") + } + if scenario != "untrusted HTTPS" { + content, err := os.ReadFile(responses[0]) + if err != nil || string(content) != body { + t.Fatal("complete sensitive response was not retained locally") + } + } + } + }) + } + } +}