package storage import ( "crypto/rand" "io" "mime" "net/http" "os" "path/filepath" "regexp" "strings" ) const slugChars = "23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ" var safeFilenameRegex = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) func GenerateSlug(length int) string { b := make([]byte, length) if _, err := rand.Read(b); err != nil { return "x" + strings.ToLower(slugChars[:length-1]) } charsLen := byte(len(slugChars)) for i := 0; i < length; i++ { b[i] = slugChars[b[i]%charsLen] } return string(b) } func SanitizeFilename(name string) string { // Normalizar separadores de ruta (incluyendo Windows \) normalized := strings.ReplaceAll(name, "\\", "/") base := filepath.Base(normalized) // Eliminar secuencias '..' para evitar cualquier ambigüedad for strings.Contains(base, "..") { base = strings.ReplaceAll(base, "..", ".") } sanitized := safeFilenameRegex.ReplaceAllString(base, "_") // Evitar archivos ocultos o nombres que empiecen por punto sanitized = strings.TrimLeft(sanitized, "._-") if len(sanitized) > 100 { ext := filepath.Ext(sanitized) sanitized = sanitized[:100-len(ext)] + ext } if sanitized == "" || sanitized == "." { sanitized = "archivo.bin" } return sanitized } func DetectMimeType(filename string, reader io.ReadSeeker) string { ext := strings.ToLower(filepath.Ext(filename)) // 1. Extensiones peligrosas de scripts, código y plantillas: NUNCA ejecutables como text/html o php switch ext { case ".html", ".htm", ".shtml", ".xhtml", ".hta": return "text/plain; charset=utf-8" case ".php", ".phtml", ".php3", ".php4", ".php5", ".pht", ".phar", ".inc", ".cgi", ".pl", ".py", ".sh", ".bash", ".sql": return "text/plain; charset=utf-8" case ".js", ".mjs", ".ts", ".jsx", ".tsx": return "text/plain; charset=utf-8" case ".txt", ".log", ".env", ".ini", ".conf", ".yaml", ".yml", ".md", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".css": return "text/plain; charset=utf-8" case ".json": return "application/json" case ".pdf": return "application/pdf" case ".mp4": return "video/mp4" case ".webm": return "video/webm" case ".mp3": return "audio/mpeg" case ".wav": return "audio/wav" case ".ogg": return "audio/ogg" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".gif": return "image/gif" case ".webp": return "image/webp" case ".svg": return "image/svg+xml" } // 2. Consulta MIME del sistema para otras extensiones if m := mime.TypeByExtension(ext); m != "" { // Doble verificación: neutralizar si el sistema operativo clasifica como HTML o PHP if strings.HasPrefix(m, "text/html") || strings.Contains(m, "php") || strings.Contains(m, "javascript") { return "text/plain; charset=utf-8" } return m } // 3. Sniff de primeros 512 bytes if reader != nil { buf := make([]byte, 512) n, err := reader.Read(buf) _, _ = reader.Seek(0, io.SeekStart) if err == nil && n > 0 { detected := http.DetectContentType(buf[:n]) if detected != "application/octet-stream" { if strings.HasPrefix(detected, "text/html") { return "text/plain; charset=utf-8" } return detected } } } return "application/octet-stream" } func SaveFile(uploadDir, slug, originalFilename string, src io.Reader) (string, int64, error) { if err := os.MkdirAll(uploadDir, 0750); err != nil { return "", 0, err } // Crear archivo preventivo .htaccess para bloquear cualquier motor de script si Apache apuntara aquí htaccessPath := filepath.Join(uploadDir, ".htaccess") if _, err := os.Stat(htaccessPath); os.IsNotExist(err) { htaccessContent := `# Seguridad drop.sigmac.app: Desactivar ejecución de scripts Options -ExecCGI -Indexes Require all denied ` _ = os.WriteFile(htaccessPath, []byte(htaccessContent), 0644) } safeName := SanitizeFilename(originalFilename) storedFilename := slug + "_" + safeName destPath := filepath.Join(uploadDir, storedFilename) // Crear con permisos 0640 (sin flag +x ejecutable) dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0640) if err != nil { return "", 0, err } defer dest.Close() written, err := io.Copy(dest, src) if err != nil { _ = os.Remove(destPath) return "", 0, err } return storedFilename, written, nil }