diff --git a/internal/api/configapi/container_install_command_test.go b/internal/api/configapi/container_install_command_test.go index 811801a77f..76ab4b1917 100644 --- a/internal/api/configapi/container_install_command_test.go +++ b/internal/api/configapi/container_install_command_test.go @@ -47,6 +47,52 @@ func TestContainerBootstrapHistoryAndShellData(t *testing.T) { assertContainerBootstrapCleanup(t, root) } +func TestContainerBootstrapOverflowDoesNotBecomeShellHistory(t *testing.T) { + root, env := bootstrapFixture(t, recordingInstaller) + token := strings.Repeat("x", 10000) + "-OVERFLOW_HISTORY_MARKER" + command := BuildContainerRuntimeAgentInstallCommand("https://pulse.example", "separate-issued-token", true) + history := filepath.Join(root, "shell-history") + payload, err := json.Marshal(map[string]string{"command": command, "input": token, "history": history}) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("python3", "-c", bootstrapPTYRunner) + cmd.Stdin = strings.NewReader(string(payload)) + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("overflowed input was accepted") + } + hist, err := os.ReadFile(history) + if err != nil || !strings.Contains(string(hist), command) { + t.Fatal("history control did not record the copied command") + } + for _, output := range []string{string(out), string(hist)} { + if strings.Contains(output, "OVERFLOW_HISTORY_MARKER") || strings.Contains(output, strings.Repeat("x", 512)) { + t.Fatal("discarded credential input was echoed or returned to shell history") + } + } + if _, err := os.Stat(filepath.Join(root, "captured-token")); !os.IsNotExist(err) { + t.Fatal("installer ran after overflowed input") + } + assertContainerBootstrapCleanup(t, root) +} + +func TestContainerBootstrapPreservesMaximumAcceptedInput(t *testing.T) { + root, env := bootstrapFixture(t, recordingInstaller) + token := strings.Repeat("a", 4096) + command := BuildContainerRuntimeAgentInstallCommand("https://pulse.example", "separate-issued-token", true) + out, err := runBootstrap(t, command, token, env, true) + if err != nil { + t.Fatalf("maximum-size private input: %v\n%s", err, out) + } + captured, err := os.ReadFile(filepath.Join(root, "captured-token")) + if err != nil || string(captured) != token { + t.Fatal("terminal silently truncated an accepted token") + } + assertContainerBootstrapCleanup(t, root) +} + func assertContainerBootstrapCleanup(t *testing.T, root string) { t.Helper() directories, err := os.ReadFile(filepath.Join(root, "private-directories")) diff --git a/internal/api/configapi/install_command.go b/internal/api/configapi/install_command.go index 0293eef748..8c514f782e 100644 --- a/internal/api/configapi/install_command.go +++ b/internal/api/configapi/install_command.go @@ -83,9 +83,18 @@ func privateBootstrapCommand(scriptURL, curlFlags, preflight, run string, needsT privileged = append(privileged, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);`, `token_file="$token_dir/token";`, - `cleanup() { unset pulse_token; rm -f -- "$token_file"; rmdir -- "$token_dir"; };`, + `unset pulse_token pulse_discard tty_state;`, + `cleanup() { unset pulse_token pulse_discard; if [ -n "${tty_state:-}" ]; then stty "$tty_state" /dev/null || :; fi; rm -f -- "$token_file"; rmdir -- "$token_dir"; };`, `trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;`, - `if ! IFS= read -r -s -p `+posixShellQuote(prompt+" (paste at this prompt, not in the command): ")+` pulse_token &2; exit 1; fi;`, + // A normal canonical tty silently truncates long lines (4095 bytes on + // Linux). Bounded character input preserves valid input and detects + // overflow; keep echo off while draining the rest of an invalid line + // so it cannot become a second command in the caller's shell history. + `if ! tty_state=$(stty -g &2; exit 1; fi;`, + `stty -echo &2; exit 1; fi;`, + `if [ "${#pulse_token}" -gt 4096 ]; then while IFS= read -r -s -n 4097 pulse_discard /dev/tty;`, `if [ -z "$pulse_token" ] || [ "${#pulse_token}" -gt 4096 ]; then echo "A non-empty token of at most 4096 characters is required." >&2; exit 1; fi;`, `printf %s "$pulse_token" > "$token_file"; unset pulse_token;`, diff --git a/internal/api/configapi/install_command_test.go b/internal/api/configapi/install_command_test.go index b35d089dd9..caf1c13311 100644 --- a/internal/api/configapi/install_command_test.go +++ b/internal/api/configapi/install_command_test.go @@ -16,6 +16,8 @@ const bootstrapPTYRunner = ` import errno, fcntl, json, os, pty, select, signal, subprocess, sys, termios, time p = json.load(sys.stdin) master, slave = pty.openpty() +tty_watch = os.dup(slave) +initial_tty = termios.tcgetattr(tty_watch) def session(): os.setsid() fcntl.ioctl(0, termios.TIOCSCTTY, 0) @@ -48,6 +50,12 @@ else: os.killpg(child.pid, signal.SIGKILL) output += b"\nPTY fixture deadline expired\n" code = child.wait() +final_tty = termios.tcgetattr(tty_watch) +mode_mask = termios.ECHO | termios.ICANON +if (initial_tty[3] & mode_mask) != (final_tty[3] & mode_mask): + output += b"\nBootstrap did not restore terminal echo/canonical mode\n" + code = 1 +os.close(tty_watch) os.close(master) sys.stdout.buffer.write(output) sys.exit(code if code >= 0 else 128 - code)