From 2e00ae4cddff6ba04fb52adc44b21293857f4267 Mon Sep 17 00:00:00 2001
From: Rowan Bohde <rowan.bohde@gmail.com>
Date: Wed, 27 Nov 2024 20:50:27 -0600
Subject: [PATCH] Validate OAuth Redirect URIs (#32643)

This fixes a TODO in the code to validate the RedirectURIs when adding
or editing an OAuth application in user settings.

This also includes a refactor of the user settings tests to only create
the DB once per top-level test to avoid reloading fixtures.

(cherry picked from commit 16a7d343d78807e39df124756e5d43a69a2203a3)

Conflicts:
	services/forms/user_form.go
	tests/integration/user_settings_test.go
  simple conflicts
---
 modules/util/truncate.go                  |   2 +
 modules/validation/binding.go             |  37 ++++-
 modules/validation/binding_test.go        |   1 +
 modules/validation/validurllist_test.go   | 157 ++++++++++++++++++++++
 routers/web/user/setting/oauth2_common.go |  17 ++-
 services/forms/user_form.go               |   2 +-
 6 files changed, 209 insertions(+), 7 deletions(-)
 create mode 100644 modules/validation/validurllist_test.go

diff --git a/modules/util/truncate.go b/modules/util/truncate.go
index 77b116eeff..f2edbdc673 100644
--- a/modules/util/truncate.go
+++ b/modules/util/truncate.go
@@ -41,6 +41,8 @@ func SplitStringAtByteN(input string, n int) (left, right string) {
 
 // SplitTrimSpace splits the string at given separator and trims leading and trailing space
 func SplitTrimSpace(input, sep string) []string {
+	// Trim initial leading & trailing space
+	input = strings.TrimSpace(input)
 	// replace CRLF with LF
 	input = strings.ReplaceAll(input, "\r\n", "\n")
 
diff --git a/modules/validation/binding.go b/modules/validation/binding.go
index 1c81423ab3..006fbfafc1 100644
--- a/modules/validation/binding.go
+++ b/modules/validation/binding.go
@@ -10,6 +10,7 @@ import (
 
 	"code.gitea.io/gitea/modules/auth"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/util"
 
 	"code.forgejo.org/go-chi/binding"
 	"github.com/gobwas/glob"
@@ -33,6 +34,7 @@ const (
 // AddBindingRules adds additional binding rules
 func AddBindingRules() {
 	addGitRefNameBindingRule()
+	addValidURLListBindingRule()
 	addValidURLBindingRule()
 	addValidSiteURLBindingRule()
 	addGlobPatternRule()
@@ -47,7 +49,7 @@ func addGitRefNameBindingRule() {
 	// Git refname validation rule
 	binding.AddRule(&binding.Rule{
 		IsMatch: func(rule string) bool {
-			return strings.HasPrefix(rule, "GitRefName")
+			return rule == "GitRefName"
 		},
 		IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
 			str := fmt.Sprintf("%v", val)
@@ -61,11 +63,38 @@ func addGitRefNameBindingRule() {
 	})
 }
 
+func addValidURLListBindingRule() {
+	// URL validation rule
+	binding.AddRule(&binding.Rule{
+		IsMatch: func(rule string) bool {
+			return rule == "ValidUrlList"
+		},
+		IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+			str := fmt.Sprintf("%v", val)
+			if len(str) == 0 {
+				errs.Add([]string{name}, binding.ERR_URL, "Url")
+				return false, errs
+			}
+
+			ok := true
+			urls := util.SplitTrimSpace(str, "\n")
+			for _, u := range urls {
+				if !IsValidURL(u) {
+					ok = false
+					errs.Add([]string{name}, binding.ERR_URL, u)
+				}
+			}
+
+			return ok, errs
+		},
+	})
+}
+
 func addValidURLBindingRule() {
 	// URL validation rule
 	binding.AddRule(&binding.Rule{
 		IsMatch: func(rule string) bool {
-			return strings.HasPrefix(rule, "ValidUrl")
+			return rule == "ValidUrl"
 		},
 		IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
 			str := fmt.Sprintf("%v", val)
@@ -83,7 +112,7 @@ func addValidSiteURLBindingRule() {
 	// URL validation rule
 	binding.AddRule(&binding.Rule{
 		IsMatch: func(rule string) bool {
-			return strings.HasPrefix(rule, "ValidSiteUrl")
+			return rule == "ValidSiteUrl"
 		},
 		IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
 			str := fmt.Sprintf("%v", val)
@@ -174,7 +203,7 @@ func addUsernamePatternRule() {
 func addValidGroupTeamMapRule() {
 	binding.AddRule(&binding.Rule{
 		IsMatch: func(rule string) bool {
-			return strings.HasPrefix(rule, "ValidGroupTeamMap")
+			return rule == "ValidGroupTeamMap"
 		},
 		IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
 			_, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val))
diff --git a/modules/validation/binding_test.go b/modules/validation/binding_test.go
index 4f1031abf3..5adcdf0289 100644
--- a/modules/validation/binding_test.go
+++ b/modules/validation/binding_test.go
@@ -27,6 +27,7 @@ type (
 	TestForm struct {
 		BranchName   string `form:"BranchName" binding:"GitRefName"`
 		URL          string `form:"ValidUrl" binding:"ValidUrl"`
+		URLs         string `form:"ValidUrls" binding:"ValidUrlList"`
 		GlobPattern  string `form:"GlobPattern" binding:"GlobPattern"`
 		RegexPattern string `form:"RegexPattern" binding:"RegexPattern"`
 	}
diff --git a/modules/validation/validurllist_test.go b/modules/validation/validurllist_test.go
new file mode 100644
index 0000000000..506f96da69
--- /dev/null
+++ b/modules/validation/validurllist_test.go
@@ -0,0 +1,157 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+	"testing"
+
+	"code.forgejo.org/go-chi/binding"
+)
+
+// This is a copy of all the URL tests cases, plus additional ones to
+// account for multiple URLs
+var urlListValidationTestCases = []validationTestCase{
+	{
+		description: "Empty URL",
+		data: TestForm{
+			URLs: "",
+		},
+		expectedErrors: binding.Errors{},
+	},
+	{
+		description: "URL without port",
+		data: TestForm{
+			URLs: "http://test.lan/",
+		},
+		expectedErrors: binding.Errors{},
+	},
+	{
+		description: "URL with port",
+		data: TestForm{
+			URLs: "http://test.lan:3000/",
+		},
+		expectedErrors: binding.Errors{},
+	},
+	{
+		description: "URL with IPv6 address without port",
+		data: TestForm{
+			URLs: "http://[::1]/",
+		},
+		expectedErrors: binding.Errors{},
+	},
+	{
+		description: "URL with IPv6 address with port",
+		data: TestForm{
+			URLs: "http://[::1]:3000/",
+		},
+		expectedErrors: binding.Errors{},
+	},
+	{
+		description: "Invalid URL",
+		data: TestForm{
+			URLs: "http//test.lan/",
+		},
+		expectedErrors: binding.Errors{
+			binding.Error{
+				FieldNames:     []string{"URLs"},
+				Classification: binding.ERR_URL,
+				Message:        "http//test.lan/",
+			},
+		},
+	},
+	{
+		description: "Invalid schema",
+		data: TestForm{
+			URLs: "ftp://test.lan/",
+		},
+		expectedErrors: binding.Errors{
+			binding.Error{
+				FieldNames:     []string{"URLs"},
+				Classification: binding.ERR_URL,
+				Message:        "ftp://test.lan/",
+			},
+		},
+	},
+	{
+		description: "Invalid port",
+		data: TestForm{
+			URLs: "http://test.lan:3x4/",
+		},
+		expectedErrors: binding.Errors{
+			binding.Error{
+				FieldNames:     []string{"URLs"},
+				Classification: binding.ERR_URL,
+				Message:        "http://test.lan:3x4/",
+			},
+		},
+	},
+	{
+		description: "Invalid port with IPv6 address",
+		data: TestForm{
+			URLs: "http://[::1]:3x4/",
+		},
+		expectedErrors: binding.Errors{
+			binding.Error{
+				FieldNames:     []string{"URLs"},
+				Classification: binding.ERR_URL,
+				Message:        "http://[::1]:3x4/",
+			},
+		},
+	},
+	{
+		description: "Multi URLs",
+		data: TestForm{
+			URLs: "http://test.lan:3000/\nhttp://test.local/",
+		},
+		expectedErrors: binding.Errors{},
+	},
+	{
+		description: "Multi URLs with newline",
+		data: TestForm{
+			URLs: "http://test.lan:3000/\nhttp://test.local/\n",
+		},
+		expectedErrors: binding.Errors{},
+	},
+	{
+		description: "List with invalid entry",
+		data: TestForm{
+			URLs: "http://test.lan:3000/\nhttp://[::1]:3x4/",
+		},
+		expectedErrors: binding.Errors{
+			binding.Error{
+				FieldNames:     []string{"URLs"},
+				Classification: binding.ERR_URL,
+				Message:        "http://[::1]:3x4/",
+			},
+		},
+	},
+	{
+		description: "List with two invalid entries",
+		data: TestForm{
+			URLs: "ftp://test.lan:3000/\nhttp://[::1]:3x4/\n",
+		},
+		expectedErrors: binding.Errors{
+			binding.Error{
+				FieldNames:     []string{"URLs"},
+				Classification: binding.ERR_URL,
+				Message:        "ftp://test.lan:3000/",
+			},
+			binding.Error{
+				FieldNames:     []string{"URLs"},
+				Classification: binding.ERR_URL,
+				Message:        "http://[::1]:3x4/",
+			},
+		},
+	},
+}
+
+func Test_ValidURLListValidation(t *testing.T) {
+	AddBindingRules()
+
+	for _, testCase := range urlListValidationTestCases {
+		t.Run(testCase.description, func(t *testing.T) {
+			performValidationTest(t, testCase)
+		})
+	}
+}
diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go
index 85d1e820a5..2132d127b8 100644
--- a/routers/web/user/setting/oauth2_common.go
+++ b/routers/web/user/setting/oauth2_common.go
@@ -47,7 +47,6 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
 		return
 	}
 
-	// TODO validate redirect URI
 	app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
 		Name:               form.Name,
 		RedirectURIs:       util.SplitTrimSpace(form.RedirectURIs, "\n"),
@@ -95,11 +94,25 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
 	form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
 
 	if ctx.HasError() {
+		app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.ParamsInt64("id"))
+		if err != nil {
+			if auth.IsErrOAuthApplicationNotFound(err) {
+				ctx.NotFound("Application not found", err)
+				return
+			}
+			ctx.ServerError("GetOAuth2ApplicationByID", err)
+			return
+		}
+		if app.UID != oa.OwnerID {
+			ctx.NotFound("Application not found", nil)
+			return
+		}
+		ctx.Data["App"] = app
+
 		oa.renderEditPage(ctx)
 		return
 	}
 
-	// TODO validate redirect URI
 	var err error
 	if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{
 		ID:                 ctx.ParamsInt64("id"),
diff --git a/services/forms/user_form.go b/services/forms/user_form.go
index afd3a42982..3ba8724c92 100644
--- a/services/forms/user_form.go
+++ b/services/forms/user_form.go
@@ -388,7 +388,7 @@ func (f *NewAccessTokenForm) GetScope() (auth_model.AccessTokenScope, error) {
 // EditOAuth2ApplicationForm form for editing oauth2 applications
 type EditOAuth2ApplicationForm struct {
 	Name               string `binding:"Required;MaxSize(255)" form:"application_name"`
-	RedirectURIs       string `binding:"Required" form:"redirect_uris"`
+	RedirectURIs       string `binding:"Required;ValidUrlList" form:"redirect_uris"`
 	ConfidentialClient bool   `form:"confidential_client"`
 }