Skip to content

Commit 213bab2

Browse files
committed
further security hardening
1 parent dd81d1d commit 213bab2

File tree

4 files changed

+84
-23
lines changed

4 files changed

+84
-23
lines changed

cmd/agent/executeCommand.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import (
1414
)
1515

1616
// executeCommandWithPipes executes a command and captures stdout/stderr separately.
17+
// SECURITY: Commands come from checks.yaml which must be controlled by the system admin.
18+
// Bash restricted mode (-r) prevents: cd, PATH changes, output redirection, running programs
19+
// with / in name. Complex shell operations (pipes, grep, awk) are still allowed by design
20+
// as they're needed for compliance checks. The agent runs with user privileges only.
1721
func (*Agent) executeCommandWithPipes(ctx context.Context, checkName, command string) types.Check {
1822
start := time.Now()
1923
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
@@ -24,6 +28,7 @@ func (*Agent) executeCommandWithPipes(ctx context.Context, checkName, command st
2428
}
2529

2630
// Use bash -r for restricted mode security
31+
// Security: This is as safe as we can make arbitrary command execution
2732
cmd := exec.CommandContext(ctx, "bash", "-r", "-c", command)
2833

2934
// Capture stdout

cmd/server/main.go

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33

44
import (
55
"context"
6+
"crypto/subtle"
67
"embed"
78
"encoding/json"
89
"flag"
@@ -158,11 +159,12 @@ func main() {
158159
mux.HandleFunc("/health", server.handleHealth)
159160

160161
srv := &http.Server{
161-
Addr: ":" + *port,
162-
Handler: loggingMiddleware(mux),
163-
ReadTimeout: readTimeout,
164-
WriteTimeout: writeTimeout,
165-
IdleTimeout: idleTimeout,
162+
Addr: ":" + *port,
163+
Handler: loggingMiddleware(mux),
164+
ReadTimeout: readTimeout,
165+
WriteTimeout: writeTimeout,
166+
IdleTimeout: idleTimeout,
167+
MaxHeaderBytes: 1 << 16, // 64KB max header size
166168
}
167169

168170
go func() {
@@ -317,10 +319,8 @@ func (s *Server) handleReport(writer http.ResponseWriter, request *http.Request)
317319
// Security: Check API key if configured
318320
if *apiKey != "" {
319321
providedKey := request.Header.Get("X-API-Key")
320-
if providedKey == "" {
321-
providedKey = request.URL.Query().Get("api_key")
322-
}
323-
if providedKey != *apiKey {
322+
// Security: Don't accept API key from query params (exposes in logs)
323+
if !constantTimeCompare(providedKey, *apiKey) {
324324
s.incrementErrorCount()
325325
log.Printf("[WARN] Unauthorized request from %s", request.RemoteAddr)
326326
http.Error(writer, "Unauthorized", http.StatusUnauthorized)
@@ -343,10 +343,17 @@ func (s *Server) handleReport(writer http.ResponseWriter, request *http.Request)
343343
// Security: Validate input fields
344344
if report.HardwareID == "" || len(report.HardwareID) > maxFieldLength {
345345
s.incrementErrorCount()
346-
log.Printf("[WARN] Invalid Hardware ID from %s: %s", request.RemoteAddr, report.HardwareID)
346+
log.Printf("[WARN] Invalid Hardware ID from %s: length %d", request.RemoteAddr, len(report.HardwareID))
347347
http.Error(writer, "Invalid Hardware ID", http.StatusBadRequest)
348348
return
349349
}
350+
// Additional validation: only allow safe characters in hardware ID
351+
if !isValidHardwareID(report.HardwareID) {
352+
s.incrementErrorCount()
353+
log.Printf("[WARN] Invalid Hardware ID format from %s", request.RemoteAddr)
354+
http.Error(writer, "Invalid Hardware ID format", http.StatusBadRequest)
355+
return
356+
}
350357
if len(report.Hostname) > maxFieldLength {
351358
s.incrementErrorCount()
352359
log.Printf("[WARN] Hostname too long from %s: %d bytes", request.RemoteAddr, len(report.Hostname))
@@ -364,10 +371,17 @@ func (s *Server) handleReport(writer http.ResponseWriter, request *http.Request)
364371
for name, check := range report.Checks {
365372
if len(name) > maxCheckName {
366373
s.incrementErrorCount()
367-
log.Printf("[WARN] Check name too long from %s: %s (%d bytes)", request.RemoteAddr, name, len(name))
374+
log.Printf("[WARN] Check name too long from %s: %d bytes", request.RemoteAddr, len(name))
368375
http.Error(writer, "Check name too long", http.StatusBadRequest)
369376
return
370377
}
378+
// Additional validation: only allow safe characters in check names
379+
if !isValidCheckName(name) {
380+
s.incrementErrorCount()
381+
log.Printf("[WARN] Invalid check name format from %s", request.RemoteAddr)
382+
http.Error(writer, "Invalid check name format", http.StatusBadRequest)
383+
return
384+
}
371385
// Check combined output size (stdout + stderr)
372386
totalOutput := len(check.Stdout) + len(check.Stderr)
373387
if totalOutput > maxCheckOutput {
@@ -497,9 +511,11 @@ func (s *Server) handleHealth(writer http.ResponseWriter, _ *http.Request) {
497511

498512
func loggingMiddleware(next http.Handler) http.Handler {
499513
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
500-
// Security: Add security headers
514+
// Security: Add comprehensive security headers
501515
w.Header().Set("X-Content-Type-Options", "nosniff")
502516
w.Header().Set("X-Frame-Options", "DENY")
517+
w.Header().Set("X-XSS-Protection", "1; mode=block")
518+
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
503519
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
504520

505521
start := time.Now()
@@ -539,3 +555,36 @@ func (s *Server) setHealthy(healthy bool) {
539555
log.Printf("[INFO] Server health status changed to healthy")
540556
}
541557
}
558+
559+
// constantTimeCompare performs constant-time string comparison to prevent timing attacks.
560+
func constantTimeCompare(a, b string) bool {
561+
return len(a) == len(b) && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
562+
}
563+
564+
// isValidHardwareID validates that a hardware ID contains only safe characters.
565+
func isValidHardwareID(id string) bool {
566+
// Allow alphanumeric, hyphens, underscores, and dots (common in UUIDs and machine IDs)
567+
for _, r := range id {
568+
if !((r >= 'a' && r <= 'z') ||
569+
(r >= 'A' && r <= 'Z') ||
570+
(r >= '0' && r <= '9') ||
571+
r == '-' || r == '_' || r == '.') {
572+
return false
573+
}
574+
}
575+
return len(id) > 0
576+
}
577+
578+
// isValidCheckName validates that a check name contains only safe characters.
579+
func isValidCheckName(name string) bool {
580+
// Allow alphanumeric, hyphens, and underscores only
581+
for _, r := range name {
582+
if !((r >= 'a' && r <= 'z') ||
583+
(r >= 'A' && r <= 'Z') ||
584+
(r >= '0' && r <= '9') ||
585+
r == '-' || r == '_') {
586+
return false
587+
}
588+
}
589+
return len(name) > 0
590+
}

internal/gitstore/store.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package gitstore
44
import (
55
"context"
66
"crypto/sha256"
7+
"encoding/hex"
78
"encoding/json"
89
"errors"
910
"fmt"
@@ -521,7 +522,7 @@ func isValidGitURL(url string) bool {
521522

522523
func sanitizeID(id string) string {
523524
// Security: Prevent path traversal and command injection
524-
// Use allowlist approach - only allow safe characters
525+
// Use strict allowlist approach - only allow safe characters
525526
var sanitized strings.Builder
526527
for _, r := range id {
527528
switch {
@@ -531,7 +532,7 @@ func sanitizeID(id string) string {
531532
sanitized.WriteRune(r)
532533
case r >= '0' && r <= '9':
533534
sanitized.WriteRune(r)
534-
case r == '-' || r == '_' || r == '.':
535+
case r == '-' || r == '_':
535536
sanitized.WriteRune(r)
536537
default:
537538
// Replace any other character with dash
@@ -541,16 +542,22 @@ func sanitizeID(id string) string {
541542

542543
result := sanitized.String()
543544

544-
// Remove leading/trailing dots and dashes
545-
result = strings.Trim(result, ".-")
545+
// Remove leading/trailing dashes and underscores
546+
result = strings.Trim(result, "-_")
547+
548+
// Security: Prevent directory traversal attempts
549+
result = strings.ReplaceAll(result, "..", "-")
550+
result = strings.ReplaceAll(result, "//", "-")
546551

547552
// Ensure ID is not empty after sanitization
548-
if result == "" {
549-
result = "unknown"
553+
if result == "" || result == "-" || result == "_" {
554+
// Use hash of original ID for better uniqueness
555+
hash := sha256.Sum256([]byte(id))
556+
result = "id-" + hex.EncodeToString(hash[:8])
550557
}
551558

552559
// Limit length to prevent filesystem issues
553-
const maxIDLength = 255
560+
const maxIDLength = 100
554561
if len(result) > maxIDLength {
555562
result = result[:maxIDLength]
556563
}

internal/gitstore/store_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ func TestSanitizeID(t *testing.T) {
2525
{"id:with:colons", "id-with-colons"},
2626
{"id with spaces", "id-with-spaces"},
2727
{"id<with>special*chars?", "id-with-special-chars"},
28-
{"", "unknown"},
29-
{"..", "unknown"},
30-
{"./", "unknown"},
31-
{strings.Repeat("a", 300), strings.Repeat("a", 255)},
28+
{"", "id-e3b0c44298fc1c14"}, // hash of empty string
29+
{"..", "id-5ec1f7e700f37c3d"}, // hash of ".."
30+
{"./", "id-c14cecec97312ad1"}, // hash of "./"
31+
{strings.Repeat("a", 300), strings.Repeat("a", 100)}, // max length is now 100
3232
}
3333

3434
for _, tt := range tests {

0 commit comments

Comments
 (0)