From c79adf00b830ea206d1c46ce298c86802c5404d9 Mon Sep 17 00:00:00 2001
From: Wesley van Tilburg <justwesley@protonmail.com>
Date: Mon, 27 Jan 2025 03:07:39 +0100
Subject: [PATCH] Add basic auth support to rss/atom feeds (#33371)

Allows RSS readers to access private feeds using their basic auth
capabilities. Not all clients feature the ability to add cookies or
headers.

fixes #32458

Tested with miniflux

no credentials:

![image](https://github.com/user-attachments/assets/8c3369f2-1cf6-4ce3-ac6e-84447e454928)


basic auth entered:

![image](https://github.com/user-attachments/assets/c93ff22c-1429-4a80-898f-91d9f35c7c61)

![image](https://github.com/user-attachments/assets/60d83afd-9dde-4973-a440-ff8138799e87)

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 services/auth/auth.go      | 20 ++++++++++++---
 services/auth/auth_test.go | 51 +++++++++++++++++++++++++++-----------
 services/auth/basic.go     |  5 ++--
 3 files changed, 57 insertions(+), 19 deletions(-)

diff --git a/services/auth/auth.go b/services/auth/auth.go
index eb90202d24..7deca9bc3d 100644
--- a/services/auth/auth.go
+++ b/services/auth/auth.go
@@ -26,13 +26,17 @@ type globalVarsStruct struct {
 	gitRawOrAttachPathRe *regexp.Regexp
 	lfsPathRe            *regexp.Regexp
 	archivePathRe        *regexp.Regexp
+	feedPathRe           *regexp.Regexp
+	feedRefPathRe        *regexp.Regexp
 }
 
 var globalVars = sync.OnceValue(func() *globalVarsStruct {
 	return &globalVarsStruct{
-		gitRawOrAttachPathRe: regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/(?:(?:git-(?:(?:upload)|(?:receive))-pack$)|(?:info/refs$)|(?:HEAD$)|(?:objects/)|(?:raw/)|(?:releases/download/)|(?:attachments/))`),
-		lfsPathRe:            regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/info/lfs/`),
-		archivePathRe:        regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/archive/`),
+		gitRawOrAttachPathRe: regexp.MustCompile(`^/[-.\w]+/[-.\w]+/(?:(?:git-(?:(?:upload)|(?:receive))-pack$)|(?:info/refs$)|(?:HEAD$)|(?:objects/)|(?:raw/)|(?:releases/download/)|(?:attachments/))`),
+		lfsPathRe:            regexp.MustCompile(`^/[-.\w]+/[-.\w]+/info/lfs/`),
+		archivePathRe:        regexp.MustCompile(`^/[-.\w]+/[-.\w]+/archive/`),
+		feedPathRe:           regexp.MustCompile(`^/[-.\w]+(/[-.\w]+)?\.(rss|atom)$`), // "/owner.rss" or "/owner/repo.atom"
+		feedRefPathRe:        regexp.MustCompile(`^/[-.\w]+/[-.\w]+/(rss|atom)/`),     // "/owner/repo/rss/branch/..."
 	}
 })
 
@@ -61,6 +65,16 @@ func (a *authPathDetector) isAttachmentDownload() bool {
 	return strings.HasPrefix(a.req.URL.Path, "/attachments/") && a.req.Method == "GET"
 }
 
+func (a *authPathDetector) isFeedRequest(req *http.Request) bool {
+	if !setting.Other.EnableFeed {
+		return false
+	}
+	if req.Method != "GET" {
+		return false
+	}
+	return a.vars.feedPathRe.MatchString(req.URL.Path) || a.vars.feedRefPathRe.MatchString(req.URL.Path)
+}
+
 // isContainerPath checks if the request targets the container endpoint
 func (a *authPathDetector) isContainerPath() bool {
 	return strings.HasPrefix(a.req.URL.Path, "/v2/")
diff --git a/services/auth/auth_test.go b/services/auth/auth_test.go
index 55ffdebe2d..b8d3396163 100644
--- a/services/auth/auth_test.go
+++ b/services/auth/auth_test.go
@@ -9,6 +9,7 @@ import (
 	"testing"
 
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/test"
 
 	"github.com/stretchr/testify/assert"
 )
@@ -92,6 +93,19 @@ func Test_isGitRawOrLFSPath(t *testing.T) {
 			true,
 		},
 	}
+
+	defer test.MockVariableValue(&setting.LFS.StartServer)()
+	for _, tt := range tests {
+		t.Run(tt.path, func(t *testing.T) {
+			req, _ := http.NewRequest("POST", "http://localhost"+tt.path, nil)
+			setting.LFS.StartServer = false
+			assert.Equal(t, tt.want, newAuthPathDetector(req).isGitRawOrAttachOrLFSPath())
+
+			setting.LFS.StartServer = true
+			assert.Equal(t, tt.want, newAuthPathDetector(req).isGitRawOrAttachOrLFSPath())
+		})
+	}
+
 	lfsTests := []string{
 		"/owner/repo/info/lfs/",
 		"/owner/repo/info/lfs/objects/batch",
@@ -103,19 +117,6 @@ func Test_isGitRawOrLFSPath(t *testing.T) {
 		"/owner/repo/info/lfs/locks/verify",
 		"/owner/repo/info/lfs/locks/123/unlock",
 	}
-
-	origLFSStartServer := setting.LFS.StartServer
-
-	for _, tt := range tests {
-		t.Run(tt.path, func(t *testing.T) {
-			req, _ := http.NewRequest("POST", "http://localhost"+tt.path, nil)
-			setting.LFS.StartServer = false
-			assert.Equal(t, tt.want, newAuthPathDetector(req).isGitRawOrAttachOrLFSPath())
-
-			setting.LFS.StartServer = true
-			assert.Equal(t, tt.want, newAuthPathDetector(req).isGitRawOrAttachOrLFSPath())
-		})
-	}
 	for _, tt := range lfsTests {
 		t.Run(tt, func(t *testing.T) {
 			req, _ := http.NewRequest("POST", tt, nil)
@@ -128,5 +129,27 @@ func Test_isGitRawOrLFSPath(t *testing.T) {
 			assert.Equalf(t, setting.LFS.StartServer, got, "isGitOrLFSPath(%q) = %v, want %v", tt, got, setting.LFS.StartServer)
 		})
 	}
-	setting.LFS.StartServer = origLFSStartServer
+}
+
+func Test_isFeedRequest(t *testing.T) {
+	tests := []struct {
+		want bool
+		path string
+	}{
+		{true, "/user.rss"},
+		{true, "/user/repo.atom"},
+		{false, "/user/repo"},
+		{false, "/use/repo/file.rss"},
+
+		{true, "/org/repo/rss/branch/xxx"},
+		{true, "/org/repo/atom/tag/xxx"},
+		{false, "/org/repo/branch/main/rss/any"},
+		{false, "/org/atom/any"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.path, func(t *testing.T) {
+			req, _ := http.NewRequest("GET", "http://localhost"+tt.path, nil)
+			assert.Equal(t, tt.want, newAuthPathDetector(req).isFeedRequest(req))
+		})
+	}
 }
diff --git a/services/auth/basic.go b/services/auth/basic.go
index 67987206a7..e22b9e1eb7 100644
--- a/services/auth/basic.go
+++ b/services/auth/basic.go
@@ -47,9 +47,10 @@ func (b *Basic) Name() string {
 // name/token on successful validation.
 // Returns nil if header is empty or validation fails.
 func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
-	// Basic authentication should only fire on API, Download or on Git or LFSPaths
+	// Basic authentication should only fire on API, Feed, Download or on Git or LFSPaths
+	// Not all feed (rss/atom) clients feature the ability to add cookies or headers, so we need to allow basic auth for feeds
 	detector := newAuthPathDetector(req)
-	if !detector.isAPIPath() && !detector.isContainerPath() && !detector.isAttachmentDownload() && !detector.isGitRawOrAttachOrLFSPath() {
+	if !detector.isAPIPath() && !detector.isFeedRequest(req) && !detector.isContainerPath() && !detector.isAttachmentDownload() && !detector.isGitRawOrAttachOrLFSPath() {
 		return nil, nil
 	}