Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
55420aa
feat: add spacing to keyframe animation definitions in VM details cel…
julienhmmt Nov 30, 2025
f5a4041
feat: initial implementation of cloud-init, with forms, handlers, yam…
julienhmmt Nov 30, 2025
df9881d
feat: rename resty_cloudinit.go to cloudinit.go
julienhmmt Nov 30, 2025
2aee730
feat: migrate cloud-init i18n files to standard go-i18n format with i…
julienhmmt Nov 30, 2025
a1f5d91
feat: reorganize cloud-init admin page layout to full-width single co…
julienhmmt Nov 30, 2025
b39e37c
feat: implement Proxmox storage integration for cloud-init templates …
julienhmmt Nov 30, 2025
1242137
feat: reorganize VM creation form layout by moving cloud-init configu…
julienhmmt Nov 30, 2025
6acb7d2
feat: add YAML view modal for cloud-init templates with eye icon butt…
julienhmmt Nov 30, 2025
ddd740c
feat: improve cloud-init template content fetching with consistent no…
julienhmmt Nov 30, 2025
4d46a13
feat: enhance cloud-init template storage with local YAML fallback an…
julienhmmt Dec 1, 2025
fd49ecc
feat: restrict cloud-init template management to users with valid Pro…
julienhmmt Dec 1, 2025
d80b632
feat: remove Proxmox storage integration for cloud-init templates and…
julienhmmt Dec 1, 2025
a5d4b9d
feat: rename showNotification to showInlineNotification in cloud-init…
julienhmmt Dec 1, 2025
1b5228a
feat: add cloud-init template preview functionality to VM creation pa…
julienhmmt Dec 1, 2025
613c79f
feat: simplify VM creation success message and clarify cloud-init par…
julienhmmt Dec 1, 2025
529c1de
feat: implement cloud-init template application during VM creation wi…
julienhmmt Dec 1, 2025
60f67c0
feat: remove storage enabled check from snippet storage selection to …
julienhmmt Dec 1, 2025
a573981
feat: add comprehensive cloud-init SFTP upload documentation and impl…
julienhmmt Dec 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions backend/cloudinit/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Package cloudinit provides cloud-init validation and utility functions.
package cloudinit

import (
"errors"
"fmt"
"strings"

"gopkg.in/yaml.v3"
)

// Validation constants
const (
MaxYAMLSize = 128 * 1024 // 128 KB max size for cloud-init YAML
MaxLineCount = 2000 // Maximum lines allowed
MinYAMLSize = 1 // Minimum size (at least 1 byte)
CloudInitMagic = "#cloud-config"
)

// ValidationError represents a YAML validation error with details.
type ValidationError struct {
Message string
Line int
Column int
}

func (e *ValidationError) Error() string {
if e.Line > 0 {
return fmt.Sprintf("line %d, column %d: %s", e.Line, e.Column, e.Message)
}
return e.Message
}

// ValidateCloudInitYAML validates a cloud-init YAML string.
// It checks for:
// - Valid YAML syntax
// - Size limits (max 128 KB)
// - Line count limits (max 2000 lines)
// - Does NOT validate cloud-init semantics (out of scope)
func ValidateCloudInitYAML(input string) error {
// Check for empty input
if strings.TrimSpace(input) == "" {
return &ValidationError{Message: "YAML content cannot be empty"}
}

// Check size limits
if len(input) > MaxYAMLSize {
return &ValidationError{
Message: fmt.Sprintf("YAML content exceeds maximum size of %d KB", MaxYAMLSize/1024),
}
}

// Check line count
lineCount := strings.Count(input, "\n") + 1
if lineCount > MaxLineCount {
return &ValidationError{
Message: fmt.Sprintf("YAML content exceeds maximum of %d lines", MaxLineCount),
}
}

// Parse YAML to check syntax
var parsed interface{}
if err := yaml.Unmarshal([]byte(input), &parsed); err != nil {
// Try to extract line/column from yaml error
var typeErr *yaml.TypeError
if errors.As(err, &typeErr) {
return &ValidationError{Message: fmt.Sprintf("YAML syntax error: %v", typeErr.Errors)}
}
return &ValidationError{Message: fmt.Sprintf("invalid YAML syntax: %v", err)}
}

// Check that parsed result is a map (cloud-init configs should be key-value)
if parsed != nil {
if _, ok := parsed.(map[string]interface{}); !ok {
// Allow string or nil for simple configs
if _, isString := parsed.(string); !isString && parsed != nil {
return &ValidationError{Message: "cloud-init config should be a YAML mapping (key: value pairs)"}
}
}
}

return nil
}

// ValidateCloudInitYAMLStrict validates with stricter rules for cloud-init.
// It additionally checks that the YAML starts with #cloud-config comment.
func ValidateCloudInitYAMLStrict(input string) error {
// First run basic validation
if err := ValidateCloudInitYAML(input); err != nil {
return err
}

// Check for cloud-init magic header
trimmed := strings.TrimSpace(input)
if !strings.HasPrefix(trimmed, CloudInitMagic) {
return &ValidationError{
Message: fmt.Sprintf("cloud-init config should start with '%s' comment", CloudInitMagic),
Line: 1,
}
}

return nil
}

// IsValidYAML is a simple helper that returns true if the input is valid YAML.
func IsValidYAML(input string) bool {
return ValidateCloudInitYAML(input) == nil
}

// SanitizeYAML removes potentially dangerous content from YAML.
// Currently just trims whitespace; future enhancements could include
// removing sensitive patterns.
func SanitizeYAML(input string) string {
// Trim leading/trailing whitespace
return strings.TrimSpace(input)
}

// ParseCloudInitYAML parses YAML and returns a map representation.
// Returns nil if the input is not a valid map.
func ParseCloudInitYAML(input string) (map[string]interface{}, error) {
if err := ValidateCloudInitYAML(input); err != nil {
return nil, err
}

var result map[string]interface{}
if err := yaml.Unmarshal([]byte(input), &result); err != nil {
return nil, &ValidationError{Message: fmt.Sprintf("failed to parse YAML: %v", err)}
}

return result, nil
}
240 changes: 240 additions & 0 deletions backend/cloudinit/validator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package cloudinit

import (
"strings"
"testing"
)

func TestValidateCloudInitYAML(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
errMsg string
}{
{
name: "valid simple YAML",
input: "key: value",
wantErr: false,
},
{
name: "valid cloud-init config",
input: `#cloud-config
users:
- name: testuser
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- ssh-rsa AAAAB...`,
wantErr: false,
},
{
name: "empty string",
input: "",
wantErr: true,
errMsg: "cannot be empty",
},
{
name: "whitespace only",
input: " \n\t\n ",
wantErr: true,
errMsg: "cannot be empty",
},
{
name: "invalid YAML syntax",
input: "key: value\n invalid: indentation",
wantErr: true,
errMsg: "YAML",
},
{
name: "invalid YAML - unclosed quote",
input: `key: "unclosed string`,
wantErr: true,
errMsg: "YAML",
},
{
name: "valid nested structure",
input: `runcmd:
- echo "hello"
- apt-get update
packages:
- nginx
- vim`,
wantErr: false,
},
{
name: "too large",
input: strings.Repeat("a", MaxYAMLSize+1),
wantErr: true,
errMsg: "exceeds maximum size",
},
{
name: "too many lines",
input: strings.Repeat("key: value\n", MaxLineCount+1),
wantErr: true,
errMsg: "exceeds maximum",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCloudInitYAML(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateCloudInitYAML() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr && tt.errMsg != "" && err != nil {
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("ValidateCloudInitYAML() error = %v, want error containing %q", err, tt.errMsg)
}
}
})
}
}

func TestValidateCloudInitYAMLStrict(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
errMsg string
}{
{
name: "valid with cloud-config header",
input: `#cloud-config
users:
- name: test`,
wantErr: false,
},
{
name: "missing cloud-config header",
input: "users:\n - name: test",
wantErr: true,
errMsg: "#cloud-config",
},
{
name: "header with leading whitespace on first line",
input: ` #cloud-config
users:
- name: test`,
wantErr: false, // TrimSpace removes leading whitespace, so header is valid
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCloudInitYAMLStrict(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateCloudInitYAMLStrict() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr && tt.errMsg != "" && err != nil {
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("ValidateCloudInitYAMLStrict() error = %v, want error containing %q", err, tt.errMsg)
}
}
})
}
}

func TestIsValidYAML(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{
name: "valid YAML",
input: "key: value",
want: true,
},
{
name: "invalid YAML",
input: "key: value\n bad: indent",
want: false,
},
{
name: "empty",
input: "",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidYAML(tt.input); got != tt.want {
t.Errorf("IsValidYAML() = %v, want %v", got, tt.want)
}
})
}
}

func TestParseCloudInitYAML(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
wantKey string
}{
{
name: "simple map",
input: "key: value",
wantErr: false,
wantKey: "key",
},
{
name: "cloud-init config",
input: `#cloud-config
users:
- name: test`,
wantErr: false,
wantKey: "users",
},
{
name: "invalid YAML",
input: "not: valid: yaml:",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseCloudInitYAML(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseCloudInitYAML() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.wantKey != "" {
if _, ok := got[tt.wantKey]; !ok {
t.Errorf("ParseCloudInitYAML() missing expected key %q", tt.wantKey)
}
}
})
}
}

func TestSanitizeYAML(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "no change needed",
input: "key: value",
want: "key: value",
},
{
name: "trim whitespace",
input: " \n key: value \n ",
want: "key: value",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SanitizeYAML(tt.input); got != tt.want {
t.Errorf("SanitizeYAML() = %v, want %v", got, tt.want)
}
})
}
}
Loading
Loading