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>