完成Linux敏感输入安全整改

拒绝通过命令行参数和管道传入SMTP敏感值,改用Linux交互式终端隐藏输入,并增加伪终端回显关闭、状态恢复和首尾空格保留测试。
This commit is contained in:
xuans 2026-10-05 13:06:34 +08:00
parent 038bf73e59
commit 0788b6e41d
6 changed files with 204 additions and 7 deletions

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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("敏感值出现在提示输出中")
}
}

View File

@ -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 交互式终端")
}