model: validate VM names as DNS labels at CLI + daemon

A VM name flows into five places that all have narrower grammars
than "arbitrary string":

  - the guest's /etc/hostname  (vm_disk.patchRootOverlay)
  - the guest's /etc/hosts      (same)
  - the <name>.vm DNS record    (vmdns.RecordName)
  - the kernel command line     (system.BuildBootArgs*)
  - VM-dir file-path fragments  (layout.VMsDir/<id>, etc.)

Nothing in the chain was validating the input. A name with
whitespace, newline, dot, slash, colon, or = would produce broken
hostnames, weird DNS labels, smuggled kernel cmdline tokens, or
(in the worst case) surprising traversal through the on-disk
layout. Not host shell injection — we already avoid shelling out
with the raw name — but a real correctness and supportability bug.

New: model.ValidateVMName. Rules:

  - 1..63 chars (DNS label max per RFC 1123; also a comfortable
    /etc/hostname cap)
  - lowercase ASCII letters, digits, '-' only
  - no leading or trailing '-'
  - no normalization — the name is the user-visible identifier
    (store key, `ssh <name>.vm`, `vm show`); silently rewriting
    "MyVM" → "myvm" would hand the user back something different
    than they typed

Called from two places:

  - internal/cli/commands_vm.go vmCreateParamsFromFlags — rejects
    bad `--name` values before any RPC. Empty name still passes
    through so the daemon can generate one.
  - internal/daemon/vm_create.go reserveVM — defense in depth for
    any non-CLI RPC caller (SDK, direct JSON over the socket).

Tests:

  - internal/model/vm_name_test.go — exhaustive character-class
    matrix (space, newline, tab, dot, slash, colon, equals, quote,
    control chars, unicode letters, uppercase, leading/trailing
    hyphen, over-length, max-length-exact, digits-only).
  - internal/cli TestVMCreateParamsFromFlagsRejectsInvalidName —
    CLI wire-through + empty-name passthrough.
  - internal/daemon TestReserveVMRejectsInvalidName — daemon
    defense-in-depth (including `box/../evil` path-traversal).
  - scripts/smoke.sh — end-to-end rejection + no-leaked-row
    assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-23 14:06:40 -03:00
parent 700a1e6e60
commit caa6a2b996
No known key found for this signature in database
GPG key ID: 33112E6833C34679
7 changed files with 225 additions and 0 deletions

View file

@ -443,6 +443,47 @@ func TestVMCreateParamsFromFlagsRejectsNonPositive(t *testing.T) {
}
}
func TestVMCreateParamsFromFlagsRejectsInvalidName(t *testing.T) {
cmd := NewBangerCommand()
vm, _, err := cmd.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
create, _, err := vm.Find([]string{"create"})
if err != nil {
t.Fatalf("find create: %v", err)
}
// A sampling of failure modes; the exhaustive character-class
// matrix lives in internal/model/vm_name_test.go. Here we just
// prove the CLI wires the validator in and surfaces its errors
// before any RPC call is made.
cases := []struct {
name string
input string
}{
{"space", "my box"},
{"uppercase", "MyBox"},
{"dot", "box.vm"},
{"leading hyphen", "-box"},
{"newline", "my\nbox"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := vmCreateParamsFromFlags(create, tc.input, "", 2, 1024, "8G", "8G", false, false); err == nil {
t.Fatalf("vmCreateParamsFromFlags(%q) = nil error, want rejection", tc.input)
}
})
}
// Empty name must STILL be accepted at the CLI layer — the daemon
// generates one when the flag is unset. Rejecting here would
// break `banger vm create` with no --name.
if _, err := vmCreateParamsFromFlags(create, "", "", 2, 1024, "8G", "8G", false, false); err != nil {
t.Fatalf("vmCreateParamsFromFlags(empty name) = %v, want nil (daemon generates)", err)
}
}
func TestVMCreateParamsFromFlagsIncludesChangedDiskFlags(t *testing.T) {
cmd := NewBangerCommand()
vm, _, err := cmd.Find([]string{"vm"})

View file

@ -925,6 +925,11 @@ func vmCreateParamsFromFlags(cmd *cobra.Command, name, imageName string, vcpu, m
// command-build time, so we always forward the flag values. The CLI
// becomes the single source of truth for effective defaults and the
// progress renderer shows the exact sizing.
if strings.TrimSpace(name) != "" {
if err := model.ValidateVMName(name); err != nil {
return api.VMCreateParams{}, err
}
}
if err := validatePositiveSetting("vcpu", vcpu); err != nil {
return api.VMCreateParams{}, err
}