From ce47e8ca9d7f0d374241b0965f93a66e68eee7ee Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 29 Nov 2025 18:35:27 +0000 Subject: [PATCH] ADA: Add unit tests for sanitizeInstallerURL and sanitizeSetupAuthToken Add comprehensive test coverage for security-critical URL and token sanitization functions in config_handlers.go. These functions protect the setup script endpoint from injection attacks. TestSanitizeInstallerURL (23 cases): empty/whitespace handling, valid http/https URLs, fragment stripping, query preservation, control character rejection, invalid scheme rejection (ftp/file/javascript/data), and missing host validation. TestSanitizeSetupAuthToken (19 cases): empty/whitespace handling, valid hex tokens of various lengths (32-128 chars), mixed case hex, control character rejection, non-hex character rejection, and length validation. --- internal/api/config_handlers_sanitize_test.go | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 internal/api/config_handlers_sanitize_test.go diff --git a/internal/api/config_handlers_sanitize_test.go b/internal/api/config_handlers_sanitize_test.go new file mode 100644 index 0000000..f851e94 --- /dev/null +++ b/internal/api/config_handlers_sanitize_test.go @@ -0,0 +1,332 @@ +package api + +import ( + "strings" + "testing" +) + +func TestSanitizeInstallerURL(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + errMsg string + }{ + // Valid cases + { + name: "empty string returns empty", + input: "", + want: "", + wantErr: false, + }, + { + name: "whitespace-only returns empty", + input: " \t ", + want: "", + wantErr: false, + }, + { + name: "valid http URL", + input: "http://example.com", + want: "http://example.com", + wantErr: false, + }, + { + name: "valid https URL", + input: "https://example.com", + want: "https://example.com", + wantErr: false, + }, + { + name: "URL with path", + input: "https://example.com/path/to/installer", + want: "https://example.com/path/to/installer", + wantErr: false, + }, + { + name: "URL with port", + input: "https://example.com:8080", + want: "https://example.com:8080", + wantErr: false, + }, + { + name: "URL with query params preserves them", + input: "https://example.com/installer?version=1.0&arch=amd64", + want: "https://example.com/installer?version=1.0&arch=amd64", + wantErr: false, + }, + { + name: "URL with fragment strips fragment", + input: "https://example.com/installer#section", + want: "https://example.com/installer", + wantErr: false, + }, + { + name: "URL with fragment and query preserves query, strips fragment", + input: "https://example.com/installer?version=1.0#section", + want: "https://example.com/installer?version=1.0", + wantErr: false, + }, + { + name: "URL with leading/trailing whitespace is trimmed", + input: " https://example.com/installer ", + want: "https://example.com/installer", + wantErr: false, + }, + { + name: "URL with authentication", + input: "https://user:pass@example.com/installer", + want: "https://user:pass@example.com/installer", + wantErr: false, + }, + + // Error cases + { + name: "carriage return in URL", + input: "https://example.com\r/installer", + wantErr: true, + errMsg: "control characters", + }, + { + name: "newline in URL", + input: "https://example.com\n/installer", + wantErr: true, + errMsg: "control characters", + }, + { + name: "both CR and LF in URL", + input: "https://example.com\r\n/installer", + wantErr: true, + errMsg: "control characters", + }, + { + name: "ftp scheme rejected", + input: "ftp://example.com/installer", + wantErr: true, + errMsg: "scheme must be http or https", + }, + { + name: "file scheme rejected", + input: "file:///etc/passwd", + wantErr: true, + errMsg: "scheme must be http or https", + }, + { + name: "javascript scheme rejected", + input: "javascript:alert(1)", + wantErr: true, + errMsg: "scheme must be http or https", + }, + { + name: "data scheme rejected", + input: "data:text/plain,hello", + wantErr: true, + errMsg: "scheme must be http or https", + }, + { + name: "URL without scheme", + input: "example.com/installer", + wantErr: true, + errMsg: "scheme must be http or https", + }, + { + name: "URL without host", + input: "https:///path", + wantErr: true, + errMsg: "host component is required", + }, + { + name: "relative URL without host", + input: "/path/to/installer", + wantErr: true, + errMsg: "scheme must be http or https", + }, + { + name: "malformed URL", + input: "https://[invalid", + wantErr: true, + errMsg: "failed to parse URL", + }, + { + name: "URL with only scheme", + input: "https://", + wantErr: true, + errMsg: "host component is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sanitizeInstallerURL(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("sanitizeInstallerURL() expected error, got nil") + return + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("sanitizeInstallerURL() error = %q, want substring %q", err.Error(), tt.errMsg) + } + } else { + if err != nil { + t.Errorf("sanitizeInstallerURL() unexpected error = %v", err) + return + } + if got != tt.want { + t.Errorf("sanitizeInstallerURL() = %q, want %q", got, tt.want) + } + } + }) + } +} + +func TestSanitizeSetupAuthToken(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + errMsg string + }{ + // Valid cases + { + name: "empty string returns empty", + input: "", + want: "", + wantErr: false, + }, + { + name: "whitespace-only returns empty", + input: " \t ", + want: "", + wantErr: false, + }, + { + name: "valid 32-char hex token lowercase", + input: "0123456789abcdef0123456789abcdef", + want: "0123456789abcdef0123456789abcdef", + wantErr: false, + }, + { + name: "valid 32-char hex token uppercase", + input: "0123456789ABCDEF0123456789ABCDEF", + want: "0123456789ABCDEF0123456789ABCDEF", + wantErr: false, + }, + { + name: "valid 32-char hex token mixed case", + input: "0123456789aBcDeF0123456789AbCdEf", + want: "0123456789aBcDeF0123456789AbCdEf", + wantErr: false, + }, + { + name: "valid 64-char hex token", + input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + want: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + wantErr: false, + }, + { + name: "valid 128-char hex token (max length)", + input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + want: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + wantErr: false, + }, + { + name: "valid hex token with leading/trailing whitespace is trimmed", + input: " 0123456789abcdef0123456789abcdef ", + want: "0123456789abcdef0123456789abcdef", + wantErr: false, + }, + + // Error cases + { + name: "carriage return in token", + input: "0123456789abcdef\r0123456789abcdef", + wantErr: true, + errMsg: "control characters", + }, + { + name: "newline in token", + input: "0123456789abcdef\n0123456789abcdef", + wantErr: true, + errMsg: "control characters", + }, + { + name: "both CR and LF in token", + input: "0123456789abcdef\r\n123456789abcdef", + wantErr: true, + errMsg: "control characters", + }, + { + name: "non-hex character g", + input: "0123456789abcdefg123456789abcdef", + wantErr: true, + errMsg: "hexadecimal", + }, + { + name: "non-hex character z", + input: "0123456789abcdefz123456789abcdef", + wantErr: true, + errMsg: "hexadecimal", + }, + { + name: "token with spaces", + input: "0123456789abcdef 0123456789abcdef", + wantErr: true, + errMsg: "hexadecimal", + }, + { + name: "token with dash", + input: "0123456789abcdef-0123456789abcdef", + wantErr: true, + errMsg: "hexadecimal", + }, + { + name: "token too short (31 chars)", + input: "0123456789abcdef0123456789abcde", + wantErr: true, + errMsg: "hexadecimal", + }, + { + name: "token too long (129 chars)", + input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0", + wantErr: true, + errMsg: "hexadecimal", + }, + { + name: "special characters", + input: "0123456789abcdef!@#$%^&*()abcdef", + wantErr: true, + errMsg: "hexadecimal", + }, + { + name: "empty hex string (just numbers)", + input: "12345678901234567890123456789012", + want: "12345678901234567890123456789012", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sanitizeSetupAuthToken(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("sanitizeSetupAuthToken() expected error, got nil") + return + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("sanitizeSetupAuthToken() error = %q, want substring %q", err.Error(), tt.errMsg) + } + } else { + if err != nil { + t.Errorf("sanitizeSetupAuthToken() unexpected error = %v", err) + return + } + if got != tt.want { + t.Errorf("sanitizeSetupAuthToken() = %q, want %q", got, tt.want) + } + } + }) + } +}