From 7c4203516d4e6c9e847a269ea69eb4d6f6f4a024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Thu, 7 May 2026 19:36:09 +0300 Subject: [PATCH 1/3] fix: sanitize API error responses to prevent internal details leaking Replace all raw http.Error(w, err.Error(), ...) calls with a new writeJSONError helper that: - Logs the full internal error internally - Returns only a safe public message to the client as JSON Affected handlers: ListAuditLogs, CreateZone, ListZones, ListRecordsForZone, CreateRecord, DeleteZone, DeleteRecord. Fixes #86. --- internal/adapters/api/handler.go | 43 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/internal/adapters/api/handler.go b/internal/adapters/api/handler.go index 7ff2b49..3a9df30 100644 --- a/internal/adapters/api/handler.go +++ b/internal/adapters/api/handler.go @@ -14,6 +14,17 @@ import ( const maxBodySize = 1 << 20 // 1MB +// writeJSONError logs the internal error and writes a safe JSON response to the client. +func (h *Handler) writeJSONError(w http.ResponseWriter, status int, publicMsg string, logErr error) { + if logErr != nil { + h.logger.Error("API error", "status", status, "error", logErr) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + resp := map[string]string{"error": publicMsg} + json.NewEncoder(w).Encode(resp) +} + // validateContentType checks if Content-Type is application/json. // Returns true if empty (backward compat) or exactly "application/json". func validateContentType(r *http.Request) bool { @@ -146,7 +157,7 @@ func (h *Handler) ListAuditLogs(w http.ResponseWriter, r *http.Request) { logs, err := h.svc.ListAuditLogs(r.Context(), tenantID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + h.writeJSONError(w, http.StatusInternalServerError, "An internal error occurred", err) return } @@ -164,12 +175,12 @@ func (h *Handler) CreateZone(w http.ResponseWriter, r *http.Request) { return } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodySize)).Decode(&zone); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + h.writeJSONError(w, http.StatusBadRequest, "Invalid request body", err) return } if err := domain.ValidateZoneName(zone.Name); err != nil { - http.Error(w, "Invalid zone name: "+err.Error(), http.StatusBadRequest) + h.writeJSONError(w, http.StatusBadRequest, "Invalid zone name", err) return } @@ -177,13 +188,13 @@ func (h *Handler) CreateZone(w http.ResponseWriter, r *http.Request) { tenantID, ok := r.Context().Value(CtxTenantID).(string) if !ok || tenantID == "" { h.logger.Warn("CreateZone: missing or invalid tenant ID in context") - http.Error(w, "Unauthorized: missing tenant context", http.StatusUnauthorized) + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) return } zone.TenantID = tenantID if err := domain.ValidateZoneRole(zone.Role, zone.MasterServer); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + h.writeJSONError(w, http.StatusBadRequest, "Invalid zone configuration", err) return } if zone.Role == "" { @@ -191,7 +202,7 @@ func (h *Handler) CreateZone(w http.ResponseWriter, r *http.Request) { } if err := h.svc.CreateZone(r.Context(), &zone); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + h.writeJSONError(w, http.StatusInternalServerError, "An internal error occurred", err) return } @@ -213,7 +224,7 @@ func (h *Handler) ListZones(w http.ResponseWriter, r *http.Request) { zones, err := h.svc.ListZones(r.Context(), tenantID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + h.writeJSONError(w, http.StatusInternalServerError, "An internal error occurred", err) return } @@ -236,7 +247,7 @@ func (h *Handler) ListRecordsForZone(w http.ResponseWriter, r *http.Request) { records, err := h.svc.ListRecordsForZone(r.Context(), zoneID, tenantID) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + h.writeJSONError(w, http.StatusInternalServerError, "An internal error occurred", err) return } @@ -255,12 +266,12 @@ func (h *Handler) CreateRecord(w http.ResponseWriter, r *http.Request) { return } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodySize)).Decode(&record); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + h.writeJSONError(w, http.StatusBadRequest, "Invalid request body", err) return } if err := domain.ValidateRecord(&record); err != nil { - http.Error(w, "Invalid record: "+err.Error(), http.StatusBadRequest) + h.writeJSONError(w, http.StatusBadRequest, "Invalid record", err) return } @@ -269,13 +280,13 @@ func (h *Handler) CreateRecord(w http.ResponseWriter, r *http.Request) { tenantID, ok := r.Context().Value(CtxTenantID).(string) if !ok || tenantID == "" { h.logger.Warn("CreateRecord: missing or invalid tenant ID in context") - http.Error(w, "Unauthorized: missing tenant context", http.StatusUnauthorized) + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) return } record.TenantID = tenantID if err := h.svc.CreateRecord(r.Context(), &record); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + h.writeJSONError(w, http.StatusInternalServerError, "An internal error occurred", err) return } @@ -292,12 +303,12 @@ func (h *Handler) DeleteZone(w http.ResponseWriter, r *http.Request) { tenantID, ok := r.Context().Value(CtxTenantID).(string) if !ok || tenantID == "" { h.logger.Warn("DeleteZone: missing or invalid tenant ID in context") - http.Error(w, "Unauthorized: missing tenant context", http.StatusUnauthorized) + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) return } if err := h.svc.DeleteZone(r.Context(), id, tenantID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + h.writeJSONError(w, http.StatusInternalServerError, "An internal error occurred", err) return } @@ -312,12 +323,12 @@ func (h *Handler) DeleteRecord(w http.ResponseWriter, r *http.Request) { tenantID, ok := r.Context().Value(CtxTenantID).(string) if !ok || tenantID == "" { h.logger.Warn("DeleteRecord: missing or invalid tenant ID in context") - http.Error(w, "Unauthorized: missing tenant context", http.StatusUnauthorized) + h.writeJSONError(w, http.StatusUnauthorized, "Unauthorized: missing tenant context", nil) return } if err := h.svc.DeleteRecord(r.Context(), id, zoneID, tenantID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + h.writeJSONError(w, http.StatusInternalServerError, "An internal error occurred", err) return } From 574e150201414c634a230c04faa4251f345886b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Thu, 7 May 2026 19:43:25 +0300 Subject: [PATCH 2/3] ci: retrigger lint From 7216f25aef306566c4a2b2774f4ef0942d046eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Thu, 7 May 2026 19:50:44 +0300 Subject: [PATCH 3/3] fix: check error return from json encoder in writeJSONError The errcheck linter flagged that json.NewEncoder(w).Encode(resp) return value was not checked in writeJSONError. This could silently swallow encoding errors. Now logs the encoding failure internally. Fixes: #86 --- internal/adapters/api/handler.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/adapters/api/handler.go b/internal/adapters/api/handler.go index 3a9df30..f92e0b6 100644 --- a/internal/adapters/api/handler.go +++ b/internal/adapters/api/handler.go @@ -22,7 +22,9 @@ func (h *Handler) writeJSONError(w http.ResponseWriter, status int, publicMsg st w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) resp := map[string]string{"error": publicMsg} - json.NewEncoder(w).Encode(resp) + if err := json.NewEncoder(w).Encode(resp); err != nil { + h.logger.Error("failed to encode error response", "error", err) + } } // validateContentType checks if Content-Type is application/json.