security: blindaje contra ejecucion de scripts RCE, Stored XSS, MIME sniffing, Path Traversal y DoS
This commit is contained in:
@@ -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 | 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 | 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-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] 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] 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] 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
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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] **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] **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] **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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ func (a *AuthService) GenerateToken(length int) string {
|
|||||||
return hex.EncodeToString(bytes)
|
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 {
|
func (a *AuthService) GetOrCreateSession(w http.ResponseWriter, r *http.Request) *models.Session {
|
||||||
cookie, err := r.Cookie(CookieSessionName)
|
cookie, err := r.Cookie(CookieSessionName)
|
||||||
if err == nil && cookie.Value != "" {
|
if err == nil && cookie.Value != "" {
|
||||||
@@ -93,6 +108,7 @@ func (a *AuthService) GetOrCreateSession(w http.ResponseWriter, r *http.Request)
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: a.isSecureRequest(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,6 +140,7 @@ func (a *AuthService) AttachUserToSession(w http.ResponseWriter, r *http.Request
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: a.isSecureRequest(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -142,6 +159,8 @@ func (a *AuthService) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: a.isSecureRequest(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,12 +211,15 @@ func (h *Handlers) ShareText(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Límite de 5 MB para evitar DoS por memoria
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +325,17 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = h.db.IncrementViews(id)
|
_ = h.db.IncrementViews(id)
|
||||||
share.ViewsCount++
|
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)
|
session := h.auth.GetOrCreateSession(w, r)
|
||||||
data := PageData{
|
data := PageData{
|
||||||
Session: session,
|
Session: session,
|
||||||
@@ -331,7 +345,7 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) {
|
|||||||
IsVideo: strings.HasPrefix(share.MimeType, "video/"),
|
IsVideo: strings.HasPrefix(share.MimeType, "video/"),
|
||||||
IsAudio: strings.HasPrefix(share.MimeType, "audio/"),
|
IsAudio: strings.HasPrefix(share.MimeType, "audio/"),
|
||||||
IsPDF: share.MimeType == "application/pdf",
|
IsPDF: share.MimeType == "application/pdf",
|
||||||
IsText: strings.HasPrefix(share.MimeType, "text/") || share.Type == "text",
|
IsText: isText,
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl := h.templates["viewer.html"]
|
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)
|
_ = 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) {
|
func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) {
|
||||||
id := strings.TrimPrefix(r.URL.Path, "/d/")
|
id := strings.TrimPrefix(r.URL.Path, "/d/")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -371,9 +414,12 @@ func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
_ = h.db.IncrementDownloads(id)
|
_ = h.db.IncrementDownloads(id)
|
||||||
|
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
safeTitle := sanitizeHeaderFilename(share.Title)
|
||||||
|
|
||||||
if share.Type == "text" {
|
if share.Type == "text" {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
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))
|
_, _ = w.Write([]byte(share.TextContent))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -384,7 +430,7 @@ func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
http.ServeFile(w, r, fullPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,8 +452,14 @@ func (h *Handlers) Raw(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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" {
|
if share.Type == "text" {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", "inline")
|
||||||
_, _ = w.Write([]byte(share.TextContent))
|
_, _ = w.Write([]byte(share.TextContent))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -418,8 +470,21 @@ func (h *Handlers) Raw(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", share.MimeType)
|
// Neutralizar cualquier intento de servir HTML, PHP o scripts ejecutables inline
|
||||||
w.Header().Set("Content-Disposition", "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)
|
http.ServeFile(w, r, fullPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-30
@@ -28,8 +28,19 @@ func GenerateSlug(length int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SanitizeFilename(name string) 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, "_")
|
sanitized := safeFilenameRegex.ReplaceAllString(base, "_")
|
||||||
|
// Evitar archivos ocultos o nombres que empiecen por punto
|
||||||
|
sanitized = strings.TrimLeft(sanitized, "._-")
|
||||||
|
|
||||||
if len(sanitized) > 100 {
|
if len(sanitized) > 100 {
|
||||||
ext := filepath.Ext(sanitized)
|
ext := filepath.Ext(sanitized)
|
||||||
sanitized = sanitized[:100-len(ext)] + ext
|
sanitized = sanitized[:100-len(ext)] + ext
|
||||||
@@ -42,25 +53,19 @@ func SanitizeFilename(name string) string {
|
|||||||
|
|
||||||
func DetectMimeType(filename string, reader io.ReadSeeker) string {
|
func DetectMimeType(filename string, reader io.ReadSeeker) string {
|
||||||
ext := strings.ToLower(filepath.Ext(filename))
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
if m := mime.TypeByExtension(ext); m != "" {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sniff first 512 bytes
|
// 1. Extensiones peligrosas de scripts, código y plantillas: NUNCA ejecutables como text/html o php
|
||||||
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
|
|
||||||
switch ext {
|
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":
|
case ".pdf":
|
||||||
return "application/pdf"
|
return "application/pdf"
|
||||||
case ".mp4":
|
case ".mp4":
|
||||||
@@ -83,29 +88,59 @@ func DetectMimeType(filename string, reader io.ReadSeeker) string {
|
|||||||
return "image/webp"
|
return "image/webp"
|
||||||
case ".svg":
|
case ".svg":
|
||||||
return "image/svg+xml"
|
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) {
|
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
|
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
|
||||||
|
<FilesMatch "\.(php|phtml|phar|pl|py|cgi|sh|bash)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
`
|
||||||
|
_ = os.WriteFile(htaccessPath, []byte(htaccessContent), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
safeName := SanitizeFilename(originalFilename)
|
safeName := SanitizeFilename(originalFilename)
|
||||||
storedFilename := slug + "_" + safeName
|
storedFilename := slug + "_" + safeName
|
||||||
destPath := filepath.Join(uploadDir, storedFilename)
|
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 {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func main() {
|
|||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: mux,
|
Handler: securityHeadersMiddleware(mux),
|
||||||
ReadTimeout: 120 * time.Second,
|
ReadTimeout: 120 * time.Second,
|
||||||
WriteTimeout: 120 * time.Second,
|
WriteTimeout: 120 * time.Second,
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
@@ -120,3 +120,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("👋 Servidor apagado correctamente.")
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"app_compartir/internal/cleaner"
|
"app_compartir/internal/cleaner"
|
||||||
"app_compartir/internal/db"
|
"app_compartir/internal/db"
|
||||||
"app_compartir/internal/models"
|
"app_compartir/internal/models"
|
||||||
|
"app_compartir/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGuestExpirationAndRegisteredPermanence(t *testing.T) {
|
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.")
|
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("<?php echo 'hacked'; ?>")
|
||||||
|
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).")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user