fix(oauth2server): accept all loopback redirect forms

Hardcoding the three exact strings localhost / 127.0.0.1 / ::1 rejected
legitimate loopback redirects like 127.0.0.2:1234 (anywhere in 127.0.0.0/8)
or [0:0:0:0:0:0:0:1]:1234 (expanded IPv6 loopback). Use net.IP.IsLoopback()
to cover the full loopback ranges, and match "localhost" case-insensitively.
0.0.0.0 stays rejected as it is not a loopback address.

https://claude.ai/code/session_01LsTDrCJ7trE6WQ4FYf78UB
This commit is contained in:
Tink bot 2026-05-07 21:52:18 +00:00 committed by kolaente
parent c6bda7a2dd
commit aa1956e1aa
2 changed files with 23 additions and 3 deletions

View File

@ -17,6 +17,7 @@
package oauth2server package oauth2server
import ( import (
"net"
"net/url" "net/url"
"strings" "strings"
) )
@ -24,8 +25,10 @@ import (
// ValidateRedirectURI checks that the redirect_uri is either a Vikunja native // ValidateRedirectURI checks that the redirect_uri is either a Vikunja native
// app scheme (e.g. vikunja-flutter://callback) or a loopback http URL as // 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 // recommended by RFC 8252 for native apps that cannot register a custom
// scheme. Dangerous schemes like javascript:, data:, https://, or non-loopback // scheme. Any address in 127.0.0.0/8, the IPv6 loopback (::1, in any
// http:// targets are rejected. // notation), and the literal hostname "localhost" are accepted; dangerous
// schemes like javascript:, data:, https://, or non-loopback http:// targets
// are rejected.
func ValidateRedirectURI(redirectURI string) bool { func ValidateRedirectURI(redirectURI string) bool {
u, err := url.Parse(redirectURI) u, err := url.Parse(redirectURI)
if err != nil || u.Scheme == "" { if err != nil || u.Scheme == "" {
@ -38,7 +41,12 @@ func ValidateRedirectURI(redirectURI string) bool {
if u.Scheme == "http" { if u.Scheme == "http" {
host := u.Hostname() host := u.Hostname()
return host == "localhost" || host == "127.0.0.1" || host == "::1" if strings.EqualFold(host, "localhost") {
return true
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return true
}
} }
return false return false

View File

@ -38,9 +38,21 @@ func TestValidateRedirectURI(t *testing.T) {
t.Run("accepts http 127.0.0.1", func(t *testing.T) { t.Run("accepts http 127.0.0.1", func(t *testing.T) {
assert.True(t, ValidateRedirectURI("http://127.0.0.1:8080/callback")) assert.True(t, ValidateRedirectURI("http://127.0.0.1:8080/callback"))
}) })
t.Run("accepts other 127.0.0.0/8 loopback addresses", func(t *testing.T) {
assert.True(t, ValidateRedirectURI("http://127.0.0.2:1234/callback"))
})
t.Run("accepts http ipv6 loopback", func(t *testing.T) { t.Run("accepts http ipv6 loopback", func(t *testing.T) {
assert.True(t, ValidateRedirectURI("http://[::1]:8080/callback")) assert.True(t, ValidateRedirectURI("http://[::1]:8080/callback"))
}) })
t.Run("accepts expanded ipv6 loopback", func(t *testing.T) {
assert.True(t, ValidateRedirectURI("http://[0:0:0:0:0:0:0:1]:1234/callback"))
})
t.Run("accepts localhost case-insensitively", func(t *testing.T) {
assert.True(t, ValidateRedirectURI("http://LocalHost:8080/callback"))
})
t.Run("rejects 0.0.0.0", func(t *testing.T) {
assert.False(t, ValidateRedirectURI("http://0.0.0.0:8080/callback"))
})
t.Run("rejects https scheme", func(t *testing.T) { t.Run("rejects https scheme", func(t *testing.T) {
assert.False(t, ValidateRedirectURI("https://evil.com/callback")) assert.False(t, ValidateRedirectURI("https://evil.com/callback"))
}) })