VYPR
High severityNVD Advisory· Published Mar 9, 2026· Updated Mar 10, 2026

Pocket ID: OAuth redirect_uri validation bypass via userinfo/host confusion

CVE-2026-28512

Description

Pocket ID is an OIDC provider that allows users to authenticate with their passkeys to your services. From 2.0.0 to before 2.4.0, a flaw in callback URL validation allowed crafted redirect_uri values containing URL userinfo (@) to bypass legitimate callback pattern checks. If an attacker can trick a user into opening a malicious authorization link, the authorization code may be redirected to an attacker-controlled host. This vulnerability is fixed in 2.4.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/pocket-id/pocket-id/backendGo
< 0.0.0-20260228130835-3a339e33191c0.0.0-20260228130835-3a339e33191c

Affected products

1

Patches

1
3a339e33191c

fix: improve wildcard matching by using `go-urlpattern` (#1332)

https://github.com/pocket-id/pocket-idElias SchneiderFeb 28, 2026via ghsa
5 files changed · +229 369
  • backend/go.mod+3 0 modified
    @@ -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
    
  • backend/go.sum+42 0 modified
    @@ -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,41 +393,75 @@ 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=
     golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
     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=
    
  • backend/internal/dto/validations.go+2 17 modified
    @@ -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
     }
    
  • backend/internal/utils/callback_url_util.go+110 102 modified
    @@ -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
    -		}
    +	pattern, patternQuery, err := extractQueryParams(pattern)
    +	if err != nil {
    +		return false, err
     	}
     
    -	// Verify path with wildcard support
    -	matched, err := matchPath(patternPath, inputPath)
    -	if err != nil || !matched {
    +	inputCallbackURL, inputQuery, err := extractQueryParams(inputCallbackURL)
    +	if err != nil {
     		return false, err
     	}
     
    -	// Verify query parameters
    -	if len(patternQuery) != len(inputQuery) {
    +	pattern = normalizeToURLPatternStandard(pattern)
    +
    +	// Validate query params
    +	v := validateQueryParams(patternQuery, inputQuery)
    +	if !v {
     		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
    -			}
    -		}
    +	// 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 true, nil
    +	return p.Test(inputCallbackURL, ""), 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
    +// 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 {
    -				// single * = [^/]* (no slash)
    -				regexPattern.WriteString(`[^/]*`)
    -				i++
    +				// Replace single asterisk with :p{index}
    +				result.WriteString(":p")
    +				result.WriteString(strconv.Itoa(i))
     			}
    -		default:
    -			regexPattern.WriteString(regexp.QuoteMeta(string(runes[i])))
    -			i++
    +		} else {
    +			result.WriteByte(patternPath[i])
     		}
     	}
    +	patternPath = result.String()
     
    -	regexPattern.WriteString("$")
    -
    -	matched, err := regexp.MatchString(regexPattern.String(), input)
    -	return matched, err
    +	return patternBase + patternPath
     }
     
    -// 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 == '.'
    -	}
    -
    +func extractPath(url string) (base string, path string) {
     	pathStart := -1
     
     	// Look for scheme:// first
    -	if i := strings.Index(s, "://"); i >= 0 {
    +	if i := strings.Index(url, "://"); i >= 0 {
     		// Look for the next slash after scheme://
    -		rest := s[i+3:]
    -		if j := strings.IndexRune(rest, '/'); j >= 0 {
    +		rest := url[i+3:]
    +		if j := strings.IndexByte(rest, '/'); j >= 0 {
     			pathStart = i + 3 + j
     		}
     	} else {
     		// Otherwise, first slash is path start
    -		pathStart = strings.IndexRune(s, '/')
    +		pathStart = strings.IndexByte(url, '/')
     	}
     
     	if pathStart >= 0 {
    -		path = s[pathStart:]
    -		base := s[:pathStart]
    -		parts = strings.FieldsFunc(base, split)
    +		path = url[pathStart:]
    +		base = url[:pathStart]
     	} else {
    -		parts = strings.FieldsFunc(s, split)
     		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
    +		}
    +
    +		if len(patternValues) != len(inputValues) {
    +			return false
    +		}
    +
    +		for i := range patternValues {
    +			matched, err := path.Match(patternValues[i], inputValues[i])
    +			if err != nil || !matched {
    +				return false
    +			}
    +		}
     	}
     
    -	return parts, path
    +	return true
     }
    
  • backend/internal/utils/callback_url_util_test.go+72 250 modified
    @@ -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")
    -		})
    -	}
    -}
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.