From 0c6b026580388b3e980ffa015aa160feedabb088 Mon Sep 17 00:00:00 2001
From: KN4CK3R <admin@oldschoolhack.me>
Date: Thu, 17 Nov 2022 18:55:15 +0100
Subject: [PATCH] Fix setting HTTP headers after write (#21833)

The headers can't be modified after it was send to the client.
---
 modules/context/context.go                | 62 ++++++++++++++++++-----
 routers/api/packages/rubygems/rubygems.go |  8 ++-
 routers/common/repo.go                    | 41 ++++++---------
 routers/web/feed/profile.go               |  2 -
 4 files changed, 70 insertions(+), 43 deletions(-)

diff --git a/modules/context/context.go b/modules/context/context.go
index d34dbb5e64834..bfb6f9338588a 100644
--- a/modules/context/context.go
+++ b/modules/context/context.go
@@ -34,6 +34,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/translation"
+	"code.gitea.io/gitea/modules/typesniffer"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web/middleware"
 	"code.gitea.io/gitea/services/auth"
@@ -322,9 +323,9 @@ func (ctx *Context) plainTextInternal(skip, status int, bs []byte) {
 	if statusPrefix == 4 || statusPrefix == 5 {
 		log.Log(skip, log.TRACE, "plainTextInternal (status=%d): %s", status, string(bs))
 	}
-	ctx.Resp.WriteHeader(status)
 	ctx.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
 	ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff")
+	ctx.Resp.WriteHeader(status)
 	if _, err := ctx.Resp.Write(bs); err != nil {
 		log.ErrorWithSkip(skip, "plainTextInternal (status=%d): write bytes failed: %v", status, err)
 	}
@@ -345,16 +346,45 @@ func (ctx *Context) RespHeader() http.Header {
 	return ctx.Resp.Header()
 }
 
+type ServeHeaderOptions struct {
+	ContentType        string // defaults to "application/octet-stream"
+	ContentTypeCharset string
+	Disposition        string // defaults to "attachment"
+	Filename           string
+	CacheDuration      time.Duration // defaults to 5 minutes
+}
+
 // SetServeHeaders sets necessary content serve headers
-func (ctx *Context) SetServeHeaders(filename string) {
-	ctx.Resp.Header().Set("Content-Description", "File Transfer")
-	ctx.Resp.Header().Set("Content-Type", "application/octet-stream")
-	ctx.Resp.Header().Set("Content-Disposition", "attachment; filename="+filename)
-	ctx.Resp.Header().Set("Content-Transfer-Encoding", "binary")
-	ctx.Resp.Header().Set("Expires", "0")
-	ctx.Resp.Header().Set("Cache-Control", "must-revalidate")
-	ctx.Resp.Header().Set("Pragma", "public")
-	ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
+func (ctx *Context) SetServeHeaders(opts *ServeHeaderOptions) {
+	header := ctx.Resp.Header()
+
+	contentType := typesniffer.ApplicationOctetStream
+	if opts.ContentType != "" {
+		if opts.ContentTypeCharset != "" {
+			contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset)
+		} else {
+			contentType = opts.ContentType
+		}
+	}
+	header.Set("Content-Type", contentType)
+	header.Set("X-Content-Type-Options", "nosniff")
+
+	if opts.Filename != "" {
+		disposition := opts.Disposition
+		if disposition == "" {
+			disposition = "attachment"
+		}
+
+		backslashEscapedName := strings.ReplaceAll(strings.ReplaceAll(opts.Filename, `\`, `\\`), `"`, `\"`) // \ -> \\, " -> \"
+		header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, backslashEscapedName, url.PathEscape(opts.Filename)))
+		header.Set("Access-Control-Expose-Headers", "Content-Disposition")
+	}
+
+	duration := opts.CacheDuration
+	if duration == 0 {
+		duration = 5 * time.Minute
+	}
+	httpcache.AddCacheControlToHeader(header, duration)
 }
 
 // ServeContent serves content to http request
@@ -366,7 +396,9 @@ func (ctx *Context) ServeContent(name string, r io.ReadSeeker, params ...interfa
 			modTime = v
 		}
 	}
-	ctx.SetServeHeaders(name)
+	ctx.SetServeHeaders(&ServeHeaderOptions{
+		Filename: name,
+	})
 	http.ServeContent(ctx.Resp, ctx.Req, name, modTime, r)
 }
 
@@ -378,13 +410,17 @@ func (ctx *Context) ServeFile(file string, names ...string) {
 	} else {
 		name = path.Base(file)
 	}
-	ctx.SetServeHeaders(name)
+	ctx.SetServeHeaders(&ServeHeaderOptions{
+		Filename: name,
+	})
 	http.ServeFile(ctx.Resp, ctx.Req, file)
 }
 
 // ServeStream serves file via io stream
 func (ctx *Context) ServeStream(rd io.Reader, name string) {
-	ctx.SetServeHeaders(name)
+	ctx.SetServeHeaders(&ServeHeaderOptions{
+		Filename: name,
+	})
 	_, err := io.Copy(ctx.Resp, rd)
 	if err != nil {
 		ctx.ServerError("Download file failed", err)
diff --git a/routers/api/packages/rubygems/rubygems.go b/routers/api/packages/rubygems/rubygems.go
index 6fdd03e8ea704..eff6178f43ad2 100644
--- a/routers/api/packages/rubygems/rubygems.go
+++ b/routers/api/packages/rubygems/rubygems.go
@@ -75,7 +75,9 @@ func enumeratePackages(ctx *context.Context, filename string, pvs []*packages_mo
 		})
 	}
 
-	ctx.SetServeHeaders(filename + ".gz")
+	ctx.SetServeHeaders(&context.ServeHeaderOptions{
+		Filename: filename + ".gz",
+	})
 
 	zw := gzip.NewWriter(ctx.Resp)
 	defer zw.Close()
@@ -113,7 +115,9 @@ func ServePackageSpecification(ctx *context.Context) {
 		return
 	}
 
-	ctx.SetServeHeaders(filename)
+	ctx.SetServeHeaders(&context.ServeHeaderOptions{
+		Filename: filename,
+	})
 
 	zw := zlib.NewWriter(ctx.Resp)
 	defer zw.Close()
diff --git a/routers/common/repo.go b/routers/common/repo.go
index a9e80fad48c8d..f4b813d6b490b 100644
--- a/routers/common/repo.go
+++ b/routers/common/repo.go
@@ -7,7 +7,6 @@ package common
 import (
 	"fmt"
 	"io"
-	"net/url"
 	"path"
 	"path/filepath"
 	"strings"
@@ -53,50 +52,44 @@ func ServeData(ctx *context.Context, filePath string, size int64, reader io.Read
 		buf = buf[:n]
 	}
 
-	httpcache.AddCacheControlToHeader(ctx.Resp.Header(), 5*time.Minute)
-
 	if size >= 0 {
 		ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", size))
 	} else {
 		log.Error("ServeData called to serve data: %s with size < 0: %d", filePath, size)
 	}
 
-	fileName := path.Base(filePath)
+	opts := &context.ServeHeaderOptions{
+		Filename: path.Base(filePath),
+	}
+
 	sniffedType := typesniffer.DetectContentType(buf)
 	isPlain := sniffedType.IsText() || ctx.FormBool("render")
-	mimeType := ""
-	charset := ""
 
 	if setting.MimeTypeMap.Enabled {
-		fileExtension := strings.ToLower(filepath.Ext(fileName))
-		mimeType = setting.MimeTypeMap.Map[fileExtension]
+		fileExtension := strings.ToLower(filepath.Ext(filePath))
+		opts.ContentType = setting.MimeTypeMap.Map[fileExtension]
 	}
 
-	if mimeType == "" {
+	if opts.ContentType == "" {
 		if sniffedType.IsBrowsableBinaryType() {
-			mimeType = sniffedType.GetMimeType()
+			opts.ContentType = sniffedType.GetMimeType()
 		} else if isPlain {
-			mimeType = "text/plain"
+			opts.ContentType = "text/plain"
 		} else {
-			mimeType = typesniffer.ApplicationOctetStream
+			opts.ContentType = typesniffer.ApplicationOctetStream
 		}
 	}
 
 	if isPlain {
+		var charset string
 		charset, err = charsetModule.DetectEncoding(buf)
 		if err != nil {
 			log.Error("Detect raw file %s charset failed: %v, using by default utf-8", filePath, err)
 			charset = "utf-8"
 		}
+		opts.ContentTypeCharset = strings.ToLower(charset)
 	}
 
-	if charset != "" {
-		ctx.Resp.Header().Set("Content-Type", mimeType+"; charset="+strings.ToLower(charset))
-	} else {
-		ctx.Resp.Header().Set("Content-Type", mimeType)
-	}
-	ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff")
-
 	isSVG := sniffedType.IsSvgImage()
 
 	// serve types that can present a security risk with CSP
@@ -109,16 +102,12 @@ func ServeData(ctx *context.Context, filePath string, size int64, reader io.Read
 		ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
 	}
 
-	disposition := "inline"
+	opts.Disposition = "inline"
 	if isSVG && !setting.UI.SVG.Enabled {
-		disposition = "attachment"
+		opts.Disposition = "attachment"
 	}
 
-	// encode filename per https://datatracker.ietf.org/doc/html/rfc5987
-	encodedFileName := `filename*=UTF-8''` + url.PathEscape(fileName)
-
-	ctx.Resp.Header().Set("Content-Disposition", disposition+"; "+encodedFileName)
-	ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
+	ctx.SetServeHeaders(opts)
 
 	_, err = ctx.Resp.Write(buf)
 	if err != nil {
diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go
index 61a39755f5020..6444222ba0356 100644
--- a/routers/web/feed/profile.go
+++ b/routers/web/feed/profile.go
@@ -5,7 +5,6 @@
 package feed
 
 import (
-	"net/http"
 	"time"
 
 	"code.gitea.io/gitea/models"
@@ -57,7 +56,6 @@ func showUserFeed(ctx *context.Context, formatType string) {
 
 // writeFeed write a feeds.Feed as atom or rss to ctx.Resp
 func writeFeed(ctx *context.Context, feed *feeds.Feed, formatType string) {
-	ctx.Resp.WriteHeader(http.StatusOK)
 	if formatType == "atom" {
 		ctx.Resp.Header().Set("Content-Type", "application/atom+xml;charset=utf-8")
 		if err := feed.WriteAtom(ctx.Resp); err != nil {