// Package responses provides structured API response types with security-conscious error handling. package responses import ( "encoding/json" "fmt" "net/http" "regexp" "strings" "time" "github.com/jfraeys/fetch_ml/internal/logging" ) // ErrorResponse provides a sanitized error response to clients. // It includes a trace ID for support lookup while preventing information leakage. type ErrorResponse struct { Error string `json:"error"` // Sanitized error message for clients Code string `json:"code"` // Machine-readable error code TraceID string `json:"trace_id"` // For support lookup (internal correlation) } // Error codes for machine-readable error identification const ( ErrCodeBadRequest = "BAD_REQUEST" ErrCodeUnauthorized = "UNAUTHORIZED" ErrCodeForbidden = "FORBIDDEN" ErrCodeNotFound = "NOT_FOUND" ErrCodeConflict = "CONFLICT" ErrCodeRateLimited = "RATE_LIMITED" ErrCodeInternal = "INTERNAL_ERROR" ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE" ErrCodeValidation = "VALIDATION_ERROR" ) // HTTP status to error code mapping var statusToCode = map[int]string{ http.StatusBadRequest: ErrCodeBadRequest, http.StatusUnauthorized: ErrCodeUnauthorized, http.StatusForbidden: ErrCodeForbidden, http.StatusNotFound: ErrCodeNotFound, http.StatusConflict: ErrCodeConflict, http.StatusTooManyRequests: ErrCodeRateLimited, http.StatusInternalServerError: ErrCodeInternal, http.StatusServiceUnavailable: ErrCodeServiceUnavailable, 422: ErrCodeValidation, // Unprocessable Entity } // Patterns to sanitize from error messages (security: prevent information leakage) var ( // Remove file paths pathPattern = regexp.MustCompile(`/[^\s]*`) // Remove sensitive keywords sensitiveKeywords = []string{"password", "secret", "token", "key", "credential", "auth"} ) // WriteError writes a sanitized error response to the client. // It extracts the trace ID from the context, logs the full error internally, // and returns a sanitized message to the client. func WriteError(w http.ResponseWriter, r *http.Request, status int, err error, logger *logging.Logger) { traceID := logging.TraceIDFromContext(r.Context()) if traceID == "" { traceID = generateTraceID() } // Log the full error internally with all details if logger != nil { logger.Error("request failed", "trace_id", traceID, "method", r.Method, "path", r.URL.Path, "status", status, "error", err.Error(), "client_ip", getClientIP(r), ) } // Build sanitized response resp := ErrorResponse{ Error: sanitizeError(err.Error()), Code: errorCodeFromStatus(status), TraceID: traceID, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(resp) } // WriteErrorMessage writes a sanitized error response with a custom message. func WriteErrorMessage(w http.ResponseWriter, r *http.Request, status int, message string, logger *logging.Logger) { traceID := logging.TraceIDFromContext(r.Context()) if traceID == "" { traceID = generateTraceID() } if logger != nil { logger.Error("request failed", "trace_id", traceID, "method", r.Method, "path", r.URL.Path, "status", status, "error", message, ) } resp := ErrorResponse{ Error: sanitizeError(message), Code: errorCodeFromStatus(status), TraceID: traceID, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(resp) } // sanitizeError removes potentially sensitive information from error messages. // It prevents information leakage to clients while preserving useful context. func sanitizeError(msg string) string { if msg == "" { return "An error occurred" } // Remove file paths msg = pathPattern.ReplaceAllString(msg, "[path]") // Remove sensitive keywords and their values lowerMsg := strings.ToLower(msg) for _, keyword := range sensitiveKeywords { if strings.Contains(lowerMsg, keyword) { return "An error occurred" } } // Remove internal error details msg = strings.ReplaceAll(msg, "internal error", "an error occurred") msg = strings.ReplaceAll(msg, "Internal Error", "an error occurred") // Truncate if too long if len(msg) > 200 { msg = msg[:200] + "..." } return msg } // errorCodeFromStatus returns the appropriate error code for an HTTP status. func errorCodeFromStatus(status int) string { if code, ok := statusToCode[status]; ok { return code } return ErrCodeInternal } // getClientIP extracts the client IP from the request. func getClientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if idx := strings.Index(xff, ","); idx != -1 { return strings.TrimSpace(xff[:idx]) } return strings.TrimSpace(xff) } if xri := r.Header.Get("X-Real-IP"); xri != "" { return strings.TrimSpace(xri) } return r.RemoteAddr } // generateTraceID generates a new trace ID when one isn't in context. func generateTraceID() string { return fmt.Sprintf("%d", time.Now().UnixNano()) }