This commit is contained in:
2026-05-24 17:15:48 +00:00
parent 329d5c665a
commit 063b3b643f
22 changed files with 1348 additions and 92 deletions
+67 -2
View File
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"log"
"mime"
"net/http"
"net/mail"
"path/filepath"
"strconv"
"strings"
"time"
@@ -255,8 +257,9 @@ func (s *Server) messageFlag(w http.ResponseWriter, r *http.Request) {
}
// Return to message or mailbox depending on referrer.
// Validate returnTo to prevent open redirect: must start with "/" but not "//".
returnTo := r.FormValue("return")
if returnTo == "" {
if returnTo == "" || !strings.HasPrefix(returnTo, "/") || strings.HasPrefix(returnTo, "//") {
returnTo = fmt.Sprintf("/mail/%d/%d", boxID, uid64)
}
http.Redirect(w, r, returnTo, http.StatusSeeOther)
@@ -470,7 +473,7 @@ func (s *Server) composeSend(w http.ResponseWriter, r *http.Request) {
toAddrs, err := parseAddressList(toRaw)
if err != nil || len(toAddrs) == 0 {
redirect(w, r, "/compose", "", "Invalid To address: "+err.Error())
redirect(w, r, "/compose", "", "Invalid To address.")
return
}
ccAddrs, _ := parseAddressList(ccRaw)
@@ -902,3 +905,65 @@ func clearPendingTOTP(w http.ResponseWriter) {
SameSite: http.SameSiteStrictMode,
})
}
// ---- Attachment download ----
// messageAttachment serves a decrypted attachment by its 0-based index within a message.
// The route is GET /mail/{boxid}/{uid}/attachment/{n}.
func (s *Server) messageAttachment(w http.ResponseWriter, r *http.Request) {
user := s.currentUser(r)
boxID := pathID(r, "boxid")
uid64, err := strconv.ParseUint(r.PathValue("uid"), 10, 32)
if err != nil {
http.NotFound(w, r)
return
}
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 0 || n > 9999 {
http.NotFound(w, r)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Verify mailbox ownership.
box, err := s.deps.DB.GetMailboxByID(ctx, boxID)
if err != nil || box == nil || box.UserID != user.ID {
http.NotFound(w, r)
return
}
// Verify message belongs to this mailbox.
msg, err := s.deps.DB.GetIMAPMessageByUID(ctx, boxID, uint32(uid64))
if err != nil || msg == nil {
http.NotFound(w, r)
return
}
data, filename, contentType, err := s.deps.Store.GetAttachmentData(ctx, user.ID, msg.ID, n)
if err != nil {
log.Printf("[webmail] attachment %d/%d/%d: %v", boxID, uid64, n, err)
http.NotFound(w, r)
return
}
// Sanitize filename: use only the base name, discard any path components.
safeFilename := filepath.Base(filename)
if safeFilename == "." || safeFilename == "/" || safeFilename == "" {
safeFilename = "attachment"
}
// Restrict content-type to avoid serving HTML/JS from user data.
if ct, _, err2 := mime.ParseMediaType(contentType); err2 != nil || ct == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": safeFilename}))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}