feat: vendor gitea 1.16.2

This commit is contained in:
2026-06-02 08:36:01 +00:00
parent 247cd60f69
commit 54798b4615
2145 changed files with 162965 additions and 126447 deletions
@@ -0,0 +1,65 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httplib
import (
"mime"
"strings"
"code.gitea.io/gitea/modules/setting"
)
type ContentDispositionType string
const (
ContentDispositionInline ContentDispositionType = "inline"
ContentDispositionAttachment ContentDispositionType = "attachment"
)
func needsEncodingRune(b rune) bool {
return (b < ' ' || b > '~') && b != '\t'
}
// getSafeName replaces all invalid chars in the filename field by underscore
func getSafeName(s string) (_ string, needsEncoding bool) {
var out strings.Builder
for _, b := range s {
if needsEncodingRune(b) {
needsEncoding = true
out.WriteRune('_')
} else {
out.WriteRune(b)
}
}
return out.String(), needsEncoding
}
func EncodeContentDispositionAttachment(filename string) string {
return encodeContentDisposition(ContentDispositionAttachment, filename)
}
func EncodeContentDispositionInline(filename string) string {
return encodeContentDisposition(ContentDispositionInline, filename)
}
// encodeContentDisposition encodes a correct Content-Disposition Header
func encodeContentDisposition(t ContentDispositionType, filename string) string {
safeFilename, needsEncoding := getSafeName(filename)
result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename})
// No need for the utf8 encoding
if !needsEncoding {
return result
}
utf8Result := mime.FormatMediaType(string(t), map[string]string{"filename": filename})
// The mime package might have unexpected results in other go versions
// Make tests instance fail, otherwise use the default behavior of the go mime package
if !strings.HasPrefix(result, string(t)+"; filename=") || !strings.HasPrefix(utf8Result, string(t)+"; filename*=") {
setting.PanicInDevOrTesting("Unexpected mime package result %s", result)
return utf8Result
}
encodedFileName := strings.TrimPrefix(utf8Result, string(t))
return result + encodedFileName
}
@@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httplib
import (
"mime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestContentDisposition(t *testing.T) {
type testEntry struct {
disposition ContentDispositionType
filename string
header string
}
table := []testEntry{
{disposition: ContentDispositionInline, filename: "test.txt", header: "inline; filename=test.txt"},
{disposition: ContentDispositionInline, filename: "test❌.txt", header: "inline; filename=test_.txt; filename*=utf-8''test%E2%9D%8C.txt"},
{disposition: ContentDispositionInline, filename: "test ❌.txt", header: "inline; filename=\"test _.txt\"; filename*=utf-8''test%20%E2%9D%8C.txt"},
{disposition: ContentDispositionInline, filename: "\"test.txt", header: "inline; filename=\"\\\"test.txt\""},
{disposition: ContentDispositionInline, filename: "hello\tworld.txt", header: "inline; filename=\"hello\tworld.txt\""},
{disposition: ContentDispositionAttachment, filename: "hello\tworld.txt", header: "attachment; filename=\"hello\tworld.txt\""},
{disposition: ContentDispositionAttachment, filename: "hello\nworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Aworld.txt"},
{disposition: ContentDispositionAttachment, filename: "hello\rworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Dworld.txt"},
}
// Check the needsEncodingRune replacer ranges except tab that is checked above
// Any change in behavior should fail here
for c := ' '; !needsEncodingRune(c); c++ {
var header string
switch {
case strings.ContainsAny(string(c), ` (),/:;<=>?@[]`):
header = "inline; filename=\"hello" + string(c) + "world.txt\""
case strings.ContainsAny(string(c), `"\`):
// This document advises against for backslash in quoted form:
// https://datatracker.ietf.org/doc/html/rfc6266#appendix-D
// However the mime package is not generating the filename* in this scenario
header = "inline; filename=\"hello\\" + string(c) + "world.txt\""
default:
header = "inline; filename=hello" + string(c) + "world.txt"
}
table = append(table, testEntry{
disposition: ContentDispositionInline,
filename: "hello" + string(c) + "world.txt",
header: header,
})
}
for _, entry := range table {
t.Run(string(entry.disposition)+"_"+entry.filename, func(t *testing.T) {
encoded := encodeContentDisposition(entry.disposition, entry.filename)
assert.Equal(t, entry.header, encoded)
disposition, params, err := mime.ParseMediaType(encoded)
require.NoError(t, err)
assert.Equal(t, string(entry.disposition), disposition)
assert.Equal(t, entry.filename, params["filename"])
})
}
}
+87 -81
View File
@@ -8,10 +8,9 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
"time"
@@ -19,7 +18,6 @@ import (
charsetModule "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
@@ -28,18 +26,55 @@ import (
)
type ServeHeaderOptions struct {
ContentType string // defaults to "application/octet-stream"
ContentTypeCharset string
ContentLength *int64
Disposition string // defaults to "attachment"
ContentType string // defaults to "application/octet-stream"
ContentLength *int64
Filename string
CacheIsPublic bool
CacheDuration time.Duration // defaults to 5 minutes
LastModified time.Time
ContentDisposition ContentDispositionType
CacheIsPublic bool
CacheDuration time.Duration // defaults to 5 minutes
LastModified time.Time
}
const (
// Disable JS execution on the same origin, since we serve the file from the same origin as Gitea server.
// This rule can be relaxed in the future as long as it is properly sandboxed.
// "style-src" is for SVG inline styles (from Display SVG files as images instead of text #14101)
serveHeaderCspDefault = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
// No sandbox attribute for PDF as it breaks rendering in at least Safari.
// This should generally be safe as scripts inside PDF can not escape the PDF document.
// See https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion.
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
serveHeaderCspPdf = "default-src 'none'; style-src 'unsafe-inline'"
// For audios and videos, actually it doesn't really need CSP (just like Gitea <= 1.25)
serveHeaderCspAudioVideo = ""
)
func serveSetHeaderContentRelated(w http.ResponseWriter, contentType string) {
header := w.Header()
contentType = util.IfZero(contentType, typesniffer.MimeTypeApplicationOctetStream)
header.Set("Content-Type", contentType)
header.Set("X-Content-Type-Options", "nosniff")
csp := serveHeaderCspDefault
if strings.HasPrefix(contentType, "application/pdf") {
csp = serveHeaderCspPdf
}
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
csp = serveHeaderCspAudioVideo
}
if csp != "" {
header.Set("Content-Security-Policy", csp)
} else {
header.Del("Content-Security-Policy")
}
}
// ServeSetHeaders sets necessary content serve headers
func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) {
header := w.Header()
skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp")
@@ -47,29 +82,14 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
w.Header().Add(gzhttp.HeaderNoCompression, "1")
}
contentType := typesniffer.MimeTypeApplicationOctetStream
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")
serveSetHeaderContentRelated(w, opts.ContentType)
if opts.ContentLength != nil {
header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
}
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)))
contentDisposition := util.IfZero(opts.ContentDisposition, ContentDispositionAttachment)
header.Set("Content-Disposition", encodeContentDisposition(contentDisposition, path.Base(opts.Filename)))
header.Set("Access-Control-Expose-Headers", "Content-Disposition")
}
@@ -85,54 +105,40 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
}
}
// ServeData download file from io.Reader
func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byte, opts *ServeHeaderOptions) {
// do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests
sniffedType := typesniffer.DetectContentType(mineBuf)
// the "render" parameter came from year 2016: 638dd24c, it doesn't have clear meaning, so I think it could be removed later
isPlain := sniffedType.IsText() || r.FormValue("render") != ""
func serveSetHeadersByUserContent(w http.ResponseWriter, contentPrefetchBuf []byte, opts ServeHeaderOptions) {
var detectCharset bool
if setting.MimeTypeMap.Enabled {
fileExtension := strings.ToLower(filepath.Ext(opts.Filename))
fileExtension := strings.ToLower(path.Ext(opts.Filename))
opts.ContentType = setting.MimeTypeMap.Map[fileExtension]
detectCharset = strings.HasPrefix(opts.ContentType, "text/") && !strings.Contains(opts.ContentType, "charset=")
}
if opts.ContentType == "" {
sniffedType := typesniffer.DetectContentType(contentPrefetchBuf)
if sniffedType.IsBrowsableBinaryType() {
opts.ContentType = sniffedType.GetMimeType()
} else if isPlain {
} else if sniffedType.IsText() {
// intentionally do not render user's HTML content as a page, for safety, and avoid content spamming & abusing
opts.ContentType = "text/plain"
detectCharset = true
} else {
opts.ContentType = typesniffer.MimeTypeApplicationOctetStream
}
}
if isPlain {
charset, err := charsetModule.DetectEncoding(mineBuf)
if err != nil {
log.Error("Detect raw file %s charset failed: %v, using by default utf-8", opts.Filename, err)
charset = "utf-8"
if detectCharset {
if charset, _ := charsetModule.DetectEncoding(contentPrefetchBuf); charset != "" {
opts.ContentType += "; charset=" + strings.ToLower(charset)
}
opts.ContentTypeCharset = strings.ToLower(charset)
}
isSVG := sniffedType.IsSvgImage()
// serve types that can present a security risk with CSP
if isSVG {
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
} else if sniffedType.IsPDF() {
// no sandbox attribute for pdf as it breaks rendering in at least safari. this
// should generally be safe as scripts inside PDF can not escape the PDF document
// see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
opts.Disposition = "inline"
if isSVG && !setting.UI.SVG.Enabled {
opts.Disposition = "attachment"
if opts.ContentDisposition == "" {
sniffedType := typesniffer.FromContentType(opts.ContentType)
opts.ContentDisposition = ContentDispositionInline
if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled {
opts.ContentDisposition = ContentDispositionAttachment
}
}
ServeSetHeaders(w, opts)
@@ -140,7 +146,10 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt
const mimeDetectionBufferLen = 1024
func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts *ServeHeaderOptions) {
func ServeUserContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts ServeHeaderOptions) {
if opts.ContentLength != nil {
panic("do not set ContentLength, use size argument instead")
}
buf := make([]byte, mimeDetectionBufferLen)
n, err := util.ReadAtMost(reader, buf)
if err != nil {
@@ -150,7 +159,7 @@ func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, re
if n >= 0 {
buf = buf[:n]
}
setServeHeadersByFile(r, w, buf, opts)
serveSetHeadersByUserContent(w, buf, opts)
// reset the reader to the beginning
reader = io.MultiReader(bytes.NewReader(buf), reader)
@@ -204,32 +213,29 @@ func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, re
partialLength := end - start + 1
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
w.Header().Set("Content-Length", strconv.FormatInt(partialLength, 10))
if _, err = io.CopyN(io.Discard, reader, start); err != nil {
http.Error(w, "serve content: unable to skip", http.StatusInternalServerError)
return
if seeker, ok := reader.(io.Seeker); ok {
if _, err = seeker.Seek(start, io.SeekStart); err != nil {
http.Error(w, "serve content: unable to seek", http.StatusInternalServerError)
return
}
} else {
if _, err = io.CopyN(io.Discard, reader, start); err != nil {
http.Error(w, "serve content: unable to skip", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusPartialContent)
_, _ = io.CopyN(w, reader, partialLength) // just like http.ServeContent, not necessary to handle the error
}
func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, modTime *time.Time, reader io.ReadSeeker, opts *ServeHeaderOptions) {
buf := make([]byte, mimeDetectionBufferLen)
n, err := util.ReadAtMost(reader, buf)
func ServeUserContentByFile(r *http.Request, w http.ResponseWriter, file fs.File, opts ServeHeaderOptions) {
info, err := file.Stat()
if err != nil {
http.Error(w, "serve content: unable to read", http.StatusInternalServerError)
http.Error(w, "unable to serve file, stat error", http.StatusInternalServerError)
return
}
if _, err = reader.Seek(0, io.SeekStart); err != nil {
http.Error(w, "serve content: unable to seek", http.StatusInternalServerError)
return
}
if n >= 0 {
buf = buf[:n]
}
setServeHeadersByFile(r, w, buf, opts)
if modTime == nil {
modTime = &time.Time{}
}
http.ServeContent(w, r, opts.Filename, *modTime, reader)
opts.LastModified = info.ModTime()
ServeUserContentByReader(r, w, info.Size(), file, opts)
}
@@ -12,11 +12,13 @@ import (
"strings"
"testing"
"code.gitea.io/gitea/modules/typesniffer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServeContentByReader(t *testing.T) {
func TestServeUserContentByReader(t *testing.T) {
data := "0123456789abcdef"
test := func(t *testing.T, expectedStatusCode int, expectedContent string) {
@@ -27,7 +29,7 @@ func TestServeContentByReader(t *testing.T) {
}
reader := strings.NewReader(data)
w := httptest.NewRecorder()
ServeContentByReader(r, w, int64(len(data)), reader, &ServeHeaderOptions{})
ServeUserContentByReader(r, w, int64(len(data)), reader, ServeHeaderOptions{})
assert.Equal(t, expectedStatusCode, w.Code)
if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK {
assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length"))
@@ -58,7 +60,7 @@ func TestServeContentByReader(t *testing.T) {
})
}
func TestServeContentByReadSeeker(t *testing.T) {
func TestServeUserContentByFile(t *testing.T) {
data := "0123456789abcdef"
tmpFile := t.TempDir() + "/test"
err := os.WriteFile(tmpFile, []byte(data), 0o644)
@@ -76,7 +78,7 @@ func TestServeContentByReadSeeker(t *testing.T) {
defer seekReader.Close()
w := httptest.NewRecorder()
ServeContentByReadSeeker(r, w, nil, seekReader, &ServeHeaderOptions{})
ServeUserContentByFile(r, w, seekReader, ServeHeaderOptions{})
assert.Equal(t, expectedStatusCode, w.Code)
if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK {
assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length"))
@@ -106,3 +108,36 @@ func TestServeContentByReadSeeker(t *testing.T) {
test(t, http.StatusPartialContent, data[1:])
})
}
func TestServeSetHeaderContentRelated(t *testing.T) {
cases := []struct {
contentType string
csp string
}{
{"", serveHeaderCspDefault},
{"any", serveHeaderCspDefault},
{"application/pdf", serveHeaderCspPdf},
{"application/pdf; other", serveHeaderCspPdf},
{"audio/mp4", serveHeaderCspAudioVideo},
{"video/ogg; other", serveHeaderCspAudioVideo},
{typesniffer.MimeTypeImageSvg, serveHeaderCspDefault},
}
for _, c := range cases {
w := httptest.NewRecorder()
serveSetHeaderContentRelated(w, c.contentType)
csp := w.Header().Get("Content-Security-Policy")
assert.Equal(t, c.csp, csp, "content-type: %s", c.contentType)
assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) // it should always be there
}
// make sure sandboxed
require.Contains(t, serveHeaderCspDefault, "; sandbox")
}
func TestServeSetHeaders(t *testing.T) {
w := httptest.NewRecorder()
ServeSetHeaders(w, ServeHeaderOptions{Filename: "foo.zip"})
assert.Equal(t, "attachment; filename=foo.zip", w.Header().Get("Content-Disposition"))
ServeSetHeaders(w, ServeHeaderOptions{Filename: "foo.zip", ContentDisposition: ContentDispositionInline})
assert.Equal(t, "inline; filename=foo.zip", w.Header().Get("Content-Disposition"))
}
+29 -7
View File
@@ -24,7 +24,18 @@ func urlIsRelative(s string, u *url.URL) bool {
if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') {
return false
}
return u != nil && u.Scheme == "" && u.Host == ""
if u == nil {
return false // invalid URL
}
if u.Scheme != "" || u.Host != "" {
return false // absolute URL with scheme or host
}
// Now, the URL is likely a relative URL
// HINT: GOLANG-HTTP-REDIRECT-BUG: Golang security vulnerability: "http.Redirect" calls "path.Clean" and changes the meaning of a path
// For example, `/a/../\b` will be changed to `/\b`, then it hits the first checked pattern and becomes an open redirect to "{current-scheme}://b"
// For a valid relative URL, its "path" shouldn't contain `\` because such char must be escaped.
// So if the "path" contains `\`, it is not a valid relative URL, then we can prevent open redirect.
return !strings.Contains(u.Path, "\\")
}
// IsRelativeURL detects if a URL is relative (no scheme or host)
@@ -35,14 +46,14 @@ func IsRelativeURL(s string) bool {
func getRequestScheme(req *http.Request) string {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
if s := req.Header.Get("X-Forwarded-Proto"); s != "" {
return s
if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Forwarded-Proto")); ok {
return proto
}
if s := req.Header.Get("X-Forwarded-Protocol"); s != "" {
return s
if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Forwarded-Protocol")); ok {
return proto
}
if s := req.Header.Get("X-Url-Scheme"); s != "" {
return s
if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Url-Scheme")); ok {
return proto
}
if s := req.Header.Get("Front-End-Https"); s != "" {
return util.Iif(s == "on", "https", "http")
@@ -53,6 +64,13 @@ func getRequestScheme(req *http.Request) string {
return ""
}
func parseForwardedProtoValue(val string) (string, bool) {
if val == "http" || val == "https" {
return val, true
}
return "", false
}
// GuessCurrentAppURL tries to guess the current full public URL (with sub-path) by http headers. It always has a '/' suffix, exactly the same as setting.AppURL
// TODO: should rename it to GuessCurrentPublicURL in the future
func GuessCurrentAppURL(ctx context.Context) string {
@@ -61,6 +79,10 @@ func GuessCurrentAppURL(ctx context.Context) string {
// GuessCurrentHostURL tries to guess the current full host URL (no sub-path) by http headers, there is no trailing slash.
func GuessCurrentHostURL(ctx context.Context) string {
// "never" means always trust ROOT_URL and skip any request header detection.
if setting.PublicURLDetection == setting.PublicURLNever {
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
}
// Try the best guess to get the current host URL (will be used for public URL) by http headers.
// At the moment, if site admin doesn't configure the proxy headers correctly, then Gitea would guess wrong.
// There are some cases:
@@ -23,6 +23,7 @@ func TestIsRelativeURL(t *testing.T) {
"foo",
"/",
"/foo?k=%20#abc",
"/foo?k=\\",
}
for _, s := range rel {
assert.True(t, IsRelativeURL(s), "rel = %q", s)
@@ -32,6 +33,8 @@ func TestIsRelativeURL(t *testing.T) {
"\\\\",
"/\\",
"\\/",
"/a/../\\b",
"/any\\thing",
"mailto:a@b.com",
"https://test.com",
}
@@ -44,6 +47,7 @@ func TestGuessCurrentHostURL(t *testing.T) {
defer test.MockVariableValue(&setting.AppURL, "http://cfg-host/sub/")()
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
headersWithProto := http.Header{"X-Forwarded-Proto": {"https"}}
maliciousProtoHeaders := http.Header{"X-Forwarded-Proto": {"http://attacker.host/?trash="}}
t.Run("Legacy", func(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLLegacy)()
@@ -57,6 +61,9 @@ func TestGuessCurrentHostURL(t *testing.T) {
// if "X-Forwarded-Proto" exists, then use it and "Host" header
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto})
assert.Equal(t, "https://req-host:3000", GuessCurrentHostURL(ctx))
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: maliciousProtoHeaders})
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx))
})
t.Run("Auto", func(t *testing.T) {
@@ -73,6 +80,24 @@ func TestGuessCurrentHostURL(t *testing.T) {
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto})
assert.Equal(t, "https://req-host:3000", GuessCurrentHostURL(ctx))
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: maliciousProtoHeaders})
assert.Equal(t, "http://req-host:3000", GuessCurrentHostURL(ctx))
})
t.Run("Never", func(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(t.Context()))
ctx := context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000"})
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx))
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", TLS: &tls.ConnectionState{}})
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx))
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto})
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx))
})
}