From c6bda7a2ddc50cee3db0d1a16afe7d470093d877 Mon Sep 17 00:00:00 2001 From: Tink bot Date: Thu, 7 May 2026 21:45:37 +0000 Subject: [PATCH] feat(oauth2server): accept loopback redirect URIs Previously the OAuth server rejected every redirect_uri that did not start with a vikunja- custom scheme. Native apps that cannot register a custom scheme (e.g. CLIs, desktop tools) need loopback redirects per RFC 8252, so also allow http://localhost, http://127.0.0.1 and http://[::1] (any port). Non-loopback http:// and https:// targets remain rejected. https://claude.ai/code/session_01LsTDrCJ7trE6WQ4FYf78UB --- pkg/modules/auth/oauth2server/client.go | 20 ++++++++++++---- pkg/modules/auth/oauth2server/client_test.go | 25 ++++++++++++++++++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/pkg/modules/auth/oauth2server/client.go b/pkg/modules/auth/oauth2server/client.go index 44e381958..cad975731 100644 --- a/pkg/modules/auth/oauth2server/client.go +++ b/pkg/modules/auth/oauth2server/client.go @@ -21,15 +21,25 @@ import ( "strings" ) -// ValidateRedirectURI checks that the redirect_uri uses a scheme starting with -// "vikunja-". This allowlists only Vikunja native app schemes (e.g. -// vikunja-flutter://callback) and rejects dangerous schemes like javascript:, -// data:, http:, https:, etc. +// ValidateRedirectURI checks that the redirect_uri is either a Vikunja native +// app scheme (e.g. vikunja-flutter://callback) or a loopback http URL as +// recommended by RFC 8252 for native apps that cannot register a custom +// scheme. Dangerous schemes like javascript:, data:, https://, or non-loopback +// http:// targets are rejected. func ValidateRedirectURI(redirectURI string) bool { u, err := url.Parse(redirectURI) if err != nil || u.Scheme == "" { return false } - return strings.HasPrefix(u.Scheme, "vikunja-") + if strings.HasPrefix(u.Scheme, "vikunja-") { + return true + } + + if u.Scheme == "http" { + host := u.Hostname() + return host == "localhost" || host == "127.0.0.1" || host == "::1" + } + + return false } diff --git a/pkg/modules/auth/oauth2server/client_test.go b/pkg/modules/auth/oauth2server/client_test.go index 328e1fb55..40d602595 100644 --- a/pkg/modules/auth/oauth2server/client_test.go +++ b/pkg/modules/auth/oauth2server/client_test.go @@ -29,11 +29,32 @@ func TestValidateRedirectURI(t *testing.T) { t.Run("accepts vikunja-desktop scheme", func(t *testing.T) { assert.True(t, ValidateRedirectURI("vikunja-desktop://auth")) }) + t.Run("accepts http localhost", func(t *testing.T) { + assert.True(t, ValidateRedirectURI("http://localhost/callback")) + }) + t.Run("accepts http localhost with port", func(t *testing.T) { + assert.True(t, ValidateRedirectURI("http://localhost:8080/callback")) + }) + t.Run("accepts http 127.0.0.1", func(t *testing.T) { + assert.True(t, ValidateRedirectURI("http://127.0.0.1:8080/callback")) + }) + t.Run("accepts http ipv6 loopback", func(t *testing.T) { + assert.True(t, ValidateRedirectURI("http://[::1]:8080/callback")) + }) t.Run("rejects https scheme", func(t *testing.T) { assert.False(t, ValidateRedirectURI("https://evil.com/callback")) }) - t.Run("rejects http scheme", func(t *testing.T) { - assert.False(t, ValidateRedirectURI("http://localhost/callback")) + t.Run("rejects https localhost", func(t *testing.T) { + assert.False(t, ValidateRedirectURI("https://localhost/callback")) + }) + t.Run("rejects non-loopback http", func(t *testing.T) { + assert.False(t, ValidateRedirectURI("http://evil.com/callback")) + }) + t.Run("rejects http with localhost in path", func(t *testing.T) { + assert.False(t, ValidateRedirectURI("http://evil.com/localhost")) + }) + t.Run("rejects http with localhost subdomain trick", func(t *testing.T) { + assert.False(t, ValidateRedirectURI("http://localhost.evil.com/callback")) }) t.Run("rejects javascript scheme", func(t *testing.T) { assert.False(t, ValidateRedirectURI("javascript:alert(1)"))