Files
multica/server/internal/handler/attachment_capability.go
Bohan Jiang bdae0d2a03 fix(attachments): serve proxy-mode downloads with a scoped capability URL (MUL-5292) (#6092)
Desktop users on self-hosted deployments saw a save dialog but never got a
file. Electron's native download is a browser-level request: it carries
neither the desktop client's Authorization header nor a session cookie, so
GET /api/attachments/{id}/download answered 401.

The authenticated GET /api/attachments/{id} already exists to hand native
loaders a URL they can fetch without our credentials, and it already does so
in two of three modes -- a CloudFront-signed URL, or an S3 presigned URL.
Proxy mode (local disk, private object host) had no equivalent and kept
returning the auth-gated API path, which is the whole of the bug: one
unfinished branch of an otherwise correct design.

Finish that branch. In proxy mode the already-authenticated metadata endpoint
now mints a capability -- an HMAC-SHA256 signature over (version, attachment
id, expiry) with a key domain-separated from the JWT secret, valid for 60
seconds and scoped to exactly one attachment -- and a separate public route
redeems it. Membership is checked when the capability is minted, never at
redemption; the signature is the proof that check happened.

Nothing moves out of middleware.Auth: the existing authenticated download
route is untouched, so clients that predate this keep working and there is no
second copy of the header/cookie/PAT/task-token resolution. The capability
route always proxy-streams, so it emits no cross-origin redirect and the
signed query cannot leak to a CDN in a Referer.

The capability is site-relative and minted only by GetAttachmentByID. Both
matter: an absolute URL would be picked up by the inline-media re-sign path
and pinned into an <img> far longer than the TTL, and a capability in a list
response would expire before anything used it.

Verification: go test ./internal/handler/ ./cmd/server/ and the
@multica/views editor tests pass; gofmt, go vet, tsc --noEmit clean.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:20:13 +08:00

187 lines
7.5 KiB
Go

package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/auth"
)
// Attachment download capabilities — MUL-5292.
//
// A native download is a browser-level request: Electron's
// webContents.downloadURL (and an <img> in a cross-site webview) carries
// neither the desktop client's Authorization header nor a session cookie, so
// the authenticated /api/attachments/{id}/download endpoint answers 401 and
// the user never gets a file.
//
// CloudFront and presign deployments already sidestep this — the
// authenticated GetAttachmentByID hands those clients a signed storage URL
// that needs no credentials of ours. Proxy mode (local disk, private object
// host) had no equivalent and kept returning the auth-gated API path, which
// is the entirety of the bug: one unfinished branch of an otherwise correct
// design, not a missing Electron feature.
//
// A capability closes that branch the same way the other two modes do. The
// ALREADY-AUTHENTICATED GetAttachmentByID mints a short-lived signature
// granting read on exactly one attachment; a separate public route accepts
// it. Membership is verified when the capability is minted and never at
// redemption — the signature is the proof that the check happened.
//
// Deliberately NOT a general-purpose credential:
// - bound to a single attachment id, so it cannot be replayed against another
// - 60-second TTL, because the download it feeds starts immediately
// - signed with a key domain-separated from the JWT secret, so it can be
// neither forged from nor used to forge a session token
// - never persisted, and never emitted into list responses
const (
// attachmentCapabilityVersion is part of the signed message so the
// message format can change later without a v1 signature verifying
// against a v2 verifier.
attachmentCapabilityVersion = "v1"
// attachmentCapabilityTTL is short by design. The client mints a
// capability and hands it to the native downloader in the same tick;
// anything longer only widens the window in which a leaked URL is
// still redeemable.
attachmentCapabilityTTL = 60 * time.Second
// attachmentCapabilityKeyDomain separates this signing domain from
// every other HMAC the deployment derives from the same root secret.
attachmentCapabilityKeyDomain = "attachment-download-capability:"
)
var (
attachmentCapabilityKeyOnce sync.Once
attachmentCapabilityKey []byte
)
// attachmentCapabilitySigningKey derives the capability key from the
// deployment's JWT secret via SHA-256, mirroring composioStateSecret in
// server/cmd/server/router.go. Deriving rather than reusing means a
// capability signature can never collide with a JWT signature; rotating
// JWT_SECRET additionally invalidates outstanding capabilities, which is the
// behaviour an operator would expect from a rotation.
func attachmentCapabilitySigningKey() []byte {
attachmentCapabilityKeyOnce.Do(func() {
sum := sha256.Sum256(append([]byte(attachmentCapabilityKeyDomain), auth.JWTSecret()...))
attachmentCapabilityKey = sum[:]
})
return attachmentCapabilityKey
}
// signAttachmentCapability returns the hex HMAC over the capability's fields.
//
// The fields are joined with a separator that cannot occur inside a UUID or a
// decimal timestamp, so no pair of (id, exp) values can be re-split into a
// different pair that produces the same signed message.
func signAttachmentCapability(attachmentID string, exp int64) string {
mac := hmac.New(sha256.New, attachmentCapabilitySigningKey())
mac.Write([]byte(attachmentCapabilityVersion))
mac.Write([]byte("|"))
mac.Write([]byte(attachmentID))
mac.Write([]byte("|"))
mac.Write([]byte(strconv.FormatInt(exp, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
// attachmentCapabilityPath builds the site-relative capability URL handed back
// as `download_url`.
//
// Site-relative on purpose. Clients already resolve `download_url` against the
// configured API base, and keeping the shape relative leaves the inline-media
// re-sign path in packages/views/editor/attachment.tsx untouched: that hook
// only upgrades to ABSOLUTE URLs, so it keeps ignoring proxy-mode responses
// exactly as it does today instead of pinning a 60-second URL into an <img>
// it caches for 20 minutes.
func attachmentCapabilityPath(attachmentID string, now time.Time) string {
exp := now.Add(attachmentCapabilityTTL).Unix()
return "/api/attachments/" + attachmentID + "/signed-download" +
"?exp=" + strconv.FormatInt(exp, 10) +
"&sig=" + signAttachmentCapability(attachmentID, exp)
}
// verifyAttachmentCapability fails closed on every path: a missing field, an
// unparseable expiry, an elapsed expiry, a malformed signature, and a
// signature minted for a different attachment all return false.
//
// The signature covers the claimed expiry, so extending `exp` invalidates the
// signature rather than extending the capability.
func verifyAttachmentCapability(attachmentID, rawExp, rawSig string, now time.Time) bool {
if attachmentID == "" || rawExp == "" || rawSig == "" {
return false
}
exp, err := strconv.ParseInt(rawExp, 10, 64)
if err != nil {
return false
}
if now.Unix() > exp {
return false
}
got, err := hex.DecodeString(rawSig)
if err != nil {
return false
}
want, err := hex.DecodeString(signAttachmentCapability(attachmentID, exp))
if err != nil {
return false
}
return hmac.Equal(got, want)
}
// ---------------------------------------------------------------------------
// DownloadAttachmentWithCapability — GET /api/attachments/{id}/signed-download
// ---------------------------------------------------------------------------
//
// Registered as a PUBLIC route. The capability in the query IS the credential,
// and a native download request has nothing for middleware.Auth to read, so
// putting this behind Auth would defeat its only purpose. The authenticated
// /api/attachments/{id}/download endpoint is left exactly as it was — this
// route is additive, so clients that predate it keep working unchanged and
// there is no second copy of the header/cookie/PAT/task-token resolution that
// middleware.Auth owns.
//
// Always proxy-streams. Capabilities are only minted in proxy mode, and
// streaming means this route never emits a cross-origin redirect, so the
// signed query cannot leak to a CDN in a Referer.
func (h *Handler) DownloadAttachmentWithCapability(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "id")
query := r.URL.Query()
if !verifyAttachmentCapability(attachmentID, query.Get("exp"), query.Get("sig"), time.Now()) {
// One generic rejection for every reason, so a caller cannot
// distinguish "expired" from "forged" from "wrong attachment"
// and use the difference to probe the signer.
writeError(w, http.StatusForbidden, "invalid or expired download link")
return
}
attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id")
if !ok {
return
}
att, err := h.Queries.GetAttachmentByIDOnly(r.Context(), attUUID)
if err != nil {
writeError(w, http.StatusNotFound, "attachment not found")
return
}
if h.Storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage not configured")
return
}
h.setAttachmentPreviewSecurityHeaders(w)
// The signature travels in the query string. If the streamed body is
// itself a document that loads subresources, no-referrer keeps that
// query out of the outbound Referer.
w.Header().Set("Referrer-Policy", "no-referrer")
h.proxyAttachmentDownload(w, r, att, h.Storage.KeyFromURL(att.Url))
}