mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-23 05:24:50 +03:00
fix: make wildcard matching in callback URLs more strict
This commit is contained in:
@@ -15,7 +15,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -1196,7 +1195,7 @@ func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL
|
|||||||
|
|
||||||
// If URLs are already configured, validate against them
|
// If URLs are already configured, validate against them
|
||||||
if len(client.CallbackURLs) > 0 {
|
if len(client.CallbackURLs) > 0 {
|
||||||
matched, err := s.getCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
|
matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
} else if matched == "" {
|
} else if matched == "" {
|
||||||
@@ -1219,7 +1218,7 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout
|
|||||||
return client.LogoutCallbackURLs[0], nil
|
return client.LogoutCallbackURLs[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
matched, err := s.getCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
|
matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
} else if matched == "" {
|
} else if matched == "" {
|
||||||
@@ -1229,21 +1228,6 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout
|
|||||||
return matched, nil
|
return matched, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) getCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
|
||||||
for _, callbackPattern := range urls {
|
|
||||||
regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
|
||||||
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if matched {
|
|
||||||
return inputCallbackURL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
|
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
|
||||||
// Add the new callback URL to the existing list
|
// Add the new callback URL to the existing list
|
||||||
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
|
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
|
||||||
|
|||||||
199
backend/internal/utils/callback_url_util.go
Normal file
199
backend/internal/utils/callback_url_util.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL
|
||||||
|
func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
|
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
|
||||||
|
//
|
||||||
|
// The authorization server MUST allow any port to be specified at the
|
||||||
|
// time of the request for loopback IP redirect URIs, to accommodate
|
||||||
|
// clients that obtain an available ephemeral port from the operating
|
||||||
|
// system at the time of the request.
|
||||||
|
loopbackRedirect := ""
|
||||||
|
u, _ := url.Parse(inputCallbackURL)
|
||||||
|
|
||||||
|
if u != nil && u.Scheme == "http" {
|
||||||
|
host := u.Hostname()
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if host == "localhost" || (ip != nil && ip.IsLoopback()) {
|
||||||
|
loopbackRedirect = u.String()
|
||||||
|
u.Host = host
|
||||||
|
inputCallbackURL = u.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range urls {
|
||||||
|
matches, err := matchCallbackURL(pattern, inputCallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if !matches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if loopbackRedirect != "" {
|
||||||
|
return loopbackRedirect, nil
|
||||||
|
}
|
||||||
|
return inputCallbackURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchCallbackURL checks if the input callback URL matches the given pattern.
|
||||||
|
// It supports wildcard matching for paths and query parameters.
|
||||||
|
//
|
||||||
|
// The base URL (scheme, userinfo, host, port) and query parameters supports single '*' wildcards only,
|
||||||
|
// while the path supports both single '*' and double '**' wildcards.
|
||||||
|
func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, err error) {
|
||||||
|
if pattern == inputCallbackURL || pattern == "*" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip fragment part
|
||||||
|
// The endpoint URI MUST NOT include a fragment component.
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
|
||||||
|
pattern, _, _ = strings.Cut(pattern, "#")
|
||||||
|
inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#")
|
||||||
|
|
||||||
|
// Store and strip query part
|
||||||
|
var patternQuery url.Values
|
||||||
|
if i := strings.Index(pattern, "?"); i >= 0 {
|
||||||
|
patternQuery, err = url.ParseQuery(pattern[i+1:])
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
pattern = pattern[:i]
|
||||||
|
}
|
||||||
|
var inputQuery url.Values
|
||||||
|
if i := strings.Index(inputCallbackURL, "?"); i >= 0 {
|
||||||
|
inputQuery, err = url.ParseQuery(inputCallbackURL[i+1:])
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
inputCallbackURL = inputCallbackURL[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split both pattern and input parts
|
||||||
|
patternParts, patternPath := splitParts(pattern)
|
||||||
|
inputParts, inputPath := splitParts(inputCallbackURL)
|
||||||
|
|
||||||
|
// Verify everything except the path and query parameters
|
||||||
|
if len(patternParts) != len(inputParts) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, patternPart := range patternParts {
|
||||||
|
matched, err := path.Match(patternPart, inputParts[i])
|
||||||
|
if err != nil || !matched {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify path with wildcard support
|
||||||
|
matched, err := matchPath(patternPath, inputPath)
|
||||||
|
if err != nil || !matched {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify query parameters
|
||||||
|
if len(patternQuery) != len(inputQuery) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for patternKey, patternValues := range patternQuery {
|
||||||
|
inputValues, exists := inputQuery[patternKey]
|
||||||
|
if !exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(patternValues) != len(inputValues) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range patternValues {
|
||||||
|
matched, err := path.Match(patternValues[i], inputValues[i])
|
||||||
|
if err != nil || !matched {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPath matches the input path against the pattern with wildcard support
|
||||||
|
// Supported wildcards:
|
||||||
|
//
|
||||||
|
// '*' matches any sequence of characters except '/'
|
||||||
|
// '**' matches any sequence of characters including '/'
|
||||||
|
func matchPath(pattern string, input string) (matches bool, err error) {
|
||||||
|
var regexPattern strings.Builder
|
||||||
|
regexPattern.WriteString("^")
|
||||||
|
|
||||||
|
runes := []rune(pattern)
|
||||||
|
n := len(runes)
|
||||||
|
|
||||||
|
for i := 0; i < n; {
|
||||||
|
switch runes[i] {
|
||||||
|
case '*':
|
||||||
|
// Check if it's a ** (globstar)
|
||||||
|
if i+1 < n && runes[i+1] == '*' {
|
||||||
|
// globstar = .* (match slashes too)
|
||||||
|
regexPattern.WriteString(".*")
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
// single * = [^/]* (no slash)
|
||||||
|
regexPattern.WriteString(`[^/]*`)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
regexPattern.WriteString(regexp.QuoteMeta(string(runes[i])))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
regexPattern.WriteString("$")
|
||||||
|
|
||||||
|
matched, err := regexp.MatchString(regexPattern.String(), input)
|
||||||
|
return matched, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitParts splits the URL into parts by special characters and returns the path separately
|
||||||
|
func splitParts(s string) (parts []string, path string) {
|
||||||
|
split := func(r rune) bool {
|
||||||
|
return r == ':' || r == '/' || r == '[' || r == ']' || r == '@' || r == '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
pathStart := -1
|
||||||
|
|
||||||
|
// Look for scheme:// first
|
||||||
|
if i := strings.Index(s, "://"); i >= 0 {
|
||||||
|
// Look for the next slash after scheme://
|
||||||
|
rest := s[i+3:]
|
||||||
|
if j := strings.IndexRune(rest, '/'); j >= 0 {
|
||||||
|
pathStart = i + 3 + j
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise, first slash is path start
|
||||||
|
pathStart = strings.IndexRune(s, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
if pathStart >= 0 {
|
||||||
|
path = s[pathStart:]
|
||||||
|
base := s[:pathStart]
|
||||||
|
parts = strings.FieldsFunc(base, split)
|
||||||
|
} else {
|
||||||
|
parts = strings.FieldsFunc(s, split)
|
||||||
|
path = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts, path
|
||||||
|
}
|
||||||
784
backend/internal/utils/callback_url_util_test.go
Normal file
784
backend/internal/utils/callback_url_util_test.go
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchCallbackURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pattern string
|
||||||
|
input string
|
||||||
|
shouldMatch bool
|
||||||
|
}{
|
||||||
|
// Basic matching
|
||||||
|
{
|
||||||
|
"exact match",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no match",
|
||||||
|
"https://example.org/callback",
|
||||||
|
"https://example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scheme
|
||||||
|
{
|
||||||
|
"scheme mismatch",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"http://example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard scheme",
|
||||||
|
"*://example.com/callback",
|
||||||
|
"https://example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hostname
|
||||||
|
{
|
||||||
|
"hostname mismatch",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://malicious.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard subdomain",
|
||||||
|
"https://*.example.com/callback",
|
||||||
|
"https://subdomain.example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in hostname prefix",
|
||||||
|
"https://app*.example.com/callback",
|
||||||
|
"https://app1.example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in hostname suffix",
|
||||||
|
"https://*-prod.example.com/callback",
|
||||||
|
"https://api-prod.example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in hostname middle",
|
||||||
|
"https://app-*-server.example.com/callback",
|
||||||
|
"https://app-staging-server.example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subdomain wildcard doesn't match domain hijack attempt",
|
||||||
|
"https://*.example.com/callback",
|
||||||
|
"https://malicious.site?url=abc.example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname mismatch with confusable characters",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://examp1e.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname mismatch with homograph attack",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://еxample.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Port
|
||||||
|
{
|
||||||
|
"port mismatch",
|
||||||
|
"https://example.com:8080/callback",
|
||||||
|
"https://example.com:9090/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard port",
|
||||||
|
"https://example.com:*/callback",
|
||||||
|
"https://example.com:8080/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in port prefix",
|
||||||
|
"https://example.com:80*/callback",
|
||||||
|
"https://example.com:8080/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Path
|
||||||
|
{
|
||||||
|
"path mismatch",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/other",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard path segment",
|
||||||
|
"https://example.com/api/*/callback",
|
||||||
|
"https://example.com/api/v1/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard entire path",
|
||||||
|
"https://example.com/*",
|
||||||
|
"https://example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in path prefix",
|
||||||
|
"https://example.com/test*",
|
||||||
|
"https://example.com/testcase",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in path suffix",
|
||||||
|
"https://example.com/*-callback",
|
||||||
|
"https://example.com/oauth-callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in path middle",
|
||||||
|
"https://example.com/api-*-v1/callback",
|
||||||
|
"https://example.com/api-internal-v1/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple partial wildcards in path",
|
||||||
|
"https://example.com/*/test*/callback",
|
||||||
|
"https://example.com/v1/testing/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple wildcard segments in path",
|
||||||
|
"https://example.com/**/callback",
|
||||||
|
"https://example.com/api/v1/foo/bar/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple wildcard segments in path",
|
||||||
|
"https://example.com/**/v1/**/callback",
|
||||||
|
"https://example.com/api/v1/foo/bar/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard matching full path segment",
|
||||||
|
"https://example.com/foo-*",
|
||||||
|
"https://example.com/foo-bar",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Credentials
|
||||||
|
{
|
||||||
|
"username mismatch",
|
||||||
|
"https://user:pass@example.com/callback",
|
||||||
|
"https://admin:pass@example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"missing credentials",
|
||||||
|
"https://user:pass@example.com/callback",
|
||||||
|
"https://example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unexpected credentials",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://user:pass@example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard password",
|
||||||
|
"https://user:*@example.com/callback",
|
||||||
|
"https://user:secret123@example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in username",
|
||||||
|
"https://admin*:pass@example.com/callback",
|
||||||
|
"https://admin123:pass@example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partial wildcard in password",
|
||||||
|
"https://user:pass*@example.com/callback",
|
||||||
|
"https://user:password123@example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard password doesn't allow domain hijack",
|
||||||
|
"https://user:*@example.com/callback",
|
||||||
|
"https://user:password@malicious.site#example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"credentials with @ in password trying to hijack hostname",
|
||||||
|
"https://user:pass@example.com/callback",
|
||||||
|
"https://user:pass@evil.com@example.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Query parameters
|
||||||
|
{
|
||||||
|
"extra query parameter",
|
||||||
|
"https://example.com/callback?code=*",
|
||||||
|
"https://example.com/callback?code=abc123&extra=value",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"missing query parameter",
|
||||||
|
"https://example.com/callback?code=*&state=*",
|
||||||
|
"https://example.com/callback?code=abc123",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameter after fragment",
|
||||||
|
"https://example.com/callback?code=123",
|
||||||
|
"https://example.com/callback#section?code=123",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameter name mismatch",
|
||||||
|
"https://example.com/callback?code=*",
|
||||||
|
"https://example.com/callback?token=abc123",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wildcard query parameter",
|
||||||
|
"https://example.com/callback?code=*",
|
||||||
|
"https://example.com/callback?code=abc123",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple query parameters",
|
||||||
|
"https://example.com/callback?code=*&state=*",
|
||||||
|
"https://example.com/callback?code=abc123&state=xyz789",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameters in different order",
|
||||||
|
"https://example.com/callback?state=*&code=*",
|
||||||
|
"https://example.com/callback?code=abc123&state=xyz789",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"exact query parameter value",
|
||||||
|
"https://example.com/callback?mode=production",
|
||||||
|
"https://example.com/callback?mode=production",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameter value mismatch",
|
||||||
|
"https://example.com/callback?mode=production",
|
||||||
|
"https://example.com/callback?mode=development",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mixed exact and wildcard query parameters",
|
||||||
|
"https://example.com/callback?mode=production&code=*",
|
||||||
|
"https://example.com/callback?mode=production&code=abc123",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mixed exact and wildcard with wrong exact value",
|
||||||
|
"https://example.com/callback?mode=production&code=*",
|
||||||
|
"https://example.com/callback?mode=development&code=abc123",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple values for same parameter",
|
||||||
|
"https://example.com/callback?param=*¶m=*",
|
||||||
|
"https://example.com/callback?param=value1¶m=value2",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unexpected query parameters",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback?extra=value",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query parameter with redirect to external site",
|
||||||
|
"https://example.com/callback?code=*",
|
||||||
|
"https://example.com/callback?code=123&redirect=https://evil.com",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"open redirect via encoded URL in query param",
|
||||||
|
"https://example.com/callback?state=*",
|
||||||
|
"https://example.com/callback?state=abc&next=//evil.com",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fragment
|
||||||
|
{
|
||||||
|
"fragment ignored when both pattern and input have fragment",
|
||||||
|
"https://example.com/callback#fragment",
|
||||||
|
"https://example.com/callback#fragment",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fragment ignored when pattern has fragment but input doesn't",
|
||||||
|
"https://example.com/callback#fragment",
|
||||||
|
"https://example.com/callback",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fragment ignored when input has fragment but pattern doesn't",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback#section",
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Path traversal and injection attempts
|
||||||
|
{
|
||||||
|
"path traversal attempt",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/../admin/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"backslash instead of forward slash",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com\\callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"double slash in hostname (protocol smuggling)",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com//evil.com/callback",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"CRLF injection attempt in path",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback%0d%0aLocation:%20https://evil.com",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"null byte injection",
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.com/callback%00.evil.com",
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
matches, err := matchCallbackURL(tt.pattern, tt.input)
|
||||||
|
require.NoError(t, err, tt.name)
|
||||||
|
assert.Equal(t, tt.shouldMatch, matches, tt.name)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urls []string
|
||||||
|
inputCallbackURL string
|
||||||
|
expectedURL string
|
||||||
|
expectMatch bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "127.0.0.1 with dynamic port - exact match",
|
||||||
|
urls: []string{"http://127.0.0.1/callback"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:8080/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "127.0.0.1 with different port",
|
||||||
|
urls: []string{"http://127.0.0.1/callback"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:9999/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:9999/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback with dynamic port",
|
||||||
|
urls: []string{"http://[::1]/callback"},
|
||||||
|
inputCallbackURL: "http://[::1]:8080/callback",
|
||||||
|
expectedURL: "http://[::1]:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback without brackets in input",
|
||||||
|
urls: []string{"http://[::1]/callback"},
|
||||||
|
inputCallbackURL: "http://::1:8080/callback",
|
||||||
|
expectedURL: "http://::1:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "localhost with dynamic port",
|
||||||
|
urls: []string{"http://localhost/callback"},
|
||||||
|
inputCallbackURL: "http://localhost:8080/callback",
|
||||||
|
expectedURL: "http://localhost:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "https loopback doesn't trigger special handling",
|
||||||
|
urls: []string{"https://127.0.0.1/callback"},
|
||||||
|
inputCallbackURL: "https://127.0.0.1:8080/callback",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loopback with path match",
|
||||||
|
urls: []string{"http://127.0.0.1/auth/*"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:3000/auth/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:3000/auth/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "loopback with path mismatch",
|
||||||
|
urls: []string{"http://127.0.0.1/callback"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:8080/different",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-loopback IP",
|
||||||
|
urls: []string{"http://192.168.1.1/callback"},
|
||||||
|
inputCallbackURL: "http://192.168.1.1:8080/callback",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard matches loopback",
|
||||||
|
urls: []string{"*"},
|
||||||
|
inputCallbackURL: "http://127.0.0.1:8080/callback",
|
||||||
|
expectedURL: "http://127.0.0.1:8080/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := GetCallbackURLFromList(tt.urls, tt.inputCallbackURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.expectMatch {
|
||||||
|
assert.Equal(t, tt.expectedURL, result)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urls []string
|
||||||
|
inputCallbackURL string
|
||||||
|
expectedURL string
|
||||||
|
expectMatch bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "matches first pattern",
|
||||||
|
urls: []string{
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.org/callback",
|
||||||
|
},
|
||||||
|
inputCallbackURL: "https://example.com/callback",
|
||||||
|
expectedURL: "https://example.com/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches second pattern",
|
||||||
|
urls: []string{
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.org/callback",
|
||||||
|
},
|
||||||
|
inputCallbackURL: "https://example.org/callback",
|
||||||
|
expectedURL: "https://example.org/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches none",
|
||||||
|
urls: []string{
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://example.org/callback",
|
||||||
|
},
|
||||||
|
inputCallbackURL: "https://malicious.com/callback",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches wildcard pattern",
|
||||||
|
urls: []string{
|
||||||
|
"https://example.com/callback",
|
||||||
|
"https://*.example.org/callback",
|
||||||
|
},
|
||||||
|
inputCallbackURL: "https://subdomain.example.org/callback",
|
||||||
|
expectedURL: "https://subdomain.example.org/callback",
|
||||||
|
expectMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty pattern list",
|
||||||
|
urls: []string{},
|
||||||
|
inputCallbackURL: "https://example.com/callback",
|
||||||
|
expectedURL: "",
|
||||||
|
expectMatch: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := GetCallbackURLFromList(tt.urls, tt.inputCallbackURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.expectMatch {
|
||||||
|
assert.Equal(t, tt.expectedURL, result)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pattern string
|
||||||
|
input string
|
||||||
|
shouldMatch bool
|
||||||
|
}{
|
||||||
|
// Exact matches
|
||||||
|
{
|
||||||
|
name: "exact match",
|
||||||
|
pattern: "/callback",
|
||||||
|
input: "/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact mismatch",
|
||||||
|
pattern: "/callback",
|
||||||
|
input: "/other",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty paths",
|
||||||
|
pattern: "",
|
||||||
|
input: "",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Single wildcard (*)
|
||||||
|
{
|
||||||
|
name: "single wildcard matches segment",
|
||||||
|
pattern: "/api/*/callback",
|
||||||
|
input: "/api/v1/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single wildcard doesn't match multiple segments",
|
||||||
|
pattern: "/api/*/callback",
|
||||||
|
input: "/api/v1/v2/callback",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single wildcard at end",
|
||||||
|
pattern: "/callback/*",
|
||||||
|
input: "/callback/test",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single wildcard at start",
|
||||||
|
pattern: "/*/callback",
|
||||||
|
input: "/api/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple single wildcards",
|
||||||
|
pattern: "/*/test/*",
|
||||||
|
input: "/api/test/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial wildcard prefix",
|
||||||
|
pattern: "/test*",
|
||||||
|
input: "/testing",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial wildcard suffix",
|
||||||
|
pattern: "/*-callback",
|
||||||
|
input: "/oauth-callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial wildcard middle",
|
||||||
|
pattern: "/api-*-v1",
|
||||||
|
input: "/api-internal-v1",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Double wildcard (**)
|
||||||
|
{
|
||||||
|
name: "double wildcard matches multiple segments",
|
||||||
|
pattern: "/api/**/callback",
|
||||||
|
input: "/api/v1/v2/v3/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double wildcard matches single segment",
|
||||||
|
pattern: "/api/**/callback",
|
||||||
|
input: "/api/v1/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double wildcard doesn't match when pattern has extra slashes",
|
||||||
|
pattern: "/api/**/callback",
|
||||||
|
input: "/api/callback",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double wildcard at end",
|
||||||
|
pattern: "/api/**",
|
||||||
|
input: "/api/v1/v2/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double wildcard in middle",
|
||||||
|
pattern: "/api/**/v2/**/callback",
|
||||||
|
input: "/api/v1/v2/v3/v4/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Complex patterns
|
||||||
|
{
|
||||||
|
name: "mix of single and double wildcards",
|
||||||
|
pattern: "/*/api/**/callback",
|
||||||
|
input: "/app/api/v1/v2/callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard with special characters",
|
||||||
|
pattern: "/callback-*",
|
||||||
|
input: "/callback-123",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with query-like string (no special handling)",
|
||||||
|
pattern: "/callback?code=*",
|
||||||
|
input: "/callback?code=abc",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{
|
||||||
|
name: "single wildcard matches empty segment",
|
||||||
|
pattern: "/api/*/callback",
|
||||||
|
input: "/api//callback",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pattern longer than input",
|
||||||
|
pattern: "/api/v1/callback",
|
||||||
|
input: "/api",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "input longer than pattern",
|
||||||
|
pattern: "/api",
|
||||||
|
input: "/api/v1/callback",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
matches, err := matchPath(tt.pattern, tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.shouldMatch, matches)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitParts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectedParts []string
|
||||||
|
expectedPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple https URL",
|
||||||
|
input: "https://example.com/callback",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with port",
|
||||||
|
input: "https://example.com:8080/callback",
|
||||||
|
expectedParts: []string{"https", "example", "com", "8080"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with subdomain",
|
||||||
|
input: "https://api.example.com/callback",
|
||||||
|
expectedParts: []string{"https", "api", "example", "com"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with credentials",
|
||||||
|
input: "https://user:pass@example.com/callback",
|
||||||
|
expectedParts: []string{"https", "user", "pass", "example", "com"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL without path",
|
||||||
|
input: "https://example.com",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with deep path",
|
||||||
|
input: "https://example.com/api/v1/callback",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "/api/v1/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with path and query",
|
||||||
|
input: "https://example.com/callback?code=123",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "/callback?code=123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with trailing slash",
|
||||||
|
input: "https://example.com/",
|
||||||
|
expectedParts: []string{"https", "example", "com"},
|
||||||
|
expectedPath: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with multiple subdomains",
|
||||||
|
input: "https://api.v1.staging.example.com/callback",
|
||||||
|
expectedParts: []string{"https", "api", "v1", "staging", "example", "com"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with port and credentials",
|
||||||
|
input: "https://user:pass@example.com:8080/callback",
|
||||||
|
expectedParts: []string{"https", "user", "pass", "example", "com", "8080"},
|
||||||
|
expectedPath: "/callback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scheme with authority separator but no slash",
|
||||||
|
input: "http://example.com",
|
||||||
|
expectedParts: []string{"http", "example", "com"},
|
||||||
|
expectedPath: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
parts, path := splitParts(tt.input)
|
||||||
|
assert.Equal(t, tt.expectedParts, parts, "parts mismatch")
|
||||||
|
assert.Equal(t, tt.expectedPath, path, "path mismatch")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -349,8 +349,8 @@
|
|||||||
"login_code_email_success": "The login code has been sent to the user.",
|
"login_code_email_success": "The login code has been sent to the user.",
|
||||||
"send_email": "Send Email",
|
"send_email": "Send Email",
|
||||||
"show_code": "Show Code",
|
"show_code": "Show Code",
|
||||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> are supported.",
|
||||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
"logout_callback_url_description": "URL(s) provided by your client for logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> are supported.",
|
||||||
"api_key_expiration": "API Key Expiration",
|
"api_key_expiration": "API Key Expiration",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
||||||
"authorize_device": "Authorize Device",
|
"authorize_device": "Authorize Device",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { LucideExternalLink } from '@lucide/svelte';
|
import { LucideExternalLink } from '@lucide/svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
import FormattedMessage from '../formatted-message.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
input = $bindable(),
|
input = $bindable(),
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-muted-foreground mt-1 text-xs">
|
<p class="text-muted-foreground mt-1 text-xs">
|
||||||
{description}
|
<FormattedMessage m={description} />
|
||||||
{#if docsLink}
|
{#if docsLink}
|
||||||
<a
|
<a
|
||||||
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white dark:text-white"
|
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white dark:text-white"
|
||||||
|
|||||||
Reference in New Issue
Block a user