From cde2756c0c9e63e172ab2920fccaaf3225f0ed8c Mon Sep 17 00:00:00 2001 From: cppla Date: Thu, 8 Oct 2026 15:35:38 +0800 Subject: [PATCH] fix: fail token generation when stdout cannot deliver credentials --- cmd/autocar/token.go | 11 +++- cmd/autocar/token_output_test.go | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 cmd/autocar/token_output_test.go diff --git a/cmd/autocar/token.go b/cmd/autocar/token.go index 5d9b9f5..8a84103 100644 --- a/cmd/autocar/token.go +++ b/cmd/autocar/token.go @@ -6,11 +6,16 @@ import ( "errors" "flag" "fmt" + "io" "os" "strings" ) func runToken(args []string) error { + return runTokenWith(args, os.Stdout) +} + +func runTokenWith(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("token", flag.ContinueOnError) output := fs.String("out", "", "write to a new 0600 file instead of stdout") bytesCount := fs.Int("bytes", 32, "random byte count (16-128)") @@ -26,7 +31,11 @@ func runToken(args []string) error { } value := base64.RawURLEncoding.EncodeToString(random) + "\n" if *output == "" { - fmt.Print(value) + if n, err := io.WriteString(stdout, value); err != nil || n != len(value) { + // Writers may include the attempted bytes in their errors. Never + // expose a generated credential through diagnostics. + return errors.New("could not write token to stdout") + } return nil } if strings.TrimSpace(*output) == "" { diff --git a/cmd/autocar/token_output_test.go b/cmd/autocar/token_output_test.go new file mode 100644 index 0000000..bf95ede --- /dev/null +++ b/cmd/autocar/token_output_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestTokenCommandRejectsClosedStdout(t *testing.T) { + closed, err := os.CreateTemp(t.TempDir(), "closed-stdout") + if err != nil { + t.Fatal(err) + } + if err := closed.Close(); err != nil { + t.Fatal(err) + } + + err = runTokenWith(nil, closed) + if err == nil || commandExitCode(err) != 1 { + t.Fatal("token reported success despite closed stdout") + } +} + +type tokenOutputFailure struct { + short bool + calls int +} + +func (w *tokenOutputFailure) Write(p []byte) (int, error) { + w.calls++ + if w.short { + return len(p) - 1, nil + } + return 0, fmt.Errorf("private writer diagnostic with credential: %s", p) +} + +func TestTokenCommandRejectsOutputFailureWithoutLeakingSecrets(t *testing.T) { + for _, short := range []bool{false, true} { + t.Run(strconv.FormatBool(short), func(t *testing.T) { + output := &tokenOutputFailure{short: short} + err := runTokenWith(nil, output) + if err == nil || commandExitCode(err) != 1 { + t.Fatal("failed or incomplete credential output must fail the command") + } + if err.Error() != "could not write token to stdout" { + t.Fatal("output failure did not return the fixed, secret-free diagnostic") + } + if output.calls != 1 { + t.Fatalf("output calls = %d, want one attempt without retry", output.calls) + } + }) + } +} + +func TestTokenCommandStdoutFormat(t *testing.T) { + for _, size := range []int{16, 32, 128} { + t.Run(strconv.Itoa(size), func(t *testing.T) { + var output strings.Builder + if err := runTokenWith([]string{"--bytes", strconv.Itoa(size)}, &output); err != nil { + t.Fatal(err) + } + value := output.String() + if !strings.HasSuffix(value, "\n") || strings.Count(value, "\n") != 1 { + t.Fatal("stdout must contain exactly one newline-terminated token") + } + decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSuffix(value, "\n")) + if err != nil || len(decoded) != size { + t.Fatal("stdout token is not the requested raw URL-safe base64 payload") + } + }) + } +} + +func TestTokenCommandFileDoesNotWriteStdout(t *testing.T) { + output := &tokenOutputFailure{} + path := filepath.Join(t.TempDir(), "token") + if err := runTokenWith([]string{"--out", path}, output); err != nil { + t.Fatal(err) + } + if output.calls != 0 { + t.Fatal("file output unexpectedly also wrote the credential to stdout") + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +}