CVE-2022-1285: Server-Side Request Forgery (SSRF) (CWE-918)
value: Server-Side Request Forgery (SSRF) in GitHub repository gogs/gogs prior to 0.12.8.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 6.5
Published: 2022-06-01T06:15Z
Repository: https://github.com/gogs/gogs
Language: Go
Files changed: CHANGELOG.md, conf/locale/locale_en-US.ini, internal/db/error.go, internal/db/webhook.go, internal/form/repo.go, internal/netutil/netutil.go, internal/netutil/netutil_test.go, internal/route/api/v1/repo/repo.go, internal/route/repo/repo.go, internal/route/repo/webhook.go, internal/route/repo/webhook_test.go
Commit date: 2022-05-31 15:17:17 +0800
Commit message: webhook: revalidate local hostname before each delivery (#6988)


Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d159bfa0..3ccf87fd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,10 +20,11 @@ All notable changes to Gogs are documented in this file.
 - Use [Task](https://github.com/go-task/task) as the build tool. [#6297](https://github.com/gogs/gogs/pull/6297)
 - The required Go version to compile source code changed to 1.16.
 
 ### Fixed
 
+- _Security:_ SSRF in webhook. [#6901](https://github.com/gogs/gogs/issues/6901)
 - _Security:_ XSS in cookies. [#6953](https://github.com/gogs/gogs/issues/6953)
 - _Security:_ OS Command Injection in file uploading. [#6968](https://github.com/gogs/gogs/issues/6968)
 - _Security:_ Remote Command Execution in file editing. [#6555](https://github.com/gogs/gogs/issues/6555)
 - Unable to use LDAP authentication on ARM machines. [#6761](https://github.com/gogs/gogs/issues/6761)
 
diff --git a/conf/locale/locale_en-US.ini b/conf/locale/locale_en-US.ini
index 5ad18949f..c2eaa4dff 100644
--- a/conf/locale/locale_en-US.ini
+++ b/conf/locale/locale_en-US.ini
@@ -441,10 +441,11 @@ migrate_repo = Migrate Repository
 migrate.clone_address = Clone Address
 migrate.clone_address_desc = This can be a HTTP/HTTPS/GIT URL.
 migrate.clone_address_desc_import_local = You're also allowed to migrate a repository by local server path.
 migrate.permission_denied = You are not allowed to import local repositories.
 migrate.invalid_local_path = Invalid local path, it does not exist or not a directory.
+migrate.clone_address_resolved_to_blocked_local_address = Clone address resolved to a local network address that is implicitly blocked.
 migrate.failed = Migration failed: %v
 
 mirror_from = mirror of
 forked_from = forked from
 copy_link = Copy
@@ -807,11 +808,11 @@ settings.webhook.request = Request
 settings.webhook.response = Response
 settings.webhook.headers = Headers
 settings.webhook.payload = Payload
 settings.webhook.body = Body
 settings.webhook.err_cannot_parse_payload_url = Cannot parse payload URL: %v
-settings.webhook.err_cannot_use_local_addresses = Non admins are not allowed to use local addresses.
+settings.webhook.url_resolved_to_blocked_local_address = Payload URL resolved to a local network address that is implicitly blocked.
 settings.githooks_desc = Git Hooks are powered by Git itself, you can edit files of supported hooks in the list below to perform custom operations.
 settings.githook_edit_desc = If the hook is inactive, sample content will be presented. Leaving content to an empty value will disable this hook.
 settings.githook_name = Hook Name
 settings.githook_content = Hook Content
 settings.update_githook = Update Hook
diff --git a/internal/db/error.go b/internal/db/error.go
index d1668d991..f754df6d3 100644
--- a/internal/db/error.go
+++ b/internal/db/error.go
@@ -192,23 +192,24 @@ func (err ErrLastOrgOwner) Error() string {
 //  |    |   \  ___/|  |_> >  <_> )___ \|  ||  | (  <_> )  | \/\___  |
 //  |____|_  /\___  >   __/ \____/____  >__||__|  \____/|__|   / ____|
 //         \/     \/|__|              \/                       \/
 
 type ErrInvalidCloneAddr struct {
-	IsURLError         bool
-	IsInvalidPath      bool
-	IsPermissionDenied bool
+	IsURLError            bool
+	IsInvalidPath         bool
+	IsPermissionDenied    bool
+	IsBlockedLocalAddress bool
 }
 
 func IsErrInvalidCloneAddr(err error) bool {
 	_, ok := err.(ErrInvalidCloneAddr)
 	return ok
 }
 
 func (err ErrInvalidCloneAddr) Error() string {
-	return fmt.Sprintf("invalid clone address [is_url_error: %v, is_invalid_path: %v, is_permission_denied: %v]",
-		err.IsURLError, err.IsInvalidPath, err.IsPermissionDenied)
+	return fmt.Sprintf("invalid clone address [is_url_error: %v, is_invalid_path: %v, is_permission_denied: %v, is_blocked_local_address: %v]",
+		err.IsURLError, err.IsInvalidPath, err.IsPermissionDenied, err.IsBlockedLocalAddress)
 }
 
 type ErrUpdateTaskNotExist struct {
 	UUID string
 }
diff --git a/internal/db/webhook.go b/internal/db/webhook.go
index bca1fb918..fee3d1eca 100644
--- a/internal/db/webhook.go
+++ b/internal/db/webhook.go
@@ -22,10 +22,11 @@ import (
 	api "github.com/gogs/go-gogs-client"
 
 	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/errutil"
 	"gogs.io/gogs/internal/httplib"
+	"gogs.io/gogs/internal/netutil"
 	"gogs.io/gogs/internal/sync"
 )
 
 var HookQueue = sync.NewUniqueQueue(1000)
 
@@ -686,10 +687,15 @@ func TestWebhook(repo *Repository, event HookEventType, p api.Payloader, webhook
 	}
 	return prepareHookTasks(x, repo, event, p, []*Webhook{webhook})
 }
 
 func (t *HookTask) deliver() {
+	if netutil.IsBlockedLocalHostname(t.URL, conf.Security.LocalNetworkAllowlist) {
+		t.ResponseContent = "Payload URL resolved to a local network address that is implicitly blocked."
+		return
+	}
+
 	t.IsDelivered = true
 
 	timeout := time.Duration(conf.Webhook.DeliverTimeout) * time.Second
 	req := httplib.Post(t.URL).SetTimeout(timeout, timeout).
 		Header("X-Github-Delivery", t.UUID).
diff --git a/internal/form/repo.go b/internal/form/repo.go
index adcbc66ab..5750d78ec 100644
--- a/internal/form/repo.go
+++ b/internal/form/repo.go
@@ -70,12 +70,12 @@ func (f MigrateRepo) ParseRemoteAddr(user *db.User) (string, error) {
 		u, err := url.Parse(remoteAddr)
 		if err != nil {
 			return "", db.ErrInvalidCloneAddr{IsURLError: true}
 		}
 
-		if netutil.IsLocalHostname(u.Hostname(), conf.Security.LocalNetworkAllowlist) {
-			return "", db.ErrInvalidCloneAddr{IsURLError: true}
+		if netutil.IsBlockedLocalHostname(u.Hostname(), conf.Security.LocalNetworkAllowlist) {
+			return "", db.ErrInvalidCloneAddr{IsBlockedLocalAddress: true}
 		}
 
 		if len(f.AuthUsername)+len(f.AuthPassword) > 0 {
 			u.User = url.UserPassword(f.AuthUsername, f.AuthPassword)
 		}
diff --git a/internal/netutil/netutil.go b/internal/netutil/netutil.go
index 5059d4639..8fef31150 100644
--- a/internal/netutil/netutil.go
+++ b/internal/netutil/netutil.go
@@ -45,13 +45,14 @@ func init() {
 		}
 		localCIDRs = append(localCIDRs, cidr)
 	}
 }
 
-// IsLocalHostname returns true if given hostname is resolved to local network
-// address, except exempted from the allowlist.
-func IsLocalHostname(hostname string, allowlist []string) bool {
+// IsBlockedLocalHostname returns true if given hostname is resolved to a local
+// network address that is implicitly blocked (i.e. not exempted from the
+// allowlist).
+func IsBlockedLocalHostname(hostname string, allowlist []string) bool {
 	for _, allow := range allowlist {
 		if hostname == allow {
 			return false
 		}
 	}
diff --git a/internal/netutil/netutil_test.go b/internal/netutil/netutil_test.go
index 65202baf3..9bd9c9821 100644
--- a/internal/netutil/netutil_test.go
+++ b/internal/netutil/netutil_test.go
@@ -32,9 +32,9 @@ func TestIsLocalHostname(t *testing.T) {
 		{hostname: "192.168.123.45", allowlist: []string{"10.0.0.17"}, want: true}, // #11
 		{hostname: "gogs.local", allowlist: []string{"gogs.local"}, want: false},   // #12
 	}
 	for _, test := range tests {
 		t.Run("", func(t *testing.T) {
-			assert.Equal(t, test.want, IsLocalHostname(test.hostname, test.allowlist))
+			assert.Equal(t, test.want, IsBlockedLocalHostname(test.hostname, test.allowlist))
 		})
 	}
 }
diff --git a/internal/route/api/v1/repo/repo.go b/internal/route/api/v1/repo/repo.go
index 11548ec4c..682d2a3b2 100644
--- a/internal/route/api/v1/repo/repo.go
+++ b/internal/route/api/v1/repo/repo.go
@@ -246,10 +246,12 @@ func Migrate(c *context.APIContext, f form.MigrateRepo) {
 				c.ErrorStatus(http.StatusUnprocessableEntity, err)
 			case addrErr.IsPermissionDenied:
 				c.ErrorStatus(http.StatusUnprocessableEntity, errors.New("You are not allowed to import local repositories."))
 			case addrErr.IsInvalidPath:
 				c.ErrorStatus(http.StatusUnprocessableEntity, errors.New("Invalid local path, it does not exist or not a directory."))
+			case addrErr.IsBlockedLocalAddress:
+				c.ErrorStatus(http.StatusUnprocessableEntity, errors.New("Clone address resolved to a local network address that is implicitly blocked."))
 			default:
 				c.Error(err, "unexpected error")
 			}
 		} else {
 			c.Error(err, "parse remote address")
diff --git a/internal/route/repo/repo.go b/internal/route/repo/repo.go
index 259aba564..cef250073 100644
--- a/internal/route/repo/repo.go
+++ b/internal/route/repo/repo.go
@@ -178,15 +178,17 @@ func MigratePost(c *context.Context, f form.MigrateRepo) {
 		if db.IsErrInvalidCloneAddr(err) {
 			c.Data["Err_CloneAddr"] = true
 			addrErr := err.(db.ErrInvalidCloneAddr)
 			switch {
 			case addrErr.IsURLError:
-				c.RenderWithErr(c.Tr("form.url_error"), MIGRATE, &f)
+				c.RenderWithErr(c.Tr("repo.migrate.clone_address")+c.Tr("form.url_error"), MIGRATE, &f)
 			case addrErr.IsPermissionDenied:
 				c.RenderWithErr(c.Tr("repo.migrate.permission_denied"), MIGRATE, &f)
 			case addrErr.IsInvalidPath:
 				c.RenderWithErr(c.Tr("repo.migrate.invalid_local_path"), MIGRATE, &f)
+			case addrErr.IsBlockedLocalAddress:
+				c.RenderWithErr(c.Tr("repo.migrate.clone_address_resolved_to_blocked_local_address"), MIGRATE, &f)
 			default:
 				c.Error(err, "unexpected error")
 			}
 		} else {
 			c.Error(err, "parse remote address")
diff --git a/internal/route/repo/webhook.go b/internal/route/repo/webhook.go
index bed0d0bda..c6ff312ab 100644
--- a/internal/route/repo/webhook.go
+++ b/internal/route/repo/webhook.go
@@ -117,24 +117,21 @@ func WebhooksNew(c *context.Context, orCtx *orgRepoContext) {
 	}
 
 	c.Success(orCtx.TmplNew)
 }
 
-func validateWebhook(actor *db.User, l macaron.Locale, w *db.Webhook) (field, msg string, ok bool) {
-	if !actor.IsAdmin {
-		// 🚨 SECURITY: Local addresses must not be allowed by non-admins to prevent SSRF,
-		// see https://github.com/gogs/gogs/issues/5366 for details.
-		payloadURL, err := url.Parse(w.URL)
-		if err != nil {
-			return "PayloadURL", l.Tr("repo.settings.webhook.err_cannot_parse_payload_url", err), false
-		}
-
-		if netutil.IsLocalHostname(payloadURL.Hostname(), conf.Security.LocalNetworkAllowlist) {
-			return "PayloadURL", l.Tr("repo.settings.webhook.err_cannot_use_local_addresses"), false
-		}
+func validateWebhook(l macaron.Locale, w *db.Webhook) (field, msg string, ok bool) {
+	// 🚨 SECURITY: Local addresses must not be allowed by non-admins to prevent SSRF,
+	// see https://github.com/gogs/gogs/issues/5366 for details.
+	payloadURL, err := url.Parse(w.URL)
+	if err != nil {
+		return "PayloadURL", l.Tr("repo.settings.webhook.err_cannot_parse_payload_url", err), false
 	}
 
+	if netutil.IsBlockedLocalHostname(payloadURL.Hostname(), conf.Security.LocalNetworkAllowlist) {
+		return "PayloadURL", l.Tr("repo.settings.webhook.url_resolved_to_blocked_local_address"), false
+	}
 	return "", "", true
 }
 
 func validateAndCreateWebhook(c *context.Context, orCtx *orgRepoContext, w *db.Webhook) {
 	c.Data["Webhook"] = w
@@ -142,11 +139,11 @@ func validateAndCreateWebhook(c *context.Context, orCtx *orgRepoContext, w *db.W
 	if c.HasError() {
 		c.Success(orCtx.TmplNew)
 		return
 	}
 
-	field, msg, ok := validateWebhook(c.User, c.Locale, w)
+	field, msg, ok := validateWebhook(c.Locale, w)
 	if !ok {
 		c.FormErr(field)
 		c.RenderWithErr(msg, orCtx.TmplNew, nil)
 		return
 	}
@@ -346,11 +343,11 @@ func validateAndUpdateWebhook(c *context.Context, orCtx *orgRepoContext, w *db.W
 	if c.HasError() {
 		c.Success(orCtx.TmplNew)
 		return
 	}
 
-	field, msg, ok := validateWebhook(c.User, c.Locale, w)
+	field, msg, ok := validateWebhook(c.Locale, w)
 	if !ok {
 		c.FormErr(field)
 		c.RenderWithErr(msg, orCtx.TmplNew, nil)
 		return
 	}
diff --git a/internal/route/repo/webhook_test.go b/internal/route/repo/webhook_test.go
index d10a6fcc0..784d66ede 100644
--- a/internal/route/repo/webhook_test.go
+++ b/internal/route/repo/webhook_test.go
@@ -29,27 +29,25 @@ func Test_validateWebhook(t *testing.T) {
 		expMsg   string
 		expOK    bool
 	}{
 		{
 			name:    "admin bypass local address check",
-			actor:   &db.User{IsAdmin: true},
-			webhook: &db.Webhook{URL: "http://localhost:3306"},
+			webhook: &db.Webhook{URL: "https://www.google.com"},
 			expOK:   true,
 		},
 
 		{
 			name:     "local address not allowed",
-			actor:    &db.User{},
 			webhook:  &db.Webhook{URL: "http://localhost:3306"},
 			expField: "PayloadURL",
-			expMsg:   "repo.settings.webhook.err_cannot_use_local_addresses",
+			expMsg:   "repo.settings.webhook.url_resolved_to_blocked_local_address",
 			expOK:    false,
 		},
 	}
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			field, msg, ok := validateWebhook(test.actor, l, test.webhook)
+			field, msg, ok := validateWebhook(l, test.webhook)
 			assert.Equal(t, test.expOK, ok)
 			assert.Equal(t, test.expMsg, msg)
 			assert.Equal(t, test.expField, field)
 		})
 	}

Vulnerable code: settings.webhook.err_cannot_use_local_addresses = Non admins are not allowed to use local addresses.
	IsURLError         bool
	IsInvalidPath      bool
	IsPermissionDenied bool
	return fmt.Sprintf("invalid clone address [is_url_error: %v, is_invalid_path: %v, is_permission_denied: %v]",
		err.IsURLError, err.IsInvalidPath, err.IsPermissionDenied)
		if netutil.IsLocalHostname(u.Hostname(), conf.Security.LocalNetworkAllowlist) {
			return "", db.ErrInvalidCloneAddr{IsURLError: true}
// IsLocalHostname returns true if given hostname is resolved to local network
// address, except exempted from the allowlist.
func IsLocalHostname(hostname string, allowlist []string) bool {
			assert.Equal(t, test.want, IsLocalHostname(test.hostname, test.allowlist))
				c.RenderWithErr(c.Tr("form.url_error"), MIGRATE, &f)
func validateWebhook(actor *db.User, l macaron.Locale, w *db.Webhook) (field, msg string, ok bool) {
	if !actor.IsAdmin {
		// 🚨 SECURITY: Local addresses must not be allowed by non-admins to prevent SSRF,
		// see https://github.com/gogs/gogs/issues/5366 for details.
		payloadURL, err := url.Parse(w.URL)
		if err != nil {
			return "PayloadURL", l.Tr("repo.settings.webhook.err_cannot_parse_payload_url", err), false
		}

		if netutil.IsLocalHostname(payloadURL.Hostname(), conf.Security.LocalNetworkAllowlist) {
			return "PayloadURL", l.Tr("repo.settings.webhook.err_cannot_use_local_addresses"), false
		}
	field, msg, ok := validateWebhook(c.User, c.Locale, w)
	field, msg, ok := validateWebhook(c.User, c.Locale, w)
			actor:   &db.User{IsAdmin: true},
			webhook: &db.Webhook{URL: "http://localhost:3306"},
			actor:    &db.User{},
			expMsg:   "repo.settings.webhook.err_cannot_use_local_addresses",
			field, msg, ok := validateWebhook(test.actor, l, test.webhook)
Fixed code: - _Security:_ SSRF in webhook. [#6901](https://github.com/gogs/gogs/issues/6901)
migrate.clone_address_resolved_to_blocked_local_address = Clone address resolved to a local network address that is implicitly blocked.
settings.webhook.url_resolved_to_blocked_local_address = Payload URL resolved to a local network address that is implicitly blocked.
	IsURLError            bool
	IsInvalidPath         bool
	IsPermissionDenied    bool
	IsBlockedLocalAddress bool
	return fmt.Sprintf("invalid clone address [is_url_error: %v, is_invalid_path: %v, is_permission_denied: %v, is_blocked_local_address: %v]",
		err.IsURLError, err.IsInvalidPath, err.IsPermissionDenied, err.IsBlockedLocalAddress)
	"gogs.io/gogs/internal/netutil"
	if netutil.IsBlockedLocalHostname(t.URL, conf.Security.LocalNetworkAllowlist) {
		t.ResponseContent = "Payload URL resolved to a local network address that is implicitly blocked."
		return
	}

		if netutil.IsBlockedLocalHostname(u.Hostname(), conf.Security.LocalNetworkAllowlist) {
			return "", db.ErrInvalidCloneAddr{IsBlockedLocalAddress: true}
// IsBlockedLocalHostname returns true if given hostname is resolved to a local
// network address that is implicitly blocked (i.e. not exempted from the
// allowlist).
func IsBlockedLocalHostname(hostname string, allowlist []string) bool {
			assert.Equal(t, test.want, IsBlockedLocalHostname(test.hostname, test.allowlist))
			case addrErr.IsBlockedLocalAddress:
				c.ErrorStatus(http.StatusUnprocessableEntity, errors.New("Clone address resolved to a local network address that is implicitly blocked."))
				c.RenderWithErr(c.Tr("repo.migrate.clone_address")+c.Tr("form.url_error"), MIGRATE, &f)
			case addrErr.IsBlockedLocalAddress:
				c.RenderWithErr(c.Tr("repo.migrate.clone_address_resolved_to_blocked_local_address"), MIGRATE, &f)
func validateWebhook(l macaron.Locale, w *db.Webhook) (field, msg string, ok bool) {
	// 🚨 SECURITY: Local addresses must not be allowed by non-admins to prevent SSRF,
	// see https://github.com/gogs/gogs/issues/5366 for details.
	payloadURL, err := url.Parse(w.URL)
	if err != nil {
		return "PayloadURL", l.Tr("repo.settings.webhook.err_cannot_parse_payload_url", err), false
	if netutil.IsBlockedLocalHostname(payloadURL.Hostname(), conf.Security.LocalNetworkAllowlist) {
		return "PayloadURL", l.Tr("repo.settings.webhook.url_resolved_to_blocked_local_address"), false
	}
	field, msg, ok := validateWebhook(c.Locale, w)
	field, msg, ok := validateWebhook(c.Locale, w)
			webhook: &db.Webhook{URL: "https://www.google.com"},
			expMsg:   "repo.settings.webhook.url_resolved_to_blocked_local_address",
			field, msg, ok := validateWebhook(l, test.webhook)