From 3a339e33191c31b68bf57db907f800d9de5ffbc8 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sat, 28 Feb 2026 14:08:35 +0100 Subject: [PATCH] fix: improve wildcard matching by using `go-urlpattern` (#1332) --- backend/go.mod | 3 + backend/go.sum | 42 +++ backend/internal/dto/validations.go | 19 +- backend/internal/utils/callback_url_util.go | 238 ++++++------- .../internal/utils/callback_url_util_test.go | 322 ++++-------------- 5 files changed, 242 insertions(+), 382 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 9013dc1e..28b6a327 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imaging v1.6.2 + github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 github.com/gin-contrib/slog v1.2.0 @@ -74,6 +75,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.14.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -123,6 +125,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nlnwa/whatwg-url v0.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 49d3a7a9..4486468e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -46,6 +46,9 @@ github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA= +github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -88,6 +91,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s= +github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= @@ -257,6 +262,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nlnwa/whatwg-url v0.5.0 h1:l71cqfqG44+VCQZQX3wD4bwheFWicPxuwaCimLEfpDo= +github.com/nlnwa/whatwg-url v0.5.0/go.mod h1:X/ejnFFVbaOWdSul+cnlsSHviCzGZJdvPkgc9zD8IY8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -320,6 +327,7 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4= @@ -385,6 +393,9 @@ golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= @@ -392,34 +403,65 @@ golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkN golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 8aca787a..135706fa 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -1,9 +1,7 @@ package dto import ( - "net/url" "regexp" - "strings" "time" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -67,19 +65,6 @@ func ValidateClientID(clientID string) bool { // ValidateCallbackURL validates callback URLs with support for wildcards func ValidateCallbackURL(raw string) bool { - // Don't validate if it contains a wildcard - if strings.Contains(raw, "*") { - return true - } - - u, err := url.Parse(raw) - if err != nil { - return false - } - - if !u.IsAbs() { - return false - } - - return true + err := utils.ValidateCallbackURLPattern(raw) + return err == nil } diff --git a/backend/internal/utils/callback_url_util.go b/backend/internal/utils/callback_url_util.go index 4ce7e98b..f4c3306b 100644 --- a/backend/internal/utils/callback_url_util.go +++ b/backend/internal/utils/callback_url_util.go @@ -1,14 +1,31 @@ package utils import ( + "log/slog" "net" "net/url" "path" - "regexp" + "strconv" "strings" + + "github.com/dunglas/go-urlpattern" ) -// GetCallbackURLFromList returns the first callback URL that matches the input callback URL +// ValidateCallbackURLPattern checks if the given callback URL pattern +// is valid according to the rules defined in this package. +func ValidateCallbackURLPattern(pattern string) error { + if pattern == "*" { + return nil + } + + pattern, _, _ = strings.Cut(pattern, "#") + pattern = normalizeToURLPatternStandard(pattern) + + _, err := urlpattern.New(pattern, "", nil) + return err +} + +// 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 @@ -24,7 +41,12 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL host := u.Hostname() ip := net.ParseIP(host) if host == "localhost" || (ip != nil && ip.IsLoopback()) { - u.Host = host + // For IPv6 loopback hosts, brackets are required when serializing without a port. + if strings.Contains(host, ":") { + u.Host = "[" + host + "]" + } else { + u.Host = host + } loopbackCallbackURLWithoutPort = u.String() } } @@ -64,143 +86,129 @@ func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, er return true, nil } - // Strip fragment part + // 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 { + pattern, patternQuery, err := extractQueryParams(pattern) + if err != nil { return false, err } - // Verify query parameters - if len(patternQuery) != len(inputQuery) { + inputCallbackURL, inputQuery, err := extractQueryParams(inputCallbackURL) + if err != nil { + return false, err + } + + pattern = normalizeToURLPatternStandard(pattern) + + // Validate query params + v := validateQueryParams(patternQuery, inputQuery) + if !v { return false, nil } + // Validate the rest of the URL using urlpattern + p, err := urlpattern.New(pattern, "", nil) + if err != nil { + //nolint:nilerr + slog.Warn("invalid callback URL pattern, skipping", "pattern", pattern, "error", err) + return false, nil + } + + return p.Test(inputCallbackURL, ""), nil +} + +// normalizeToURLPatternStandard converts patterns with single asterisk wildcards and globstar wildcards +// into a format that can be parsed by the urlpattern package, which uses :param for single segment wildcards +// and ** for multi-segment wildcards. +func normalizeToURLPatternStandard(pattern string) string { + patternBase, patternPath := extractPath(pattern) + + var result strings.Builder + for i := 0; i < len(patternPath); i++ { + if patternPath[i] == '*' { + // Replace globstar with a single asterisk + if i+1 < len(patternPath) && patternPath[i+1] == '*' { + result.WriteString("*") + i++ // skip next * + } else { + // Replace single asterisk with :p{index} + result.WriteString(":p") + result.WriteString(strconv.Itoa(i)) + } + } else { + result.WriteByte(patternPath[i]) + } + } + patternPath = result.String() + + return patternBase + patternPath +} + +func extractPath(url string) (base string, path string) { + pathStart := -1 + + // Look for scheme:// first + if i := strings.Index(url, "://"); i >= 0 { + // Look for the next slash after scheme:// + rest := url[i+3:] + if j := strings.IndexByte(rest, '/'); j >= 0 { + pathStart = i + 3 + j + } + } else { + // Otherwise, first slash is path start + pathStart = strings.IndexByte(url, '/') + } + + if pathStart >= 0 { + path = url[pathStart:] + base = url[:pathStart] + } else { + path = "" + base = url + } + + return base, path +} + +func extractQueryParams(rawUrl string) (base string, query url.Values, err error) { + if i := strings.IndexByte(rawUrl, '?'); i >= 0 { + query, err = url.ParseQuery(rawUrl[i+1:]) + if err != nil { + return "", nil, err + } + rawUrl = rawUrl[:i] + } + + return rawUrl, query, nil +} + +func validateQueryParams(patternQuery, inputQuery url.Values) bool { + if len(patternQuery) != len(inputQuery) { + return false + } + for patternKey, patternValues := range patternQuery { inputValues, exists := inputQuery[patternKey] if !exists { - return false, nil + return false } if len(patternValues) != len(inputValues) { - return false, nil + return false } for i := range patternValues { matched, err := path.Match(patternValues[i], inputValues[i]) if err != nil || !matched { - return false, err + return false } } } - 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 + return true } diff --git a/backend/internal/utils/callback_url_util_test.go b/backend/internal/utils/callback_url_util_test.go index 959a8789..c3423dde 100644 --- a/backend/internal/utils/callback_url_util_test.go +++ b/backend/internal/utils/callback_url_util_test.go @@ -7,6 +7,77 @@ import ( "github.com/stretchr/testify/require" ) +func TestValidateCallbackURLPattern(t *testing.T) { + tests := []struct { + name string + pattern string + shouldError bool + }{ + { + name: "exact URL", + pattern: "https://example.com/callback", + shouldError: false, + }, + { + name: "wildcard scheme", + pattern: "*://example.com/callback", + shouldError: false, + }, + { + name: "wildcard port", + pattern: "https://example.com:*/callback", + shouldError: false, + }, + { + name: "partial wildcard port", + pattern: "https://example.com:80*/callback", + shouldError: false, + }, + { + name: "wildcard userinfo", + pattern: "https://user:*@example.com/callback", + shouldError: false, + }, + { + name: "glob wildcard", + pattern: "*", + shouldError: false, + }, + { + name: "relative URL", + pattern: "/callback", + shouldError: true, + }, + { + name: "missing scheme separator", + pattern: "https//example.com/callback", + shouldError: true, + }, + { + name: "malformed wildcard host glob", + pattern: "https://exa[mple.com/callback", + shouldError: true, + }, + { + name: "malformed authority", + pattern: "https://[::1/callback", + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateCallbackURLPattern(tt.pattern) + if tt.shouldError { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} + func TestMatchCallbackURL(t *testing.T) { tests := []struct { name string @@ -187,12 +258,6 @@ func TestMatchCallbackURL(t *testing.T) { "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", @@ -347,7 +412,7 @@ func TestMatchCallbackURL(t *testing.T) { "backslash instead of forward slash", "https://example.com/callback", "https://example.com\\callback", - false, + true, }, { "double slash in hostname (protocol smuggling)", @@ -553,246 +618,3 @@ func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) { }) } } - -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") - }) - } -}