rdev/internal/sanitize/sanitize.go
jordan 538ea57ed4 feat: Add claude-config API, security hardening, and testing infrastructure
Claude Config API (v0.6):
- Add CRUD endpoints for commands, skills, and agents
- Commands/skills/agents stored in /workspace/.claude/ (per-project, in git)
- Credentials shared via PVC at /root/.claude/ (shared across pods)
- Use base64 encoding for file writes (prevents shell injection)
- Add content size limits (1MB max)

Security Hardening:
- Add sanitize package for command/prompt validation
- Add rate limiting middleware (token bucket algorithm)
- Add concurrent command limiting
- Add input sanitization to all command handlers
- Gitignore secrets.yaml and credentials.yaml
- Add *.example templates for secrets

Testing Infrastructure:
- Add testutil package with mocks and fixtures
- Add unit tests for auth package (63% coverage)
- Add unit tests for executor (47% coverage)
- Add handler integration tests (40% coverage)
- Add 100% coverage for sanitize, cmdlimit packages
- Add 96% coverage for ratelimit package

Infrastructure:
- Shared Claude credentials PVC (ReadWriteMany)
- Reduced workspace PVC size from 20Gi to 5Gi
- Add init container cleanup before git clone
- Document Longhorn RWX requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:29:13 -07:00

234 lines
6.5 KiB
Go

// Package sanitize provides command input sanitization to prevent injection attacks.
package sanitize
import (
"fmt"
"regexp"
"strings"
)
// Package-level compiled regex patterns for performance.
var (
// Dangerous shell command patterns
dangerousCommandPatterns = []*dangerousPattern{
{regexp.MustCompile(`(?i)\brm\s+(-[rf]+\s+)*(/|~|\.\.|/etc|/var|/usr|/home|/root)`), "destructive rm command"},
{regexp.MustCompile(`(?i)\bdd\s+`), "dd command"},
{regexp.MustCompile(`(?i)\bmkfs\b`), "mkfs command"},
{regexp.MustCompile(`(?i)\bfdisk\b`), "fdisk command"},
{regexp.MustCompile(`(?i)\bshutdown\b`), "shutdown command"},
{regexp.MustCompile(`(?i)\breboot\b`), "reboot command"},
{regexp.MustCompile(`(?i)\bsystemctl\s+(stop|disable|mask|halt|poweroff)`), "dangerous systemctl command"},
{regexp.MustCompile(`(?i)\bkill\s+-9\s+(-1|1)\b`), "kill all processes"},
{regexp.MustCompile(`(?i)\bchmod\s+(-R\s+)?(777|666)\s+/`), "dangerous chmod on root"},
{regexp.MustCompile(`(?i)\bchown\s+-R\s+\S+\s+/`), "dangerous chown on root"},
{regexp.MustCompile(`(?i):\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;`), "fork bomb"},
{regexp.MustCompile(`(?i)\bcurl\s+.*\|\s*(ba)?sh`), "remote code execution via curl"},
{regexp.MustCompile(`(?i)\bwget\s+.*\|\s*(ba)?sh`), "remote code execution via wget"},
}
// Stream ID validation pattern
streamIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
)
// dangerousPattern pairs a compiled regex with its error reason.
type dangerousPattern struct {
pattern *regexp.Regexp
reason string
}
// Error types for sanitization failures.
type Error struct {
Reason string
Input string
Pattern string
}
func (e *Error) Error() string {
if e.Pattern != "" {
return fmt.Sprintf("sanitization failed: %s (matched pattern: %q, input: %q)", e.Reason, e.Pattern, e.Input)
}
return fmt.Sprintf("sanitization failed: %s (input: %q)", e.Reason, e.Input)
}
// ShellCommand validates and sanitizes a shell command.
// Returns an error if the command contains dangerous patterns.
func ShellCommand(cmd string) error {
if strings.TrimSpace(cmd) == "" {
return &Error{Reason: "empty command", Input: cmd}
}
// Check for null bytes
if strings.ContainsRune(cmd, '\x00') {
return &Error{Reason: "contains null byte", Input: cmd}
}
// Dangerous command chaining patterns
chainPatterns := []string{
`;`, // Command separator
`&&`, // AND operator
`||`, // OR operator
`|`, // Pipe
"`", // Backtick command substitution
`$(`, // Command substitution
`${`, // Variable expansion that could be exploited
`>(`, // Process substitution
`<(`, // Process substitution
`\n`, // Newline (command separator in shell)
`\r`, // Carriage return
}
for _, pattern := range chainPatterns {
if strings.Contains(cmd, pattern) {
return &Error{
Reason: "contains command chaining operator",
Input: cmd,
Pattern: pattern,
}
}
}
// Dangerous redirect patterns
redirectPatterns := []string{
`>`, // Output redirect
`>>`, // Append redirect
`<`, // Input redirect
}
for _, pattern := range redirectPatterns {
if strings.Contains(cmd, pattern) {
return &Error{
Reason: "contains redirect operator",
Input: cmd,
Pattern: pattern,
}
}
}
// Check against pre-compiled dangerous command patterns
for _, dp := range dangerousCommandPatterns {
if dp.pattern.MatchString(cmd) {
return &Error{
Reason: dp.reason,
Input: cmd,
Pattern: dp.pattern.String(),
}
}
}
return nil
}
// GitArgs validates git command arguments.
// Returns an error if any argument contains dangerous patterns.
func GitArgs(args []string) error {
if len(args) == 0 {
return &Error{Reason: "empty git args"}
}
// Dangerous git subcommands
dangerousSubcommands := map[string]string{
"config": "git config can modify system settings",
"remote": "git remote can add malicious remotes",
"push": "git push can modify remote repositories",
}
// First arg is the subcommand
subcommand := strings.ToLower(args[0])
if reason, dangerous := dangerousSubcommands[subcommand]; dangerous {
// push is allowed for specific use cases, block only with --force
if subcommand == "push" {
for _, arg := range args[1:] {
if arg == "-f" || arg == "--force" || arg == "--force-with-lease" {
return &Error{Reason: "force push not allowed", Input: strings.Join(args, " ")}
}
}
} else {
return &Error{Reason: reason, Input: strings.Join(args, " ")}
}
}
// Check all args for shell injection
for _, arg := range args {
if err := validateArg(arg); err != nil {
return err
}
}
return nil
}
// ClaudePrompt validates a Claude prompt.
// This is relatively permissive since prompts are passed as a single argument to claude CLI.
func ClaudePrompt(prompt string) error {
if strings.TrimSpace(prompt) == "" {
return &Error{Reason: "empty prompt"}
}
// Check for null bytes
if strings.ContainsRune(prompt, '\x00') {
return &Error{Reason: "contains null byte", Input: prompt}
}
// Limit length to prevent resource exhaustion
const maxPromptLength = 100000 // 100KB
if len(prompt) > maxPromptLength {
return &Error{
Reason: fmt.Sprintf("prompt too long (max %d bytes)", maxPromptLength),
Input: prompt[:100] + "...",
}
}
return nil
}
// validateArg validates a single command argument for shell injection.
func validateArg(arg string) error {
// Check for null bytes
if strings.ContainsRune(arg, '\x00') {
return &Error{Reason: "argument contains null byte", Input: arg}
}
// Check for shell metacharacters that could break out of quoting
shellMetachars := []string{
"`", // Backtick
`$(`, // Command substitution
`${`, // Variable expansion
}
for _, meta := range shellMetachars {
if strings.Contains(arg, meta) {
return &Error{
Reason: "argument contains shell metacharacter",
Input: arg,
Pattern: meta,
}
}
}
return nil
}
// StreamID validates a stream ID.
// Stream IDs should be alphanumeric with hyphens and underscores only.
func StreamID(id string) error {
if id == "" {
return nil // Empty is allowed (will be auto-generated)
}
// Must be reasonable length
if len(id) > 64 {
return &Error{Reason: "stream ID too long (max 64 chars)", Input: id}
}
// Must match safe pattern (uses pre-compiled regex)
if !streamIDPattern.MatchString(id) {
return &Error{
Reason: "stream ID must be alphanumeric with hyphens/underscores",
Input: id,
Pattern: streamIDPattern.String(),
}
}
return nil
}