security: blindaje contra ejecucion de scripts RCE, Stored XSS, MIME sniffing, Path Traversal y DoS

This commit is contained in:
2026-10-21 01:40:43 -05:00
parent 7b58170540
commit cfda6921f9
7 changed files with 255 additions and 37 deletions
+19
View File
@@ -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,
})
}
+71 -6
View File
@@ -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)
}
+65 -30
View File
@@ -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
<FilesMatch "\.(php|phtml|phar|pl|py|cgi|sh|bash)$">
Require all denied
</FilesMatch>
`
_ = 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
}