From 0788b6e41d3053681ecfcdb6f7d4a320d9dd4a18 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 5 Oct 2026 13:06:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90Linux=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=AE=89=E5=85=A8=E6=95=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拒绝通过命令行参数和管道传入SMTP敏感值,改用Linux交互式终端隐藏输入,并增加伪终端回显关闭、状态恢复和首尾空格保留测试。 --- backend/internal/cli/operations.go | 14 +-- backend/internal/cli/operations_test.go | 12 ++ backend/internal/cli/secret_input_common.go | 21 ++++ backend/internal/cli/secret_input_linux.go | 48 ++++++++ .../internal/cli/secret_input_linux_test.go | 104 ++++++++++++++++++ backend/internal/cli/secret_input_other.go | 12 ++ 6 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 backend/internal/cli/secret_input_common.go create mode 100644 backend/internal/cli/secret_input_linux.go create mode 100644 backend/internal/cli/secret_input_linux_test.go create mode 100644 backend/internal/cli/secret_input_other.go diff --git a/backend/internal/cli/operations.go b/backend/internal/cli/operations.go index d5dfc4d..c771d69 100644 --- a/backend/internal/cli/operations.go +++ b/backend/internal/cli/operations.go @@ -231,12 +231,12 @@ func newConfigSetSecretCommand() *cobra.Command { if key != "notification.smtp_password" { return fmt.Errorf("不支持更新的敏感配置项:%s", key) } - if value == "" { - var err error - value, err = ask(bufio.NewReader(cmd.InOrStdin()), cmd.OutOrStdout(), "请输入新的 SMTP 密码或授权码", "") - if err != nil { - return err - } + if value != "" { + return errors.New("SMTP 密码不得通过命令行参数传入,请删除 --value 后在交互式终端输入") + } + value, err := readSecretInput(cmd.InOrStdin(), cmd.OutOrStdout(), "请输入新的 SMTP 密码或授权码") + if err != nil { + return err } if value == "" { return errors.New("SMTP 密码不能为空") @@ -272,7 +272,7 @@ func newConfigSetSecretCommand() *cobra.Command { }, } command.Flags().StringVar(&key, "key", "", "敏感配置项名称") - command.Flags().StringVar(&value, "value", "", "SMTP 密码;留空时交互输入") + command.Flags().StringVar(&value, "value", "", "兼容参数;敏感值不得通过命令行传入") _ = command.MarkFlagRequired("key") return command } diff --git a/backend/internal/cli/operations_test.go b/backend/internal/cli/operations_test.go index 7bcf7f5..3b3f585 100644 --- a/backend/internal/cli/operations_test.go +++ b/backend/internal/cli/operations_test.go @@ -82,3 +82,15 @@ func TestRootIncludesOperationalCommands(t *testing.T) { } } } + +func TestSetSecretRejectsCommandLineValue(t *testing.T) { + command := newConfigSetSecretCommand() + command.SilenceUsage, command.SilenceErrors = true, true + command.SetArgs([]string{ + "--key", "notification.smtp_password", + "--value", "not-a-real-secret", + }) + if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "命令行参数") { + t.Fatalf("命令行敏感值未被拒绝:%v", err) + } +} diff --git a/backend/internal/cli/secret_input_common.go b/backend/internal/cli/secret_input_common.go new file mode 100644 index 0000000..f487794 --- /dev/null +++ b/backend/internal/cli/secret_input_common.go @@ -0,0 +1,21 @@ +package cli + +import ( + "errors" + "fmt" + "strings" +) + +const maxSecretInputBytes = 4096 + +func parseSecretInput(line string) (string, error) { + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + if line == "" { + return "", errors.New("SMTP 密码不能为空") + } + if len([]byte(line)) > maxSecretInputBytes { + return "", fmt.Errorf("SMTP 密码长度不能超过 %d 字节", maxSecretInputBytes) + } + return line, nil +} diff --git a/backend/internal/cli/secret_input_linux.go b/backend/internal/cli/secret_input_linux.go new file mode 100644 index 0000000..40f412f --- /dev/null +++ b/backend/internal/cli/secret_input_linux.go @@ -0,0 +1,48 @@ +//go:build linux + +package cli + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" +) + +func readSecretInput(input io.Reader, output io.Writer, prompt string) (value string, err error) { + file, ok := input.(*os.File) + if !ok || file == nil { + return "", errors.New("敏感输入必须来自交互式终端,不接受管道或普通输入流") + } + + fd := int(file.Fd()) + original, err := unix.IoctlGetTermios(fd, unix.TCGETS) + if err != nil { + return "", errors.New("敏感输入必须来自交互式终端,不接受管道或普通输入流") + } + + withoutEcho := *original + withoutEcho.Lflag &^= unix.ECHO | unix.ECHONL + if err := unix.IoctlSetTermios(fd, unix.TCSETS, &withoutEcho); err != nil { + return "", fmt.Errorf("关闭终端回显失败:%w", err) + } + defer func() { + restoreErr := unix.IoctlSetTermios(fd, unix.TCSETS, original) + if err == nil && restoreErr != nil { + err = fmt.Errorf("恢复终端状态失败:%w", restoreErr) + } + }() + + if _, err := fmt.Fprint(output, prompt, ": "); err != nil { + return "", fmt.Errorf("显示敏感输入提示失败:%w", err) + } + line, readErr := bufio.NewReader(file).ReadString('\n') + _, _ = fmt.Fprintln(output) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return "", fmt.Errorf("读取敏感输入失败:%w", readErr) + } + return parseSecretInput(line) +} diff --git a/backend/internal/cli/secret_input_linux_test.go b/backend/internal/cli/secret_input_linux_test.go new file mode 100644 index 0000000..cdf70ca --- /dev/null +++ b/backend/internal/cli/secret_input_linux_test.go @@ -0,0 +1,104 @@ +//go:build linux + +package cli + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func openTestPTY(t *testing.T) (master, slave *os.File) { + t.Helper() + masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) + if err != nil { + t.Skipf("当前 Linux 环境无法打开伪终端:%v", err) + } + master = os.NewFile(uintptr(masterFD), "pty-master") + t.Cleanup(func() { _ = master.Close() }) + if err := unix.IoctlSetInt(masterFD, unix.TIOCSPTLCK, 0); err != nil { + t.Fatal(err) + } + ptyNumber, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN) + if err != nil { + t.Fatal(err) + } + slave, err = os.OpenFile(fmt.Sprintf("/dev/pts/%d", ptyNumber), os.O_RDWR|unix.O_NOCTTY, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = slave.Close() }) + return master, slave +} + +func TestReadSecretInputRejectsPipe(t *testing.T) { + _, err := readSecretInput(strings.NewReader("not-a-secret\n"), io.Discard, "请输入密码") + if err == nil || !strings.Contains(err.Error(), "交互式终端") { + t.Fatalf("管道输入未被拒绝:%v", err) + } +} + +func TestReadSecretInputDisablesAndRestoresEcho(t *testing.T) { + master, slave := openTestPTY(t) + before, err := unix.IoctlGetTermios(int(slave.Fd()), unix.TCGETS) + if err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + result := make(chan struct { + value string + err error + }, 1) + go func() { + value, err := readSecretInput(slave, &output, "请输入密码") + result <- struct { + value string + err error + }{value: value, err: err} + }() + + deadline := time.Now().Add(2 * time.Second) + for { + current, ioctlErr := unix.IoctlGetTermios(int(slave.Fd()), unix.TCGETS) + if ioctlErr == nil && current.Lflag&(unix.ECHO|unix.ECHONL) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("读取敏感输入时未关闭终端回显") + } + time.Sleep(5 * time.Millisecond) + } + + if _, err := master.Write([]byte(" valid-secret \n")); err != nil { + t.Fatal(err) + } + select { + case got := <-result: + if got.err != nil { + t.Fatalf("伪终端读取失败:%v", got.err) + } + if got.value != " valid-secret " { + t.Fatalf("合法首尾空格未保留:%q", got.value) + } + case <-time.After(2 * time.Second): + t.Fatal("伪终端读取超时") + } + + after, err := unix.IoctlGetTermios(int(slave.Fd()), unix.TCGETS) + if err != nil { + t.Fatal(err) + } + if after.Lflag != before.Lflag { + t.Fatalf("终端状态未恢复:before=%#x after=%#x", before.Lflag, after.Lflag) + } + if strings.Contains(output.String(), "valid-secret") { + t.Fatal("敏感值出现在提示输出中") + } +} diff --git a/backend/internal/cli/secret_input_other.go b/backend/internal/cli/secret_input_other.go new file mode 100644 index 0000000..818a947 --- /dev/null +++ b/backend/internal/cli/secret_input_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package cli + +import ( + "errors" + "io" +) + +func readSecretInput(io.Reader, io.Writer, string) (string, error) { + return "", errors.New("敏感输入仅支持 Linux 交互式终端") +}