完成Linux敏感输入安全整改
拒绝通过命令行参数和管道传入SMTP敏感值,改用Linux交互式终端隐藏输入,并增加伪终端回显关闭、状态恢复和首尾空格保留测试。
This commit is contained in:
parent
038bf73e59
commit
0788b6e41d
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
21
backend/internal/cli/secret_input_common.go
Normal file
21
backend/internal/cli/secret_input_common.go
Normal 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
|
||||
}
|
||||
48
backend/internal/cli/secret_input_linux.go
Normal file
48
backend/internal/cli/secret_input_linux.go
Normal 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)
|
||||
}
|
||||
104
backend/internal/cli/secret_input_linux_test.go
Normal file
104
backend/internal/cli/secret_input_linux_test.go
Normal 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("敏感值出现在提示输出中")
|
||||
}
|
||||
}
|
||||
12
backend/internal/cli/secret_input_other.go
Normal file
12
backend/internal/cli/secret_input_other.go
Normal 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 交互式终端")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user