Skip to content

Commit 5a6a694

Browse files
committed
fix(vm): stop leaking the VNC password; unify the CNI-VNC password gate
- VNCPass is launch-scoped and set from the flag on every start, but it was persisted to vm.json (json tag) and echoed by inspect/list — a plaintext password at rest. Mark it json:"-". - setVNCPassword embedded the full HMP dialogue in its error, which echoes the typed 'set_password vnc <pw>' line to stderr/CI logs. Return a static error instead. - startVNCProxy left an orphan proxy holding the host port if WritePIDFile failed after the child forked; kill it on that path. - The mandatory-password gate was hand-duplicated at create/clone/launch; extract requireCNIVNCPassword and add a regression test.
1 parent fc2f7f6 commit 5a6a694

6 files changed

Lines changed: 56 additions & 9 deletions

File tree

cmd/vm/clone.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error {
3030
netMode, _ := cmd.Flags().GetString("net")
3131
vnc, _ := cmd.Flags().GetInt("vnc")
3232
vncPass, _ := cmd.Flags().GetString("vnc-password")
33-
if netMode == netCNI && vnc >= 0 && vncPass == "" { // same pre-scaffold check as create
34-
return errCNIVNCPassRequired
33+
if err = requireCNIVNCPassword(netMode == netCNI, vnc, vncPass); err != nil { // same pre-scaffold check as create
34+
return err
3535
}
3636
// the clone inherits SRC's data disks by name; extra --data-disk specs must not collide with them
3737
// and the combined count still honors the AHCI cap. Parse before scaffolding to fail fast.

cmd/vm/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ type record struct {
3737
CPUs int `json:"cpus"`
3838
Memory string `json:"memory"`
3939
VNCDisp int `json:"vnc"`
40-
VNCPass string `json:"vnc_password,omitempty"`
40+
VNCPass string `json:"-"` // launch-scoped, set from the flag each start; never persisted (would leak at rest)
4141
SSHPort int `json:"ssh_port"`
4242
NetMode string `json:"net_mode,omitempty"`
4343
Hugepages bool `json:"hugepages,omitempty"`

cmd/vm/helper.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,8 @@ func setVNCPassword(ctx context.Context, monSock, pw string) error {
227227
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
228228
out, _ := readUntil(conn, "(qemu)")
229229
if strings.Contains(out, "Could not") {
230-
return fmt.Errorf("qemu: %s", strings.TrimSpace(out))
230+
// out echoes the typed "set_password vnc <pw>" line — never surface it.
231+
return errors.New("qemu rejected set_password (vnc display not active?)")
231232
}
232233
return nil
233234
}

cmd/vm/lifecycle.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) {
142142
vnc, _ := cmd.Flags().GetInt("vnc")
143143
vncPass, _ := cmd.Flags().GetString("vnc-password")
144144
netMode, _ := cmd.Flags().GetString("net")
145-
if netMode == netCNI && vnc >= 0 && vncPass == "" { // fail before scaffolding leaves a half-made VM
146-
return nil, errCNIVNCPassRequired
145+
if err = requireCNIVNCPassword(netMode == netCNI, vnc, vncPass); err != nil { // fail before scaffolding leaves a half-made VM
146+
return nil, err
147147
}
148148
oc, code, varsTmpl, err := resolveFirmware(cmd)
149149
if err != nil {
@@ -182,8 +182,8 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) {
182182
func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error {
183183
ctx := cliutil.CommandContext(cmd)
184184
logger := log.WithFunc("cmd.vm.launch")
185-
if r.Netns != "" && r.VNCDisp >= 0 && r.VNCPass == "" {
186-
return errCNIVNCPassRequired
185+
if err := requireCNIVNCPassword(r.Netns != "", r.VNCDisp, r.VNCPass); err != nil {
186+
return err
187187
}
188188
if hostIsAMD() {
189189
// macOS reads MSRs an AMD host lacks; without kvm.ignore_msrs KVM injects #GP. Best-effort,

cmd/vm/vnc.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ const (
3030
// never expose that unauthenticated.
3131
var errCNIVNCPassRequired = errors.New("--vnc with --net cni serves VNC on a host port reachable off-box; --vnc-password is required")
3232

33+
// requireCNIVNCPassword rejects an unauthenticated VNC display on a CNI VM whose
34+
// host port is reachable off-box. isCNI is the flag intent at create/clone
35+
// (pre-scaffold) or the resolved Netns at launch (post-scaffold).
36+
func requireCNIVNCPassword(isCNI bool, vncDisp int, vncPass string) error {
37+
if isCNI && vncDisp >= 0 && vncPass == "" {
38+
return errCNIVNCPassRequired
39+
}
40+
return nil
41+
}
42+
3343
// vncProxyCommand is the hidden re-exec target that runs the forwarder for its lifetime, accepting
3444
// on the TCP listener inherited as fd 3 (bound by the parent so bind errors fail the launch).
3545
func vncProxyCommand() *cobra.Command {
@@ -72,7 +82,11 @@ func startVNCProxy(ctx context.Context, dir string, disp int) error {
7282
if err := c.Start(); err != nil {
7383
return err
7484
}
75-
return utils.WritePIDFile(filepath.Join(dir, vncProxyPID), c.Process.Pid)
85+
if err := utils.WritePIDFile(filepath.Join(dir, vncProxyPID), c.Process.Pid); err != nil {
86+
_ = c.Process.Kill() // no pidfile -> stopVNCProxy can't reap it; don't orphan the proxy
87+
return err
88+
}
89+
return nil
7690
}
7791

7892
// stopVNCProxy kills a running proxy (best-effort) and removes its pidfile. Zero grace: the CLI's

cmd/vm/vnc_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package vm
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
func TestRequireCNIVNCPassword(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
isCNI bool
12+
vncDisp int
13+
vncPass string
14+
wantErr bool
15+
}{
16+
{"cni vnc no password rejected", true, 0, "", true},
17+
{"cni vnc with password ok", true, 0, "s3cret", false},
18+
{"cni vnc disabled ok", true, -1, "", false},
19+
{"non-cni vnc no password ok", false, 0, "", false},
20+
}
21+
for _, tt := range tests {
22+
t.Run(tt.name, func(t *testing.T) {
23+
err := requireCNIVNCPassword(tt.isCNI, tt.vncDisp, tt.vncPass)
24+
if tt.wantErr && !errors.Is(err, errCNIVNCPassRequired) {
25+
t.Errorf("got %v, want errCNIVNCPassRequired", err)
26+
}
27+
if !tt.wantErr && err != nil {
28+
t.Errorf("got %v, want nil", err)
29+
}
30+
})
31+
}
32+
}

0 commit comments

Comments
 (0)