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
This commit is contained in:
Tink bot
2026-05-07 21:45:37 +00:00
committed by kolaente
parent 812fa11b9b
commit c6bda7a2dd
2 changed files with 38 additions and 7 deletions

View File

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

View File

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