diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index 717b5e45..c3c92e6e 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/util" "github.com/google/uuid" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -144,11 +145,11 @@ func main() { for _, host := range proxyHosts { result := db.Where("domain_names = ?", host.DomainNames).FirstOrCreate(&host) if result.Error != nil { - logger.Log().WithField("host", host.DomainNames).WithError(result.Error).Error("Failed to seed proxy host") + logger.Log().WithField("host", util.SanitizeForLog(host.DomainNames)).WithError(result.Error).Error("Failed to seed proxy host") } else if result.RowsAffected > 0 { - logger.Log().WithField("host", host.DomainNames).Infof("✓ Created proxy host: %s -> %s://%s:%d", host.DomainNames, host.ForwardScheme, host.ForwardHost, host.ForwardPort) + logger.Log().WithField("host", util.SanitizeForLog(host.DomainNames)).Infof("✓ Created proxy host: %s -> %s://%s:%d", host.DomainNames, host.ForwardScheme, host.ForwardHost, host.ForwardPort) } else { - logger.Log().WithField("host", host.DomainNames).Info("Proxy host already exists") + logger.Log().WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Proxy host already exists") } } diff --git a/backend/internal/api/handlers/backup_handler.go b/backend/internal/api/handlers/backup_handler.go index c5a501d7..52af03d0 100644 --- a/backend/internal/api/handlers/backup_handler.go +++ b/backend/internal/api/handlers/backup_handler.go @@ -3,9 +3,11 @@ package handlers import ( "net/http" "os" + "path/filepath" "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" "github.com/gin-gonic/gin" ) @@ -33,7 +35,7 @@ func (h *BackupHandler) Create(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()}) return } - middleware.GetRequestLogger(c).WithField("action", "create_backup").WithField("filename", filename).Info("Backup created successfully") + middleware.GetRequestLogger(c).WithField("action", "create_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup created successfully") c.JSON(http.StatusCreated, gin.H{"filename": filename, "message": "Backup created successfully"}) } @@ -70,7 +72,7 @@ func (h *BackupHandler) Download(c *gin.Context) { func (h *BackupHandler) Restore(c *gin.Context) { filename := c.Param("filename") if err := h.service.RestoreBackup(filename); err != nil { - middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", filename).WithError(err).Error("Failed to restore backup") + middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).WithError(err).Error("Failed to restore backup") if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"}) return @@ -78,7 +80,7 @@ func (h *BackupHandler) Restore(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()}) return } - middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", filename).Info("Backup restored successfully") + middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup restored successfully") // In a real scenario, we might want to trigger a restart here c.JSON(http.StatusOK, gin.H{"message": "Backup restored successfully. Please restart the container."}) } diff --git a/backend/internal/api/handlers/backup_handler_sanitize_test.go b/backend/internal/api/handlers/backup_handler_sanitize_test.go new file mode 100644 index 00000000..28c8d09f --- /dev/null +++ b/backend/internal/api/handlers/backup_handler_sanitize_test.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "strings" + "os" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" +) + +func TestBackupHandlerSanitizesFilename(t *testing.T) { + gin.SetMode(gin.TestMode) + tmpDir := t.TempDir() + // prepare a fake "database" + dbPath := filepath.Join(tmpDir, "db.sqlite") + if err := os.WriteFile(dbPath, []byte("db"), 0o644); err != nil { + t.Fatalf("failed to create tmp db: %v", err) + } + + svc := &services.BackupService{DataDir: tmpDir, BackupDir: tmpDir, DatabaseName: "db.sqlite", Cron: nil} + h := NewBackupHandler(svc) + + router := gin.New() + router.GET("/backups/:filename/restore", h.Restore) + + // initialize logger to buffer + buf := &bytes.Buffer{} + logger.Init(true, buf) + + // Create a malicious filename with newline and path components + malicious := "../evil\nname" + // Use path escape to send as URL + req := httptest.NewRequest(http.MethodPost, "/backups/"+strings.ReplaceAll(malicious, "\n", "%0A")+"/restore", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + out := buf.String() + if strings.Contains(out, "\n") { + t.Fatalf("log contained raw newline in filename: %s", out) + } + if strings.Contains(out, "..") { + t.Fatalf("log contained path traversals in filename: %s", out) + } +} diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go index cd27f4b5..0d48d046 100644 --- a/backend/internal/api/handlers/certificate_handler.go +++ b/backend/internal/api/handlers/certificate_handler.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" ) type CertificateHandler struct { @@ -95,10 +96,10 @@ func (h *CertificateHandler) Upload(c *gin.Context) { h.notificationService.SendExternal(c.Request.Context(), "cert", "Certificate Uploaded", - fmt.Sprintf("Certificate %s uploaded", cert.Name), + fmt.Sprintf("Certificate %s uploaded", util.SanitizeForLog(cert.Name)), map[string]interface{}{ - "Name": cert.Name, - "Domains": cert.Domains, + "Name": util.SanitizeForLog(cert.Name), + "Domains": util.SanitizeForLog(cert.Domains), "Action": "uploaded", }, ) diff --git a/backend/internal/api/handlers/domain_handler.go b/backend/internal/api/handlers/domain_handler.go index 9c7e2da6..3b201237 100644 --- a/backend/internal/api/handlers/domain_handler.go +++ b/backend/internal/api/handlers/domain_handler.go @@ -8,6 +8,7 @@ import ( "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/util" ) type DomainHandler struct { @@ -55,9 +56,9 @@ func (h *DomainHandler) Create(c *gin.Context) { h.notificationService.SendExternal(c.Request.Context(), "domain", "Domain Added", - fmt.Sprintf("Domain %s added", domain.Name), + fmt.Sprintf("Domain %s added", util.SanitizeForLog(domain.Name)), map[string]interface{}{ - "Name": domain.Name, + "Name": util.SanitizeForLog(domain.Name), "Action": "created", }, ) @@ -75,9 +76,9 @@ func (h *DomainHandler) Delete(c *gin.Context) { h.notificationService.SendExternal(c.Request.Context(), "domain", "Domain Deleted", - fmt.Sprintf("Domain %s deleted", domain.Name), + fmt.Sprintf("Domain %s deleted", util.SanitizeForLog(domain.Name)), map[string]interface{}{ - "Name": domain.Name, + "Name": util.SanitizeForLog(domain.Name), "Action": "deleted", }, ) diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go index 72f4b0cc..df789cc6 100644 --- a/backend/internal/api/handlers/import_handler.go +++ b/backend/internal/api/handlers/import_handler.go @@ -258,7 +258,7 @@ func (h *ImportHandler) Upload(c *gin.Context) { return } - middleware.GetRequestLogger(c).WithField("filename", req.Filename).WithField("content_len", len(req.Content)).Info("Import Upload: received upload") + middleware.GetRequestLogger(c).WithField("filename", util.SanitizeForLog(filepath.Base(req.Filename))).WithField("content_len", len(req.Content)).Info("Import Upload: received upload") // Save upload to import/uploads/.caddyfile and return transient preview (do not persist yet) sid := uuid.NewString() @@ -277,7 +277,7 @@ func (h *ImportHandler) Upload(c *gin.Context) { return } if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil { - middleware.GetRequestLogger(c).WithField("tempPath", tempPath).WithError(err).Error("Import Upload: failed to write temp file") + middleware.GetRequestLogger(c).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithError(err).Error("Import Upload: failed to write temp file") c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"}) return } @@ -294,7 +294,7 @@ func (h *ImportHandler) Upload(c *gin.Context) { preview = string(b) } } - middleware.GetRequestLogger(c).WithError(err).WithField("tempPath", tempPath).WithField("content_preview", util.SanitizeForLog(preview)).Error("Import Upload: import failed") + middleware.GetRequestLogger(c).WithError(err).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithField("content_preview", util.SanitizeForLog(preview)).Error("Import Upload: import failed") c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)}) return } @@ -303,7 +303,11 @@ func (h *ImportHandler) Upload(c *gin.Context) { if len(result.Hosts) == 0 { imports := detectImportDirectives(req.Content) if len(imports) > 0 { - middleware.GetRequestLogger(c).WithField("imports", imports).Warn("Import Upload: no hosts parsed but imports detected") + sanitizedImports := make([]string, 0, len(imports)) + for _, imp := range imports { + sanitizedImports = append(sanitizedImports, util.SanitizeForLog(filepath.Base(imp))) + } + middleware.GetRequestLogger(c).WithField("imports", sanitizedImports).Warn("Import Upload: no hosts parsed but imports detected") } else { middleware.GetRequestLogger(c).WithField("content_len", len(req.Content)).Warn("Import Upload: no hosts parsed and no imports detected") } @@ -463,7 +467,7 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) { preview = string(b) } } - middleware.GetRequestLogger(c).WithError(err).WithField("mainCaddyfile", mainCaddyfile).WithField("preview", util.SanitizeForLog(preview)).Error("Import UploadMulti: import failed") + middleware.GetRequestLogger(c).WithError(err).WithField("mainCaddyfile", util.SanitizeForLog(filepath.Base(mainCaddyfile))).WithField("preview", util.SanitizeForLog(preview)).Error("Import UploadMulti: import failed") c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)}) return } @@ -666,10 +670,10 @@ func (h *ImportHandler) Commit(c *gin.Context) { if err := h.proxyHostSvc.Update(&host); err != nil { errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error()) errors = append(errors, errMsg) - middleware.GetRequestLogger(c).WithField("host", host.DomainNames).WithField("error", sanitizeForLog(errMsg)).Error("Import Commit Error (update)") + middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", sanitizeForLog(errMsg)).Error("Import Commit Error (update)") } else { updated++ - middleware.GetRequestLogger(c).WithField("host", sanitizeForLog(host.DomainNames)).Info("Import Commit Success: Updated host") + middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Updated host") } continue } diff --git a/backend/internal/api/handlers/import_handler_sanitize_test.go b/backend/internal/api/handlers/import_handler_sanitize_test.go new file mode 100644 index 00000000..991bfba9 --- /dev/null +++ b/backend/internal/api/handlers/import_handler_sanitize_test.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "encoding/json" +) + +func TestImportUploadSanitizesFilename(t *testing.T) { + gin.SetMode(gin.TestMode) + tmpDir := t.TempDir() + // set up in-memory DB for handler + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open in-memory db: %v", err) + } + svc := NewImportHandler(db, "/usr/bin/caddy", tmpDir, "") + + router := gin.New() + router.POST("/import/upload", svc.Upload) + + buf := &bytes.Buffer{} + logger.Init(true, buf) + + maliciousFilename := "../evil\nfile.caddy" + payload := map[string]interface{}{"filename": maliciousFilename, "content": "site { respond \"ok\" }"} + bodyBytes, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/import/upload", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + out := buf.String() + if strings.Contains(out, "\n") { + t.Fatalf("log contained raw newline in filename: %s", out) + } + if strings.Contains(out, "..") { + t.Fatalf("log contained path traversal in filename: %s", out) + } +} diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index ad3b003b..81abb9f2 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -14,6 +14,7 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" ) // ProxyHostHandler handles CRUD operations for proxy hosts. @@ -110,10 +111,10 @@ func (h *ProxyHostHandler) Create(c *gin.Context) { h.notificationService.SendExternal(c.Request.Context(), "proxy_host", "Proxy Host Created", - fmt.Sprintf("Proxy Host %s (%s) created", host.Name, host.DomainNames), + fmt.Sprintf("Proxy Host %s (%s) created", util.SanitizeForLog(host.Name), util.SanitizeForLog(host.DomainNames)), map[string]interface{}{ - "Name": host.Name, - "Domains": host.DomainNames, + "Name": util.SanitizeForLog(host.Name), + "Domains": util.SanitizeForLog(host.DomainNames), "Action": "created", }, ) diff --git a/backend/internal/api/handlers/remote_server_handler.go b/backend/internal/api/handlers/remote_server_handler.go index a3dd0f43..b8e95d9c 100644 --- a/backend/internal/api/handlers/remote_server_handler.go +++ b/backend/internal/api/handlers/remote_server_handler.go @@ -11,6 +11,7 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" + "github.com/Wikid82/charon/backend/internal/util" ) // RemoteServerHandler handles HTTP requests for remote server management. @@ -71,10 +72,10 @@ func (h *RemoteServerHandler) Create(c *gin.Context) { h.notificationService.SendExternal(c.Request.Context(), "remote_server", "Remote Server Added", - fmt.Sprintf("Remote Server %s (%s:%d) added", server.Name, server.Host, server.Port), + fmt.Sprintf("Remote Server %s (%s:%d) added", util.SanitizeForLog(server.Name), util.SanitizeForLog(server.Host), server.Port), map[string]interface{}{ - "Name": server.Name, - "Host": server.Host, + "Name": util.SanitizeForLog(server.Name), + "Host": util.SanitizeForLog(server.Host), "Port": server.Port, "Action": "created", }, @@ -140,9 +141,9 @@ func (h *RemoteServerHandler) Delete(c *gin.Context) { h.notificationService.SendExternal(c.Request.Context(), "remote_server", "Remote Server Deleted", - fmt.Sprintf("Remote Server %s deleted", server.Name), + fmt.Sprintf("Remote Server %s deleted", util.SanitizeForLog(server.Name)), map[string]interface{}{ - "Name": server.Name, + "Name": util.SanitizeForLog(server.Name), "Action": "deleted", }, ) diff --git a/backend/internal/api/middleware/recovery.go b/backend/internal/api/middleware/recovery.go index badbb378..5783a508 100644 --- a/backend/internal/api/middleware/recovery.go +++ b/backend/internal/api/middleware/recovery.go @@ -18,8 +18,8 @@ func Recovery(verbose bool) gin.HandlerFunc { if verbose { entry.WithFields(map[string]interface{}{ "method": c.Request.Method, - "path": c.Request.URL.Path, - "headers": c.Request.Header, + "path": SanitizePath(c.Request.URL.Path), + "headers": SanitizeHeaders(c.Request.Header), }).Errorf("PANIC: %v\nStacktrace:\n%s", r, debug.Stack()) } else { entry.Errorf("PANIC: %v", r) diff --git a/backend/internal/api/middleware/recovery_test.go b/backend/internal/api/middleware/recovery_test.go index b2122931..bc1ac4bf 100644 --- a/backend/internal/api/middleware/recovery_test.go +++ b/backend/internal/api/middleware/recovery_test.go @@ -78,3 +78,38 @@ func TestRecoveryLogsBriefWhenNotVerbose(t *testing.T) { t.Fatalf("non-verbose log unexpectedly included stacktrace: %s", out) } } + +func TestRecoverySanitizesHeadersAndPath(t *testing.T) { + old := log.Writer() + buf := &bytes.Buffer{} + log.SetOutput(buf) + defer log.SetOutput(old) + + // Ensure structured logger writes to the same buffer and enable debug + logger.Init(true, buf) + + router := gin.New() + router.Use(RequestID()) + router.Use(Recovery(true)) + router.GET("/panic", func(c *gin.Context) { + panic("sensitive panic") + }) + + req := httptest.NewRequest(http.MethodGet, "/panic", nil) + // Add sensitive header that should be redacted + req.Header.Set("Authorization", "Bearer secret-token") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + + out := buf.String() + if strings.Contains(out, "secret-token") { + t.Fatalf("log contained sensitive token: %s", out) + } + if !strings.Contains(out, "") { + t.Fatalf("log did not include redaction marker: %s", out) + } +} diff --git a/backend/internal/api/middleware/request_logger.go b/backend/internal/api/middleware/request_logger.go index 2bf5b9c4..64f11bef 100644 --- a/backend/internal/api/middleware/request_logger.go +++ b/backend/internal/api/middleware/request_logger.go @@ -2,6 +2,7 @@ package middleware import ( "time" + "github.com/Wikid82/charon/backend/internal/util" "github.com/gin-gonic/gin" ) @@ -16,9 +17,9 @@ func RequestLogger() gin.HandlerFunc { entry.WithFields(map[string]interface{}{ "status": c.Writer.Status(), "method": c.Request.Method, - "path": c.Request.URL.Path, + "path": SanitizePath(c.Request.URL.Path), "latency": latency.String(), - "client": c.ClientIP(), + "client": util.SanitizeForLog(c.ClientIP()), }).Info("handled request") } } diff --git a/backend/internal/api/middleware/request_logger_test.go b/backend/internal/api/middleware/request_logger_test.go index 15c58b67..59352639 100644 --- a/backend/internal/api/middleware/request_logger_test.go +++ b/backend/internal/api/middleware/request_logger_test.go @@ -11,6 +11,42 @@ import ( "github.com/gin-gonic/gin" ) +func TestRequestLoggerSanitizesPath(t *testing.T) { + old := logger.Log() + buf := &bytes.Buffer{} + logger.Init(true, buf) + + longPath := "/" + strings.Repeat("a", 300) + + router := gin.New() + router.Use(RequestID()) + router.Use(RequestLogger()) + router.GET(longPath, func(c *gin.Context) { c.Status(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, longPath, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + out := buf.String() + if strings.Contains(out, strings.Repeat("a", 300)) { + t.Fatalf("logged unsanitized long path") + } + i := strings.Index(out, "path=") + if i == -1 { + t.Fatalf("could not find path in logs: %s", out) + } + sub := out[i:] + j := strings.Index(sub, " request_id=") + if j == -1 { + t.Fatalf("could not isolate path field from logs: %s", out) + } + pathField := sub[len("path=") : j] + if strings.Contains(pathField, "\n") || strings.Contains(pathField, "\r") { + t.Fatalf("path field contains control characters after sanitization: %s", pathField) + } + _ = old // silence unused var +} + func TestRequestLoggerIncludesRequestID(t *testing.T) { buf := &bytes.Buffer{} logger.Init(true, buf) diff --git a/backend/internal/api/middleware/sanitize.go b/backend/internal/api/middleware/sanitize.go new file mode 100644 index 00000000..beb7501b --- /dev/null +++ b/backend/internal/api/middleware/sanitize.go @@ -0,0 +1,62 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/Wikid82/charon/backend/internal/util" +) + +// SanitizeHeaders returns a map of header keys to redacted/sanitized values +// for safe logging. Sensitive headers are redacted; other values are +// sanitized using util.SanitizeForLog and truncated. +func SanitizeHeaders(h http.Header) map[string][]string { + if h == nil { + return nil + } + sensitive := map[string]struct{}{ + "authorization": {}, + "cookie": {}, + "set-cookie": {}, + "proxy-authorization": {}, + "x-api-key": {}, + "x-api-token": {}, + "x-access-token": {}, + "x-auth-token": {}, + "x-api-secret": {}, + "x-forwarded-for": {}, + } + out := make(map[string][]string, len(h)) + for k, vals := range h { + keyLower := strings.ToLower(k) + if _, ok := sensitive[keyLower]; ok { + out[k] = []string{""} + continue + } + sanitizedVals := make([]string, 0, len(vals)) + for _, v := range vals { + v2 := util.SanitizeForLog(v) + if len(v2) > 200 { + v2 = v2[:200] + } + sanitizedVals = append(sanitizedVals, v2) + } + out[k] = sanitizedVals + } + return out +} + +// SanitizePath prepares a request path for safe logging by removing +// control characters and truncating long values. It does not include +// query parameters. +func SanitizePath(p string) string { + // remove query string + if i := strings.Index(p, "?"); i != -1 { + p = p[:i] + } + p = util.SanitizeForLog(p) + if len(p) > 200 { + p = p[:200] + } + return p +} diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go index 10aa62b1..96a74d1f 100644 --- a/backend/internal/services/certificate_service.go +++ b/backend/internal/services/certificate_service.go @@ -5,6 +5,7 @@ import ( "encoding/pem" "fmt" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/util" "os" "path/filepath" "strings" @@ -63,22 +64,22 @@ func (s *CertificateService) SyncFromDisk() error { defer s.cacheMu.Unlock() certRoot := filepath.Join(s.dataDir, "certificates") - logger.Log().WithField("certRoot", certRoot).Info("CertificateService: scanning cert directory") + logger.Log().WithField("certRoot", util.SanitizeForLog(certRoot)).Info("CertificateService: scanning cert directory") foundDomains := map[string]struct{}{} // If the cert root does not exist, skip scanning but still return DB entries below if _, err := os.Stat(certRoot); err == nil { _ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error { - if err != nil { - logger.Log().WithField("path", path).WithError(err).Error("CertificateService: walk error") + if err != nil { + logger.Log().WithField("path", util.SanitizeForLog(path)).WithError(err).Error("CertificateService: walk error") return nil } if !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") { certData, err := os.ReadFile(path) - if err != nil { - logger.Log().WithField("path", path).WithError(err).Error("CertificateService: failed to read cert file") + if err != nil { + logger.Log().WithField("path", util.SanitizeForLog(path)).WithError(err).Error("CertificateService: failed to read cert file") return nil } @@ -89,8 +90,8 @@ func (s *CertificateService) SyncFromDisk() error { } cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - logger.Log().WithField("path", path).WithError(err).Error("CertificateService: failed to parse cert") + if err != nil { + logger.Log().WithField("path", util.SanitizeForLog(path)).WithError(err).Error("CertificateService: failed to parse cert") return nil } @@ -134,10 +135,10 @@ func (s *CertificateService) SyncFromDisk() error { UpdatedAt: now, } if err := s.db.Create(&newCert).Error; err != nil { - logger.Log().WithField("domain", domain).WithError(err).Error("CertificateService: failed to create DB cert") + logger.Log().WithField("domain", util.SanitizeForLog(domain)).WithError(err).Error("CertificateService: failed to create DB cert") } } else { - logger.Log().WithField("domain", domain).WithError(res.Error).Error("CertificateService: db error querying cert") + logger.Log().WithField("domain", util.SanitizeForLog(domain)).WithError(res.Error).Error("CertificateService: db error querying cert") } } else { // Update expiry/certificate content and provider if changed @@ -168,13 +169,13 @@ func (s *CertificateService) SyncFromDisk() error { } if updated { existing.UpdatedAt = time.Now() - if err := s.db.Save(&existing).Error; err != nil { - logger.Log().WithField("domain", domain).WithError(err).Error("CertificateService: failed to update DB cert") + if err := s.db.Save(&existing).Error; err != nil { + logger.Log().WithField("domain", util.SanitizeForLog(domain)).WithError(err).Error("CertificateService: failed to update DB cert") } } else { // still update ExpiresAt if needed - if err := s.db.Model(&existing).Update("expires_at", &expiresAt).Error; err != nil { - logger.Log().WithField("domain", domain).WithError(err).Error("CertificateService: failed to update expiry") + if err := s.db.Model(&existing).Update("expires_at", &expiresAt).Error; err != nil { + logger.Log().WithField("domain", util.SanitizeForLog(domain)).WithError(err).Error("CertificateService: failed to update expiry") } } } @@ -196,9 +197,9 @@ func (s *CertificateService) SyncFromDisk() error { if _, ok := foundDomains[c.Domains]; !ok { // remove stale record if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", c.ID).Error; err != nil { - logger.Log().WithField("domain", c.Domains).WithError(err).Error("CertificateService: failed to delete stale cert") + logger.Log().WithField("domain", util.SanitizeForLog(c.Domains)).WithError(err).Error("CertificateService: failed to delete stale cert") } else { - logger.Log().WithField("domain", c.Domains).Info("CertificateService: removed stale DB cert") + logger.Log().WithField("domain", util.SanitizeForLog(c.Domains)).Info("CertificateService: removed stale DB cert") } } } diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 693aeb39..27455c67 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -18,6 +18,7 @@ import ( "github.com/Wikid82/charon/backend/internal/models" "github.com/containrrr/shoutrrr" "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/util" ) type NotificationService struct { @@ -119,23 +120,23 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title } go func(p models.NotificationProvider) { - if p.Type == "webhook" { + if p.Type == "webhook" { if err := s.sendCustomWebhook(ctx, p, data); err != nil { - logger.Log().WithError(err).WithField("provider", p.Name).Error("Failed to send webhook") + logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send webhook") } } else { url := normalizeURL(p.Type, p.URL) // Validate HTTP/HTTPS destinations used by shoutrrr to reduce SSRF risk if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { if _, err := validateWebhookURL(url); err != nil { - logger.Log().WithField("provider", p.Name).Warn("Skipping notification for provider due to invalid destination") + logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).Warn("Skipping notification for provider due to invalid destination") return } } // Use newline for better formatting in chat apps msg := fmt.Sprintf("%s\n\n%s", title, message) - if err := shoutrrr.Send(url, msg); err != nil { - logger.Log().WithError(err).WithField("provider", p.Name).Error("Failed to send notification") + if err := shoutrrr.Send(url, msg); err != nil { + logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(p.Name)).Error("Failed to send notification") } } }(provider) diff --git a/backend/internal/services/uptime_service.go b/backend/internal/services/uptime_service.go index 46f38806..564a884d 100644 --- a/backend/internal/services/uptime_service.go +++ b/backend/internal/services/uptime_service.go @@ -13,6 +13,7 @@ import ( "time" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/util" "gorm.io/gorm" ) @@ -289,10 +290,10 @@ func (s *UptimeService) ensureUptimeHost(host, defaultName string) string { Status: "pending", } if err := s.DB.Create(&uptimeHost).Error; err != nil { - logger.Log().WithError(err).WithField("host", host).Error("Failed to create UptimeHost") + logger.Log().WithError(err).WithField("host", util.SanitizeForLog(host)).Error("Failed to create UptimeHost") return "" } - logger.Log().WithField("host", host).Info("Created UptimeHost") + logger.Log().WithField("host_id", uptimeHost.ID).WithField("host", util.SanitizeForLog(uptimeHost.Host)).Info("Created UptimeHost") } return uptimeHost.ID @@ -667,7 +668,7 @@ func (s *UptimeService) queueDownNotification(monitor models.UptimeMonitor, reas if pending, exists := s.pendingNotifications[hostID]; exists { // Add to existing batch pending.downMonitors = append(pending.downMonitors, info) - logger.Log().WithField("monitor", monitor.Name).WithField("host", hostName).WithField("count", len(pending.downMonitors)).Info("Added to pending notification batch") + logger.Log().WithField("monitor", util.SanitizeForLog(monitor.Name)).WithField("host", util.SanitizeForLog(hostName)).WithField("count", len(pending.downMonitors)).Info("Added to pending notification batch") } else { // Create new batch with timer pending := &pendingHostNotification{ @@ -682,7 +683,7 @@ func (s *UptimeService) queueDownNotification(monitor models.UptimeMonitor, reas }) s.pendingNotifications[hostID] = pending - logger.Log().WithField("host", hostName).WithField("monitor", monitor.Name).Info("Created pending notification batch") + logger.Log().WithField("host", util.SanitizeForLog(hostName)).WithField("monitor", util.SanitizeForLog(monitor.Name)).Info("Created pending notification batch") } } @@ -754,7 +755,7 @@ func (s *UptimeService) flushPendingNotification(hostID string) { } s.NotificationService.SendExternal(context.Background(), "uptime", title, sb.String(), data) - logger.Log().WithField("count", len(pending.downMonitors)).WithField("host", pending.hostName).Info("Sent batched DOWN notification") + logger.Log().WithField("count", len(pending.downMonitors)).WithField("host", util.SanitizeForLog(pending.hostName)).Info("Sent batched DOWN notification") } // sendRecoveryNotification sends a notification when a service recovers diff --git a/go.work.sum b/go.work.sum index 22fb34c2..15f725da 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,32 +1,61 @@ cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0= +cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso= +github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk= +github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU= +github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=