mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
* MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP) Closes the open hardening items from the SVG XSS disclosure (security-findings-2026-06-02). The primary chain (PR #3023 / #3050) is intact; this PR addresses every remaining recommendation from the disclosure's hardening list except 'serve uploads from a separate origin' (a structural change beyond this fix). Changes: - /uploads/* now requires authentication. The route is wrapped in middleware.Auth so anonymous internet users can no longer fetch workspace attachments by guessing the URL. A new ServeLocalUpload handler then enforces the second layer: - workspaces/{wsID}/* paths require membership in wsID (uses MembershipCache for the hot path); - users/{userID}/* paths allow any authenticated user (avatars are referenced cross-workspace); - any other prefix returns 404, so a future feature cannot drop content under /uploads/<other-prefix>/ and inherit a relaxed policy by accident. Non-members see 404 (not 403) so the route does not act as an IDOR oracle for workspace IDs. - Directory listing on /uploads/* is rejected at the storage layer: empty keys, trailing-slash keys, and any key that resolves to a directory return 404 before http.ServeFile would render an HTML index. UUID filenames were obscurity, but enumerating them shouldn't be free. - Every successful /uploads/* response carries X-Content-Type-Options: nosniff and a tight per-response CSP (default-src 'none'; sandbox; frame-ancestors 'none'), overriding the application-wide CSP. This is belt-and-suspenders if a future regression weakens the Content-Disposition: attachment path. - UploadFile rejects HTML-family uploads at the edge (.html, .htm, .xhtml, .shtml, .xht, .phtml, plus a content-type denylist for text/html and application/xhtml+xml so renamed payloads cannot bypass the extension check). SVG and JS remain allowed because their existing serve-side defenses neutralize them and source-code attachments preview as text/plain via /api/attachments/{id}/content. Tests: - storage: TestLocalStorage_ServeFile_RejectsDirectoryListing, TestLocalStorage_ServeFile_HardeningHeaders. - handler: TestIsUploadDenied (pure), TestUploadFile_RejectsHTMLByExtension, TestUploadFile_RejectsHTMLByContentType, TestUploadFile_AllowsLegitimateImage, and the full ServeLocalUpload matrix (RequiresAuth, MemberCanRead, NonMemberDenied, RejectsDirectoryInPath, UnknownPrefixDenied, UserPrefixAllowsAnyAuthedUser). - Full server test suite passes. Co-authored-by: multica-agent <github@multica.ai> * MUL-3132: HMAC-signed query auth for /uploads/* (token-auth client compat) Addresses J's Request Changes review on PR #3903. Problem: PR #3903 wrapped /uploads/* in middleware.Auth, but native <img>/<video>/<iframe> resource loads cannot attach Authorization headers. Token-auth clients (Desktop default, legacy-token Web sessions, mobile) were breaking on inline attachment rendering even though the API itself authenticated fine. Fix: implement HMAC-signed query parameters for /uploads/*, mirroring S3 + CloudFront presigned URLs. - storage.SignLocalUploadURL(rawURL, key, secret, expiry) appends '?exp=<unix>&sig=<HMAC-SHA256(key|exp)>' query params; signature is bound to one specific key, has a TTL matching CloudFront mode (defaultAttachmentDownloadURLTTL = 30 min), constant-time compared on verify. - storage.VerifyLocalUploadSignature(key, exp, sig, secret, now) rejects expired, tampered, wrong-secret, and key-mismatched signatures. - ServeLocalUpload now has two auth paths: signed-query (no Auth middleware needed; signature itself is the authority) and Bearer/cookie (membership-gated as before). Partial signed-query fails closed. - The route in router.go dispatches between the two: if both exp+sig query params are present, route to inner handler unwrapped; else wrap in middleware.Auth. - attachmentToResponse appends signed query to URL when the storage backend is *LocalStorage. CloudFront-signed download URLs and S3 paths are unchanged. Tests: - storage: TestSignAndVerifyLocalUploadURL_RoundTrip, TestVerifyLocalUploadSignature_RejectsExpired, _RejectsTamperedSig, _BoundToKey, _RejectsWrongSecret, TestSignLocalUploadURL_PreservesExistingQuery, TestLocalUploadSignatureFromQuery_EmptyOnAbsence (7 pure tests). - handler: TestServeLocalUpload_{SignedQueryBypassesAuth, SignedQueryRejectsExpired, SignedQueryRejectsTampered, SignedQueryBoundToOneKey, PartialSignedQueryFailsClosed}, TestAttachmentToResponse_LocalStorageMintsSignedURL. Full server test suite passes. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
141 lines
5.3 KiB
Go
141 lines
5.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
"github.com/multica-ai/multica/server/internal/storage"
|
|
)
|
|
|
|
// ServeLocalUpload returns an http.HandlerFunc that authorizes a request for
|
|
// /uploads/<key> on the local-storage backend.
|
|
//
|
|
// Two acceptable auth paths:
|
|
//
|
|
// 1. Signed query string (?exp=&sig=) — minted by attachmentToResponse for
|
|
// LocalStorage and bound to one specific key with a short TTL.
|
|
// This is the only path that works for token-auth clients (Desktop,
|
|
// legacy-token Web sessions, native mobile) because browsers do not
|
|
// attach Authorization headers to native <img>/<video>/<iframe>
|
|
// resource loads. Mirror of the S3 + CloudFront presigned-URL flow.
|
|
// When valid, the request is served straight away — no further
|
|
// workspace membership lookup, the signature itself is the authority
|
|
// (the original metadata request that minted it already enforced
|
|
// membership).
|
|
//
|
|
// 2. Bearer / cookie via middleware.Auth — used for direct fetches from
|
|
// authenticated clients (server-side rendering, CLI, cookie-mode Web).
|
|
// middleware.Auth must be applied to the chain before this handler;
|
|
// here we rely on X-User-ID being set, then run the workspace /
|
|
// user-prefix membership check.
|
|
//
|
|
// Path layout follows handler.UploadFile:
|
|
//
|
|
// - workspaces/{workspaceID}/{filename} → membership-gated read
|
|
// - users/{userID}/{filename} → any authenticated user
|
|
//
|
|
// Anything else is rejected with 404. The disclosure
|
|
// (security-findings-2026-06-02) called out that /uploads/* being
|
|
// unauthenticated was one of the layers that made the SVG-XSS chain
|
|
// weaponizable end-to-end; this handler closes that gap while preserving
|
|
// inline image rendering for token-auth clients via the signed-query path.
|
|
func (h *Handler) ServeLocalUpload(local *storage.LocalStorage) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
key := strings.TrimPrefix(r.URL.Path, "/uploads/")
|
|
// Reject empty / directory-style paths up front. The storage
|
|
// layer will also catch this; rejecting here keeps the 404
|
|
// shape identical to the unrelated 404s on non-existent
|
|
// keys, denying the directory-existence oracle.
|
|
if key == "" || strings.HasSuffix(key, "/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// (1) Signed-query auth path. If the client included exp / sig
|
|
// query params, treat that as their chosen authentication
|
|
// method and fail closed if either is missing or invalid.
|
|
// We deliberately do NOT fall through to Bearer / cookie on a
|
|
// broken signature: a leaked URL with an expired sig should
|
|
// not silently start working again because the leaker also
|
|
// happens to be authenticated. The frontend should re-fetch
|
|
// the attachment metadata and get a fresh URL.
|
|
if exp, sig := storage.LocalUploadSignatureFromQuery(r.URL.Query()); exp != "" || sig != "" {
|
|
if exp == "" || sig == "" {
|
|
http.Error(w, `{"error":"missing exp or sig"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if !storage.VerifyLocalUploadSignature(key, exp, sig, auth.JWTSecret(), time.Now()) {
|
|
http.Error(w, `{"error":"signed URL expired or invalid"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
local.ServeFile(w, r, key)
|
|
return
|
|
}
|
|
|
|
// (2) Bearer / cookie auth path. middleware.Auth has already
|
|
// stamped X-User-ID by the time we get here.
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(key, "workspaces/"):
|
|
rest := strings.TrimPrefix(key, "workspaces/")
|
|
slash := strings.Index(rest, "/")
|
|
if slash <= 0 {
|
|
// "workspaces/" or "workspaces/<id>" with no file —
|
|
// directory listing in disguise.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
workspaceID := rest[:slash]
|
|
if !h.canReadWorkspaceUpload(r, userID, workspaceID) {
|
|
// 404 rather than 403 so the absence of a workspace
|
|
// and the lack of membership look identical from the
|
|
// outside — denies the IDOR oracle.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
case strings.HasPrefix(key, "users/"):
|
|
// Avatars and similar user-scoped assets. Any
|
|
// authenticated user can read these — they are
|
|
// routinely embedded in cross-workspace surfaces (member
|
|
// lists, inbox items, mention chips). The auth gate
|
|
// above is the access boundary; we don't gate on a
|
|
// userID match.
|
|
|
|
default:
|
|
// Unknown prefix — don't serve. New upload key shapes
|
|
// must opt in here explicitly so they can't inherit a
|
|
// relaxed policy by accident.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
local.ServeFile(w, r, key)
|
|
}
|
|
}
|
|
|
|
// canReadWorkspaceUpload returns true when the user is a member of the
|
|
// workspace whose ID is embedded in a /uploads/workspaces/{id}/* path.
|
|
// Uses the membership cache when available so every image fetch on a
|
|
// busy issue page doesn't hit Postgres. The cache itself nil-handles,
|
|
// so the explicit checks below are only for the empty-string inputs.
|
|
func (h *Handler) canReadWorkspaceUpload(r *http.Request, userID, workspaceID string) bool {
|
|
if workspaceID == "" || userID == "" {
|
|
return false
|
|
}
|
|
if h.MembershipCache.Get(r.Context(), userID, workspaceID) {
|
|
return true
|
|
}
|
|
if _, err := h.getWorkspaceMember(r.Context(), userID, workspaceID); err != nil {
|
|
return false
|
|
}
|
|
h.MembershipCache.Set(r.Context(), userID, workspaceID)
|
|
return true
|
|
}
|