Files
dumpsterChat/internal/upload/handlers.go
T
hobokenchicken 130187c7be security: remediate all P0-P2 audit findings (12 tasks)
P0 fixes:
- WebSocket origin checking (reject untrusted origins)
- WS session token moved from URL query param to first message frame
- WebAuthn login cookie now uses Secure flag via shared SetSessionCookie
- PostgreSQL sslmode configurable via POSTGRES_SSLMODE env (default: require)
- Fixed DatabaseDSN to use real password instead of masked placeholder

P1 fixes:
- Per-IP rate limiting middleware (auth: 5 req/s, invites: 2 req/s)
- Security headers on all responses (CSP, X-Frame-Options, nosniff, etc.)
- WS broadcasts scoped to server members (prevents cross-server data leak)
- Webhook tokens stored as SHA-256 hashes (not plaintext)

P2 fixes:
- CSRF protection via Origin header validation on state-changing requests
- MANAGE_CHANNELS permission enforced on channel update/delete
- Upload validation: 25MB limit, extension allowlist, server-side MIME check
- File serve: path traversal protection + Content-Disposition: attachment

Files: 23 changed, +499/-100. Builds clean (go build, go vet, npm build).
Zero CVEs (govulncheck, npm audit).
2026-06-29 09:30:50 -04:00

159 lines
4.3 KiB
Go

package upload
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
const maxUploadSize = 25 << 20 // 25 MB per file
var allowedExtensions = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
".mp3": true, ".wav": true, ".ogg": true, ".flac": true,
".mp4": true, ".webm": true, ".mov": true,
".pdf": true, ".txt": true, ".md": true, ".json": true, ".csv": true,
".zip": true, ".tar": true, ".gz": true,
}
type Handler struct {
client *minio.Client
bucket string
}
func NewHandler(cfg *config.Config) (*Handler, error) {
endpoint := cfg.MinIO.Endpoint
if endpoint == "" {
return nil, fmt.Errorf("minio endpoint not configured")
}
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.MinIO.AccessKey, cfg.MinIO.SecretKey, ""),
Secure: cfg.MinIO.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("minio client: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
exists, err := client.BucketExists(ctx, cfg.MinIO.Bucket)
if err != nil {
return nil, fmt.Errorf("bucket check: %w", err)
}
if !exists {
if err := client.MakeBucket(ctx, cfg.MinIO.Bucket, minio.MakeBucketOptions{}); err != nil {
return nil, fmt.Errorf("make bucket: %w", err)
}
}
return &Handler{
client: client,
bucket: cfg.MinIO.Bucket,
}, nil
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, `{"error":"file too large or invalid form"}`, http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, `{"error":"missing file"}`, http.StatusBadRequest)
return
}
defer file.Close()
// Validate extension against allowlist
ext := ""
if idx := strings.LastIndex(header.Filename, "."); idx >= 0 {
ext = strings.ToLower(header.Filename[idx:])
}
if !allowedExtensions[ext] {
http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest)
return
}
// Server-side content-type detection (blocks HTML/JS that could be used for XSS)
buf := make([]byte, 512)
n, _ := file.Read(buf)
detectedType := http.DetectContentType(buf[:n])
file.Seek(0, io.SeekStart)
blockedTypes := []string{"text/html", "application/javascript", "application/x-executable"}
for _, blocked := range blockedTypes {
if strings.HasPrefix(detectedType, blocked) {
http.Error(w, `{"error":"file type not allowed"}`, http.StatusBadRequest)
return
}
}
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = detectedType
}
objectName := uuid.New().String() + ext
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
info, err := h.client.PutObject(ctx, h.bucket, objectName, file, header.Size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
http.Error(w, `{"error":"upload failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"url":"/%s/%s","size":%d}`, h.bucket, objectName, info.Size)
}
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
objectName := strings.TrimPrefix(r.URL.Path, "/files/")
if objectName == "" {
http.Error(w, `{"error":"missing file"}`, http.StatusBadRequest)
return
}
// Sanitize: reject path traversal attempts
if strings.Contains(objectName, "..") || strings.Contains(objectName, "/") {
http.Error(w, `{"error":"invalid path"}`, http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
obj, err := h.client.GetObject(ctx, h.bucket, objectName, minio.GetObjectOptions{})
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
defer obj.Close()
stat, err := obj.Stat()
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", stat.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
w.Header().Set("Content-Disposition", "attachment")
io.Copy(w, obj)
}