c77ff07ce9
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps), tracked in deploy-k3s/SECURITY.md, plus fixes from two independent post-remediation reviews. Auth & sessions: - SHA-256 hashed auth-token storage (C1); prior-token cache eviction on re-login (MEDIUM-1) - local Google JWKS verification, iss/aud/exp checks (C2/C3) - constant-time login + generic errors (L1/LIVE-L11/LIVE-L13) - per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3) - verified-email gating, login rate limiting (LIVE-L19, H1-H3) IAP & webhooks: - Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6) - migrations 000003-000006 (token hashing, IAP replay, audit_log + webhook_event_log table creation, append-only audit log) Authorization & races: - file-ownership owner-OR-member fix (C7), atomic share-code join (C9/H9), device-token reassignment (C8/LOW-3) Secrets & deploy: - secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis password out of the ConfigMap (HIGH-1); B2 keys reconciled - digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban + unattended-upgrades at provision; secret-rotation runbook Build, vet, and the full test suite (incl. -race) pass; the goose migration chain is verified against PostgreSQL 16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
6.2 KiB
Go
211 lines
6.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/apperrors"
|
|
"github.com/treytartt/honeydue-api/internal/middleware"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
"github.com/treytartt/honeydue-api/internal/services"
|
|
)
|
|
|
|
// MediaHandler handles authenticated media serving
|
|
type MediaHandler struct {
|
|
documentRepo *repositories.DocumentRepository
|
|
taskRepo *repositories.TaskRepository
|
|
residenceRepo *repositories.ResidenceRepository
|
|
storageSvc *services.StorageService
|
|
}
|
|
|
|
// NewMediaHandler creates a new media handler
|
|
func NewMediaHandler(
|
|
documentRepo *repositories.DocumentRepository,
|
|
taskRepo *repositories.TaskRepository,
|
|
residenceRepo *repositories.ResidenceRepository,
|
|
storageSvc *services.StorageService,
|
|
) *MediaHandler {
|
|
return &MediaHandler{
|
|
documentRepo: documentRepo,
|
|
taskRepo: taskRepo,
|
|
residenceRepo: residenceRepo,
|
|
storageSvc: storageSvc,
|
|
}
|
|
}
|
|
|
|
// safeContentDisposition builds an inline Content-Disposition header value
|
|
// with a sanitized filename (audit M1). Control characters (including CR/LF),
|
|
// double-quote and backslash are stripped so an attacker-controlled upload
|
|
// filename cannot inject additional response headers (CWE-113).
|
|
func safeContentDisposition(filename string) string {
|
|
cleaned := strings.Map(func(r rune) rune {
|
|
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, filename)
|
|
if cleaned == "" {
|
|
cleaned = "download"
|
|
}
|
|
return `inline; filename="` + cleaned + `"`
|
|
}
|
|
|
|
// ServeDocument serves a document file with access control
|
|
// GET /api/media/document/:id
|
|
func (h *MediaHandler) ServeDocument(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_id")
|
|
}
|
|
|
|
// Get document
|
|
doc, err := h.documentRepo.FindByID(uint(id))
|
|
if err != nil {
|
|
return apperrors.NotFound("error.document_not_found")
|
|
}
|
|
|
|
// Check access to residence
|
|
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
|
|
if err != nil || !hasAccess {
|
|
return apperrors.Forbidden("error.access_denied")
|
|
}
|
|
|
|
// Serve the file (supports encrypted files transparently)
|
|
data, mimeType, err := h.storageSvc.ReadFile(doc.FileURL)
|
|
if err != nil {
|
|
return apperrors.NotFound("error.file_not_found")
|
|
}
|
|
|
|
// Set caching and disposition headers
|
|
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
|
if doc.FileName != "" {
|
|
c.Response().Header().Set("Content-Disposition", safeContentDisposition(doc.FileName))
|
|
}
|
|
return c.Blob(http.StatusOK, mimeType, data)
|
|
}
|
|
|
|
// ServeDocumentImage serves a document image with access control
|
|
// GET /api/media/document-image/:id
|
|
func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_id")
|
|
}
|
|
|
|
// Get document image
|
|
img, err := h.documentRepo.FindImageByID(uint(id))
|
|
if err != nil {
|
|
return apperrors.NotFound("error.image_not_found")
|
|
}
|
|
|
|
// Get parent document to check residence access
|
|
doc, err := h.documentRepo.FindByID(img.DocumentID)
|
|
if err != nil {
|
|
return apperrors.NotFound("error.document_not_found")
|
|
}
|
|
|
|
// Check access to residence
|
|
hasAccess, err := h.residenceRepo.HasAccess(doc.ResidenceID, user.ID)
|
|
if err != nil || !hasAccess {
|
|
return apperrors.Forbidden("error.access_denied")
|
|
}
|
|
|
|
// Serve the file (supports encrypted files transparently)
|
|
data, mimeType, err := h.storageSvc.ReadFile(img.ImageURL)
|
|
if err != nil {
|
|
return apperrors.NotFound("error.file_not_found")
|
|
}
|
|
|
|
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
|
c.Response().Header().Set("Content-Disposition", safeContentDisposition(filepath.Base(img.ImageURL)))
|
|
return c.Blob(http.StatusOK, mimeType, data)
|
|
}
|
|
|
|
// ServeCompletionImage serves a task completion image with access control
|
|
// GET /api/media/completion-image/:id
|
|
func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
|
|
user, err := middleware.MustGetAuthUser(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
return apperrors.BadRequest("error.invalid_id")
|
|
}
|
|
|
|
// Get completion image
|
|
img, err := h.taskRepo.FindCompletionImageByID(uint(id))
|
|
if err != nil {
|
|
return apperrors.NotFound("error.image_not_found")
|
|
}
|
|
|
|
// Get the completion to get the task
|
|
completion, err := h.taskRepo.FindCompletionByID(img.CompletionID)
|
|
if err != nil {
|
|
return apperrors.NotFound("error.completion_not_found")
|
|
}
|
|
|
|
// Get task to check residence access
|
|
task, err := h.taskRepo.FindByID(completion.TaskID)
|
|
if err != nil {
|
|
return apperrors.NotFound("error.task_not_found")
|
|
}
|
|
|
|
// Check access to residence
|
|
hasAccess, err := h.residenceRepo.HasAccess(task.ResidenceID, user.ID)
|
|
if err != nil || !hasAccess {
|
|
return apperrors.Forbidden("error.access_denied")
|
|
}
|
|
|
|
// Serve the file (supports encrypted files transparently)
|
|
data, mimeType, err := h.storageSvc.ReadFile(img.ImageURL)
|
|
if err != nil {
|
|
return apperrors.NotFound("error.file_not_found")
|
|
}
|
|
|
|
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
|
|
c.Response().Header().Set("Content-Disposition", safeContentDisposition(filepath.Base(img.ImageURL)))
|
|
return c.Blob(http.StatusOK, mimeType, data)
|
|
}
|
|
|
|
// resolveFilePath converts a stored URL to an actual file path.
|
|
// Returns empty string if the URL is empty or the resolved path would escape
|
|
// the upload directory (path traversal attempt).
|
|
func (h *MediaHandler) resolveFilePath(storedURL string) string {
|
|
if storedURL == "" {
|
|
return ""
|
|
}
|
|
|
|
uploadDir := h.storageSvc.GetUploadDir()
|
|
|
|
// Strip legacy /uploads/ prefix to get relative path
|
|
relativePath := storedURL
|
|
if strings.HasPrefix(storedURL, "/uploads/") {
|
|
relativePath = strings.TrimPrefix(storedURL, "/uploads/")
|
|
}
|
|
|
|
// Use SafeResolvePath to validate containment within upload directory
|
|
resolved, err := services.SafeResolvePath(uploadDir, relativePath)
|
|
if err != nil {
|
|
// Path traversal or invalid path — return empty to signal file not found
|
|
return ""
|
|
}
|
|
|
|
return resolved
|
|
}
|