a
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user