diff --git a/.agente/proyecto_memoria.md b/.agente/proyecto_memoria.md index 61c5a71..82373cd 100644 --- a/.agente/proyecto_memoria.md +++ b/.agente/proyecto_memoria.md @@ -21,6 +21,7 @@ | 2026-10-20 | Reasignación de puerto a 2008 para liberar 2006 a audio_control | Se acordó con pablinux liberar el puerto 2006 para `audio_control` (correlativo a SoundWave en 2005) y trasladar `drop.sigmac.app` al puerto 2008 en CT 132 y MCP Infraestructura (ID 34). | | 2026-10-20 | Activación de dominio público https://drop.sigmac.app | Pablinux configuró el proxy reverso Apache en CT 150 con SSL Let's Encrypt. Se optimizó `getBaseURL()` para forzar HTTPS en los enlaces y códigos QR generados cuando el Host es `drop.sigmac.app`. | | 2026-10-20 | Identidad visual e iconos multi-formato (SVG, ICO, PNG) | Se diseñó un icono SVG escalable con flecha bidireccional y pulsos de transferencia en colores esmeralda/cian sobre contenedor squircle grafito oscuro. Se generaron versiones PNG (64, 192, 512 px) e ICO multi-resolución, enlazados en el navbar y servidos nativamente en `/favicon.ico` y `/favicon.svg`. | +| 2026-10-21 | Blindaje de seguridad y protección contra ejecución de scripts (RCE/XSS/MIME) | Se auditaron riesgos de subida de scripts (.php, .phtml, .sh, .html, .svg). Se neutralizó la detección de MIME impidiendo que scripts se sirvan como ejecutables o HTML activo (Stored XSS). Se implementó Sandbox CSP, nosniff, DENY de frames en /raw/, permisos estrictos 0640 sin +x en disco, .htaccess preventivo en uploads/, flag Secure en cookies HTTPS, límite de 5 MB en share-text y sanitización reforzada contra path traversal multiplataforma. | --- @@ -53,6 +54,7 @@ Versión funcional completa y validada: - [x] Registrar proyecto en MCP de Infraestructura (ID 34) y crear `id_agente.md` oficial - [x] Desplegar en producción en `server-webapps` (CT 132, :2008) y registrar en SIAX Monitor (:8080) - [x] Crear icono SVG/PNG/ICO e integrarlo en la cabecera, navbar y servidor HTTP en producción +- [x] Auditar y blindar seguridad contra RCE, Stored XSS, MIME sniffing, Path Traversal y DoS en subidas y raw --- diff --git a/.agente/tareas.md b/.agente/tareas.md index 2aa7220..634d97e 100644 --- a/.agente/tareas.md +++ b/.agente/tareas.md @@ -24,6 +24,15 @@ - [x] **Despliegue a Producción (CT 132 server-webapps):** Desplegado y ejecutándose activamente como `siax-app-DROP_SIGMAC_APP.service` supervisado por SIAX Monitor (`:8080`), escuchando en puerto interno `2008` (dejando el 2006 reservado para `audio_control`). Subida atómica y reinicio automático operativo. - [x] **Proxy Reverso Apache (SSL port 443 en CT 150):** Configurado por Pablinux y verificado operando al 100% en `https://drop.sigmac.app` (redirección 301 de HTTP a HTTPS, terminación SSL y proxy a `:2008`). Detección inteligente de HTTPS en enlaces generados y códigos QR. - [x] **Identidad Visual e Iconos del Proyecto:** Creación de conjunto completo de iconos SVG vectoriales y ráster (SVG, ICO multi-resolución, PNG 64/192/512 px) con temática visual de transferencia (verde esmeralda / cian sobre grafito). Integrados en `web/templates/base.html` (meta tags, favicon, icono de marca en navbar) y expuestos vía `/favicon.ico`, `/favicon.svg` y `/static/*`. Desplegado en producción. +- [x] **Auditoría y Blindaje de Seguridad en Subidas (RCE / Stored XSS / MIME Sniffing):** + - Neutralización de scripts `.php`, `.phtml`, `.phar`, `.sh`, `.cgi` y archivos `.html` para que nunca se sirvan como ejecutables o código HTML activo. + - Implementación de cabeceras de Sandbox (`Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`) en `/raw/{id}`. + - Middleware global de cabeceras de seguridad (`nosniff`, `SAMEORIGIN`, `strict-origin-when-cross-origin`, `Permissions-Policy`). + - Protección en disco con permisos `0640` (sin bit `+x`) y archivo preventivo `.htaccess` en `uploads/` (`Require all denied` para scripts). + - Sanitización anti-path traversal multiplataforma (eliminando `\` y secuencias `..`). + - Protección de cookies de sesión con flag `Secure: true` bajo HTTPS. + - Límite de 5 MB en endpoint `/api/share-text` contra DoS por memoria. + - Suite de pruebas de seguridad automatizada en `main_test.go` (`TestSecurityProtections`). --- diff --git a/internal/auth/auth.go b/internal/auth/auth.go index fc5f8ec..1b600a1 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -59,6 +59,21 @@ func (a *AuthService) GenerateToken(length int) string { return hex.EncodeToString(bytes) } +func (a *AuthService) isSecureRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") || + strings.EqualFold(r.Header.Get("X-Forwarded-Ssl"), "on") || + r.Header.Get("X-Forwarded-Port") == "443" { + return true + } + if strings.HasPrefix(a.cfg.BaseURL, "https://") { + return true + } + return false +} + func (a *AuthService) GetOrCreateSession(w http.ResponseWriter, r *http.Request) *models.Session { cookie, err := r.Cookie(CookieSessionName) if err == nil && cookie.Value != "" { @@ -93,6 +108,7 @@ func (a *AuthService) GetOrCreateSession(w http.ResponseWriter, r *http.Request) Path: "/", Expires: expiresAt, HttpOnly: true, + Secure: a.isSecureRequest(r), SameSite: http.SameSiteLaxMode, }) @@ -124,6 +140,7 @@ func (a *AuthService) AttachUserToSession(w http.ResponseWriter, r *http.Request Path: "/", Expires: expiresAt, HttpOnly: true, + Secure: a.isSecureRequest(r), SameSite: http.SameSiteLaxMode, }) @@ -142,6 +159,8 @@ func (a *AuthService) Logout(w http.ResponseWriter, r *http.Request) { Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, + Secure: a.isSecureRequest(r), + SameSite: http.SameSiteLaxMode, }) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 91b10b7..e72aaa3 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -211,12 +211,15 @@ func (h *Handlers) ShareText(w http.ResponseWriter, r *http.Request) { return } + // Límite de 5 MB para evitar DoS por memoria + r.Body = http.MaxBytesReader(w, r.Body, 5<<20) + var req struct { Title string `json:"title"` Text string `json:"text"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Text) == "" { - http.Error(w, "Contenido de texto requerido", http.StatusBadRequest) + http.Error(w, "Contenido de texto requerido o tamaño excesivo (máx 5 MB)", http.StatusBadRequest) return } @@ -322,6 +325,17 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { _ = h.db.IncrementViews(id) share.ViewsCount++ + // Si es un archivo de texto/código y no tiene TextContent, leer hasta 512 KB para vista previa segura + isText := strings.HasPrefix(share.MimeType, "text/") || share.Type == "text" + if isText && share.TextContent == "" && share.StoredPath != "" { + fullPath := filepath.Join(h.cfg.UploadDir, share.StoredPath) + if fi, err := os.Stat(fullPath); err == nil && fi.Size() <= 512*1024 { + if content, err := os.ReadFile(fullPath); err == nil { + share.TextContent = string(content) + } + } + } + session := h.auth.GetOrCreateSession(w, r) data := PageData{ Session: session, @@ -331,7 +345,7 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { IsVideo: strings.HasPrefix(share.MimeType, "video/"), IsAudio: strings.HasPrefix(share.MimeType, "audio/"), IsPDF: share.MimeType == "application/pdf", - IsText: strings.HasPrefix(share.MimeType, "text/") || share.Type == "text", + IsText: isText, } tmpl := h.templates["viewer.html"] @@ -351,6 +365,35 @@ func (h *Handlers) renderExpired(w http.ResponseWriter, r *http.Request) { _ = tmpl.ExecuteTemplate(w, "base.html", data) } +func isSafeInlineMime(mimeType string) bool { + switch mimeType { + case "image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/x-icon": + return true + case "audio/mpeg", "audio/wav", "audio/ogg", "audio/webm", "audio/aac", "audio/flac": + return true + case "video/mp4", "video/webm", "video/ogg": + return true + case "application/pdf": + return true + case "text/plain", "text/plain; charset=utf-8": + return true + case "image/svg+xml": + return true + default: + return false + } +} + +func sanitizeHeaderFilename(name string) string { + cleaned := strings.ReplaceAll(name, "\r", "") + cleaned = strings.ReplaceAll(cleaned, "\n", "") + cleaned = strings.ReplaceAll(cleaned, "\"", "_") + if strings.TrimSpace(cleaned) == "" { + cleaned = "archivo.bin" + } + return cleaned +} + func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/d/") if id == "" { @@ -371,9 +414,12 @@ func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) { _ = h.db.IncrementDownloads(id) + w.Header().Set("X-Content-Type-Options", "nosniff") + safeTitle := sanitizeHeaderFilename(share.Title) + if share.Type == "text" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", share.Title+".txt")) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", safeTitle+".txt")) _, _ = w.Write([]byte(share.TextContent)) return } @@ -384,7 +430,7 @@ func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", share.Title)) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", safeTitle)) http.ServeFile(w, r, fullPath) } @@ -406,8 +452,14 @@ func (h *Handlers) Raw(w http.ResponseWriter, r *http.Request) { return } + // Cabeceras de seguridad estrictas contra XSS y MIME-sniffing + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") + if share.Type == "text" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", "inline") _, _ = w.Write([]byte(share.TextContent)) return } @@ -418,8 +470,21 @@ func (h *Handlers) Raw(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", share.MimeType) - w.Header().Set("Content-Disposition", "inline") + // Neutralizar cualquier intento de servir HTML, PHP o scripts ejecutables inline + mimeType := share.MimeType + if strings.HasPrefix(mimeType, "text/html") || strings.Contains(mimeType, "php") || strings.Contains(mimeType, "javascript") { + mimeType = "text/plain; charset=utf-8" + } + w.Header().Set("Content-Type", mimeType) + + if isSafeInlineMime(mimeType) { + w.Header().Set("Content-Disposition", "inline") + } else { + // Archivos ejecutables, binarios o desconocidos se fuerzan a descarga + safeTitle := sanitizeHeaderFilename(share.Title) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", safeTitle)) + } + http.ServeFile(w, r, fullPath) } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 165c692..51419c9 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -28,8 +28,19 @@ func GenerateSlug(length int) string { } func SanitizeFilename(name string) string { - base := filepath.Base(name) + // 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 @@ -42,25 +53,19 @@ func SanitizeFilename(name string) string { func DetectMimeType(filename string, reader io.ReadSeeker) string { ext := strings.ToLower(filepath.Ext(filename)) - if m := mime.TypeByExtension(ext); m != "" { - return m - } - // Sniff first 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" { - return detected - } - } - } - - // Known fallback extensions + // 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": @@ -83,29 +88,59 @@ func DetectMimeType(filename string, reader io.ReadSeeker) string { return "image/webp" case ".svg": return "image/svg+xml" - case ".txt", ".log", ".env": - return "text/plain; charset=utf-8" - case ".json": - return "application/json" - case ".js": - return "application/javascript" - case ".go", ".py", ".rs", ".java", ".c", ".cpp", ".sh", ".sql", ".html", ".css", ".md": - return "text/plain; charset=utf-8" - default: - return "application/octet-stream" } + + // 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, 0755); err != nil { + 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) - dest, err := os.Create(destPath) + // 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 } diff --git a/main.go b/main.go index 9ff35b6..1a2c599 100644 --- a/main.go +++ b/main.go @@ -91,7 +91,7 @@ func main() { server := &http.Server{ Addr: ":" + cfg.Port, - Handler: mux, + Handler: securityHeadersMiddleware(mux), ReadTimeout: 120 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second, @@ -120,3 +120,14 @@ func main() { } log.Println("👋 Servidor apagado correctamente.") } + +// securityHeadersMiddleware inyecta cabeceras de protección defensiva en todas las respuestas +func securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + next.ServeHTTP(w, r) + }) +} diff --git a/main_test.go b/main_test.go index 851777d..6dd8325 100644 --- a/main_test.go +++ b/main_test.go @@ -3,12 +3,14 @@ package main import ( "os" "path/filepath" + "strings" "testing" "time" "app_compartir/internal/cleaner" "app_compartir/internal/db" "app_compartir/internal/models" + "app_compartir/internal/storage" ) func TestGuestExpirationAndRegisteredPermanence(t *testing.T) { @@ -132,3 +134,78 @@ func TestGuestExpirationAndRegisteredPermanence(t *testing.T) { t.Log("✅ Test completado con éxito: Solo los datos de invitado caducan en 24h; los de usuarios registrados son permanentes.") } + +func TestSecurityProtections(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test_security_*") + if err != nil { + t.Fatalf("error creando tmpDir: %v", err) + } + defer os.RemoveAll(tmpDir) + + uploadDir := filepath.Join(tmpDir, "uploads") + + // 1. Probar Sanitización de Nombres (Path Traversal) + traversalTests := []struct { + input string + expected string + }{ + {"../../../../etc/passwd", "etc_passwd"}, + {"..\\..\\windows\\system32\\cmd.exe", "cmd.exe"}, + {"test/../../shell.php", "shell.php"}, + {"../../../.htaccess", "_.htaccess"}, + } + for _, tc := range traversalTests { + sanitized := storage.SanitizeFilename(tc.input) + if strings.Contains(sanitized, "/") || strings.Contains(sanitized, "\\") || strings.Contains(sanitized, "..") { + t.Fatalf("Fallo en sanitización de path traversal para %s: resultado=%s", tc.input, sanitized) + } + } + + // 2. Probar Detección de MIME Types Peligrosos (Neutralización de XSS y Scripts) + dangerousMimes := []struct { + filename string + forbidden string + expected string + }{ + {"shell.php", "application/x-httpd-php", "text/plain; charset=utf-8"}, + {"exploit.phtml", "text/html", "text/plain; charset=utf-8"}, + {"malicious.html", "text/html", "text/plain; charset=utf-8"}, + {"test.xhtml", "text/html", "text/plain; charset=utf-8"}, + {"script.sh", "application/x-sh", "text/plain; charset=utf-8"}, + } + for _, tc := range dangerousMimes { + m := storage.DetectMimeType(tc.filename, nil) + if m == tc.forbidden || strings.HasPrefix(m, "text/html") { + t.Fatalf("MIME peligroso no neutralizado para %s: obtenido %s (prohibido %s)", tc.filename, m, tc.forbidden) + } + if m != tc.expected { + t.Fatalf("MIME esperado %s para %s, obtenido %s", tc.expected, tc.filename, m) + } + } + + // 3. Probar Guardado Seguro en Disco (Permisos no ejecutables y .htaccess preventivo) + sampleContent := strings.NewReader("") + storedFile, _, err := storage.SaveFile(uploadDir, "secslug", "shell.php", sampleContent) + if err != nil { + t.Fatalf("error guardando archivo seguro: %v", err) + } + + // Verificar permisos en disco (NO ejecutable) + destPath := filepath.Join(uploadDir, storedFile) + fi, err := os.Stat(destPath) + if err != nil { + t.Fatalf("error comprobando archivo guardado: %v", err) + } + mode := fi.Mode().Perm() + if mode&0111 != 0 { + t.Fatalf("El archivo guardado tiene permisos de ejecución: %o", mode) + } + + // Verificar existencia de .htaccess preventivo + htaccessPath := filepath.Join(uploadDir, ".htaccess") + if _, err := os.Stat(htaccessPath); os.IsNotExist(err) { + t.Fatalf(".htaccess preventivo no fue creado en uploadDir") + } + + t.Log("✅ Test completado con éxito: Protecciones de seguridad activas (sin path traversal, scripts neutralizados a texto plano y archivos no ejecutables).") +}