Go Revisión de Código — PR #47

DataPulse Analytics · feat: añadir endpoint de exportación de reportes por usuario
Veredicto
⛔ BLOQUEADO
Repositorio datapulse/backend
Rama feat/report-export
Archivos 3 .go modificados
Líneas revisadas +284 / -12
Revisor revisor-codigo-go v1.0.0
Fecha 2026-06-18
3
Críticos
4
Altos
5
Medios
12
Total hallazgos
Salida de herramientas diagnósticas
$ go vet ./...
internal/handlers/reports.go:89:2: printf-like function with non-constant format string
internal/services/export.go:44:13: unreachable code after return statement
internal/db/queries.go:31:1: exported function BuildUserQuery should have comment
$ staticcheck ./...
internal/handlers/reports.go:112: SA1006 Printf with dynamic first argument and no further arguments
internal/services/export.go:67: SA4006 variable 'err' is set but never used
internal/db/queries.go:55: S1039 unnecessary use of fmt.Sprintf
$ go build -race ./...
✔ Compilación correcta (race detector habilitado)
WARNING: no test coverage for new files (0%)
Hallazgos críticos — Seguridad
CRÍTICO Inyección SQL por concatenación de strings internal/db/queries.go:31

Se concatena directamente el userID proporcionado por el cliente en la query SQL. Un atacante puede manipular este parámetro para extraer o modificar datos de cualquier usuario de la plataforma.

✗ VULNERABLEqueries.go — línea 31
func BuildUserQuery(userID string, dateRange string) string {
    // PELIGRO: concatenación directa — SQL Injection
    query := "SELECT * FROM metrics WHERE user_id = '" + userID +
             "' AND created_at >= '" + dateRange + "'"
    return query
}
✔ Corrección recomendada
✓ SEGUROusar parámetros preparados
func GetUserMetrics(ctx context.Context, db *sql.DB,
    userID string, dateRange time.Time) ([]Metric, error) {
    // Parámetros preparados — nunca concatenar input externo
    rows, err := db.QueryContext(ctx,
        "SELECT id, value, created_at FROM metrics WHERE user_id = $1 AND created_at >= $2",
        userID, dateRange)
    if err != nil {
        return nil, fmt.Errorf("query user metrics: %w", err)
    }
    defer rows.Close()
    // ...
}
CRÍTICO Inyección de comandos en os/exec internal/services/export.go:67

El nombre de archivo generado incluye el userID sin sanitizar y se pasa directamente a exec.Command vía sh -c. Un atacante puede inyectar comandos arbitrarios en el sistema (; rm -rf /, exfiltración de datos, etc.).

✗ VULNERABLEexport.go — línea 67
func CompressReport(userID string, filePath string) error {
    // PELIGRO: sh -c con input no sanitizado
    cmd := exec.Command("sh", "-c",
        "zip report_"+userID+".zip "+filePath)
    return cmd.Run()
}
✔ Corrección recomendada
✓ SEGUROargumentos como slice, sin shell
func CompressReport(ctx context.Context, userID string, filePath string) error {
    // Validar que userID solo contiene caracteres seguros
    if !isValidUserID(userID) {
        return fmt.Errorf("invalid user id format")
    }
    // Argumentos como slice — nunca sh -c con input externo
    outputFile := filepath.Join("/tmp/reports", "report_"+userID+".zip")
    cmd := exec.CommandContext(ctx, "zip", outputFile, filePath)
    cmd.Stdin = nil
    return cmd.Run()
}
CRÍTICO Path Traversal sin validación de prefijo internal/handlers/reports.go:45

El handler acepta un parámetro output_dir del cliente y lo usa directamente para escribir archivos sin comprobar que la ruta resultante esté dentro del directorio permitido. Permite escritura arbitraria en el sistema de archivos del servidor.

✗ VULNERABLEreports.go — línea 45
outputPath := r.URL.Query().Get("output_dir")
filePath := filepath.Join(outputPath, userID+".csv")
// filepath.Join no previene "../../../etc/passwd"
f, err := os.Create(filePath)
✔ Corrección recomendada
✓ SEGUROfilepath.Clean + verificación de prefijo
const allowedBase = "/var/datapulse/exports"

clean := filepath.Clean(filepath.Join(allowedBase, userID+".csv"))
if !strings.HasPrefix(clean, allowedBase+"/") {
    http.Error(w, "forbidden path", http.StatusForbidden)
    return
}
f, err := os.Create(clean)
Hallazgos altos — Manejo de errores & Concurrencia
ALTO Error descartado con _ en operación crítica internal/services/export.go:89

El error de rows.Close() y de file.Close() se descarta silenciosamente. Si la escritura en disco falla (espacio lleno, permisos) el cliente recibe un CSV corrupto sin ningún aviso.

✗ PROBLEMA
_ = rows.Close()   // error silenciado
_ = file.Close()   // error silenciado
✔ Corrección recomendada
✓ CORRECTO
if err := rows.Close(); err != nil {
    return fmt.Errorf("close rows: %w", err)
}
if err := file.Close(); err != nil {
    return fmt.Errorf("close export file: %w", err)
}
ALTO Goroutine leak — falta context.Context para cancelación internal/handlers/reports.go:112

Se lanza una goroutine para la compresión asíncrona sin ningún mecanismo de cancelación. Si el cliente desconecta o el servidor se reinicia, la goroutine queda activa indefinidamente consumiendo CPU y memoria.

✗ LEAK
go func() {
    CompressReport(userID, filePath) // sin contexto, sin WaitGroup
}()
✔ Corrección recomendada
✓ CON CONTEXTO
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()

if err := CompressReport(ctx, userID, filePath); err != nil {
    log.ErrorContext(ctx, "compress report", "error", err)
    http.Error(w, "internal server error", http.StatusInternalServerError)
    return
}
ALTO Errores sin contexto — return err sin fmt.Errorf internal/services/export.go (múltiples líneas)

12 instancias de return err sin envolver con fmt.Errorf("contexto: %w", err). En producción los logs muestran errores crípticos sin traza de qué operación falló, dificultando el debugging.

✗ SIN CONTEXTO
return err  // ¿qué operación falló? imposible saberlo
✔ Corrección recomendada
✓ CON CONTEXTO
return fmt.Errorf("write csv row for user %s: %w", userID, err)
ALTO Mutex sin defer — riesgo de deadlock internal/services/export.go:134

El mutex se desbloquea manualmente al final de la función. Si se añade un return anticipado (por error o refactoring), el mutex queda bloqueado permanentemente causando un deadlock en todo el servicio de exportación.

✗ PELIGROSO
mu.Lock()
activeJobs[userID] = true
// ... lógica con posibles returns anticipados ...
mu.Unlock()  // podría no ejecutarse nunca
✔ Corrección recomendada
✓ CON DEFER
mu.Lock()
defer mu.Unlock()  // siempre se ejecuta al salir de la función
activeJobs[userID] = true
Hallazgos medios — Rendimiento & Calidad
MEDIO Concatenación de strings en bucle — usar strings.Builder internal/services/export.go:156

Se construye el CSV fila a fila concatenando strings (csvContent += row) en un bucle de potencialmente miles de iteraciones. Esto genera O(n²) allocations en heap. Para 10.000 filas, ~50x más lento que strings.Builder o csv.Writer.

MEDIO N+1 queries — consulta de metadata dentro del bucle internal/db/queries.go:88

Para cada fila de métricas se lanza una query adicional para obtener el nombre del evento. Con 1.000 filas = 1.001 roundtrips a PostgreSQL. Consolidar con un JOIN o un batch fetch.

MEDIO Función de 87 líneas — supera el límite recomendado de 50 internal/handlers/reports.go:40

ExportReportHandler mezcla validación, lógica de negocio, I/O y respuesta HTTP. Extraer en subfunciones: validateRequest, fetchMetrics, writeCSV, sendResponse.

MEDIO Parámetro context.Context no es el primero internal/services/export.go:22

func GenerateCSV(userID string, ctx context.Context, ...) — por convención Go, ctx siempre es el primer parámetro.

MEDIO Sin tests — cobertura 0% en archivos nuevos internal/ (3 archivos)

Ninguno de los 3 archivos nuevos tiene archivo _test.go correspondiente. Añadir tabla-driven tests para BuildUserQuery, CompressReport y los casos de validación de paths.

Checklist de acciones requeridas