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
This commit is contained in:
Rowan Bohde 2024-11-27 20:50:27 -06:00 committed by Earl Warren
parent 3973f1022d
commit 2e00ae4cdd
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
6 changed files with 209 additions and 7 deletions

View file

@ -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 // SplitTrimSpace splits the string at given separator and trims leading and trailing space
func SplitTrimSpace(input, sep string) []string { func SplitTrimSpace(input, sep string) []string {
// Trim initial leading & trailing space
input = strings.TrimSpace(input)
// replace CRLF with LF // replace CRLF with LF
input = strings.ReplaceAll(input, "\r\n", "\n") input = strings.ReplaceAll(input, "\r\n", "\n")

View file

@ -10,6 +10,7 @@ import (
"code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/auth"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util"
"code.forgejo.org/go-chi/binding" "code.forgejo.org/go-chi/binding"
"github.com/gobwas/glob" "github.com/gobwas/glob"
@ -33,6 +34,7 @@ const (
// AddBindingRules adds additional binding rules // AddBindingRules adds additional binding rules
func AddBindingRules() { func AddBindingRules() {
addGitRefNameBindingRule() addGitRefNameBindingRule()
addValidURLListBindingRule()
addValidURLBindingRule() addValidURLBindingRule()
addValidSiteURLBindingRule() addValidSiteURLBindingRule()
addGlobPatternRule() addGlobPatternRule()
@ -47,7 +49,7 @@ func addGitRefNameBindingRule() {
// Git refname validation rule // Git refname validation rule
binding.AddRule(&binding.Rule{ binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool { 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) { IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val) 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() { func addValidURLBindingRule() {
// URL validation rule // URL validation rule
binding.AddRule(&binding.Rule{ binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool { 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) { IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val) str := fmt.Sprintf("%v", val)
@ -83,7 +112,7 @@ func addValidSiteURLBindingRule() {
// URL validation rule // URL validation rule
binding.AddRule(&binding.Rule{ binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool { 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) { IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val) str := fmt.Sprintf("%v", val)
@ -174,7 +203,7 @@ func addUsernamePatternRule() {
func addValidGroupTeamMapRule() { func addValidGroupTeamMapRule() {
binding.AddRule(&binding.Rule{ binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool { 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) { IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
_, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val)) _, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val))

View file

@ -27,6 +27,7 @@ type (
TestForm struct { TestForm struct {
BranchName string `form:"BranchName" binding:"GitRefName"` BranchName string `form:"BranchName" binding:"GitRefName"`
URL string `form:"ValidUrl" binding:"ValidUrl"` URL string `form:"ValidUrl" binding:"ValidUrl"`
URLs string `form:"ValidUrls" binding:"ValidUrlList"`
GlobPattern string `form:"GlobPattern" binding:"GlobPattern"` GlobPattern string `form:"GlobPattern" binding:"GlobPattern"`
RegexPattern string `form:"RegexPattern" binding:"RegexPattern"` RegexPattern string `form:"RegexPattern" binding:"RegexPattern"`
} }

View file

@ -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)
})
}
}

View file

@ -47,7 +47,6 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
return return
} }
// TODO validate redirect URI
app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{ app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
Name: form.Name, Name: form.Name,
RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
@ -95,11 +94,25 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm) form := web.GetForm(ctx).(*forms.EditOAuth2ApplicationForm)
if ctx.HasError() { 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) oa.renderEditPage(ctx)
return return
} }
// TODO validate redirect URI
var err error var err error
if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{ if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{
ID: ctx.ParamsInt64("id"), ID: ctx.ParamsInt64("id"),

View file

@ -388,7 +388,7 @@ func (f *NewAccessTokenForm) GetScope() (auth_model.AccessTokenScope, error) {
// EditOAuth2ApplicationForm form for editing oauth2 applications // EditOAuth2ApplicationForm form for editing oauth2 applications
type EditOAuth2ApplicationForm struct { type EditOAuth2ApplicationForm struct {
Name string `binding:"Required;MaxSize(255)" form:"application_name"` 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"` ConfidentialClient bool `form:"confidential_client"`
} }