Files
multica/server
Multica Eve 8ff68502fc MUL-3132: harden /uploads/* (auth, no listing, nosniff, tight CSP) (#3903)
* 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>
2026-06-09 11:59:00 +08:00
..