Compare commits

..

1 Commits

Author SHA1 Message Date
ItalyPaleAle
76da41f126 fix: disable callback URLs with protocols "javascript" and "data" 2026-03-26 20:18:13 -07:00
6 changed files with 103 additions and 54 deletions

View File

@@ -89,12 +89,19 @@ type OidcController struct {
// @Router /api/oidc/authorize [post] // @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) { func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil { err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent()) code, callbackURL, err := oc.oidcService.Authorize(
c.Request.Context(),
input,
c.GetString("userID"),
c.ClientIP(),
c.Request.UserAgent(),
)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return

View File

@@ -33,8 +33,8 @@ type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientUpdateDto struct { type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"` Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"` CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"` LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"` RequiresReauthentication bool `json:"requiresReauthentication"`
@@ -66,7 +66,7 @@ type OidcClientFederatedIdentityDto struct {
type AuthorizeOidcClientRequestDto struct { type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"` ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"` Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"` CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"`
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"` CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"` CodeChallengeMethod string `json:"codeChallengeMethod"`

View File

@@ -1,7 +1,9 @@
package dto package dto
import ( import (
"net/url"
"regexp" "regexp"
"strings"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -19,38 +21,38 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$") var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
func init() { func init() {
v := binding.Validator.Engine().(*validator.Validate) engine := binding.Validator.Engine().(*validator.Validate)
// Maximum allowed value for TTLs // Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour const maxTTL = 31 * 24 * time.Hour
if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool { validators := map[string]validator.Func{
"username": func(fl validator.FieldLevel) bool {
return ValidateUsername(fl.Field().String()) return ValidateUsername(fl.Field().String())
}); err != nil { },
panic("Failed to register custom validation for username: " + err.Error()) "client_id": func(fl validator.FieldLevel) bool {
}
if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String()) return ValidateClientID(fl.Field().String())
}); err != nil { },
panic("Failed to register custom validation for client_id: " + err.Error()) "ttl": func(fl validator.FieldLevel) bool {
}
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration) ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok { if !ok {
return false return false
} }
// Allow zero, which means the field wasn't set // Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL) return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
}); err != nil { },
panic("Failed to register custom validation for ttl: " + err.Error()) "callback_url": func(fl validator.FieldLevel) bool {
}
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String()) return ValidateCallbackURL(fl.Field().String())
}); err != nil { },
panic("Failed to register custom validation for callback_url: " + err.Error()) "callback_url_pattern": func(fl validator.FieldLevel) bool {
return ValidateCallbackURLPattern(fl.Field().String())
},
}
for k, v := range validators {
err := engine.RegisterValidation(k, v)
if err != nil {
panic("Failed to register custom validation for " + k + ": " + err.Error())
}
} }
} }
@@ -64,8 +66,24 @@ func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID) return validateClientIDRegex.MatchString(clientID)
} }
// ValidateCallbackURL validates callback URLs with support for wildcards // ValidateCallbackURL validates the input callback URL
func ValidateCallbackURL(raw string) bool { func ValidateCallbackURL(str string) bool {
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
u, err := url.Parse(str)
if err != nil {
return false
}
switch strings.ToLower(u.Scheme) {
case "javascript", "data":
return false
default:
return true
}
}
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards
func ValidateCallbackURLPattern(raw string) bool {
err := utils.ValidateCallbackURLPattern(raw) err := utils.ValidateCallbackURLPattern(raw)
return err == nil return err == nil
} }

View File

@@ -57,3 +57,28 @@ func TestValidateClientID(t *testing.T) {
}) })
} }
} }
func TestValidateCallbackURL(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid https URL", "https://example.com/callback", true},
{"valid loopback URL", "http://127.0.0.1:49813/callback", true},
{"empty scheme", "//127.0.0.1:49813/callback", true},
{"valid custom scheme", "pocketid://callback", true},
{"invalid malformed URL", "http://[::1", false},
{"invalid missing scheme separator", "://example.com/callback", false},
{"rejects javascript scheme", "javascript:alert(1)", false},
{"rejects mixed case javascript scheme", "JavaScript:alert(1)", false},
{"rejects data scheme", "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
{"rejects mixed case data scheme", "DaTa:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateCallbackURL(tt.input))
})
}
}

View File

@@ -125,9 +125,7 @@ func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) { func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer tx.Rollback()
tx.Rollback()
}()
var client model.OidcClient var client model.OidcClient
err := tx. err := tx.

View File

@@ -71,8 +71,7 @@
reauthToken = await webauthnService.reauthenticate(authResponse); reauthToken = await webauthnService.reauthenticate(authResponse);
} }
await oidService const authResult = await oidService.authorize(
.authorize(
client!.id, client!.id,
scope, scope,
callbackURL, callbackURL,
@@ -80,10 +79,8 @@
codeChallenge, codeChallenge,
codeChallengeMethod, codeChallengeMethod,
reauthToken reauthToken
) );
.then(async ({ code, callbackURL, issuer }) => { onSuccess(authResult.code, authResult.callbackURL, authResult.issuer);
onSuccess(code, callbackURL, issuer);
});
} catch (e) { } catch (e) {
errorMessage = getWebauthnErrorMessage(e); errorMessage = getWebauthnErrorMessage(e);
isLoading = false; isLoading = false;
@@ -91,13 +88,17 @@
} }
function onSuccess(code: string, callbackURL: string, issuer: string) { function onSuccess(code: string, callbackURL: string, issuer: string) {
success = true;
setTimeout(() => {
const redirectURL = new URL(callbackURL); const redirectURL = new URL(callbackURL);
if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') {
throw new Error('Invalid redirect URL protocol');
}
redirectURL.searchParams.append('code', code); redirectURL.searchParams.append('code', code);
redirectURL.searchParams.append('state', authorizeState); redirectURL.searchParams.append('state', authorizeState);
redirectURL.searchParams.append('iss', issuer); redirectURL.searchParams.append('iss', issuer);
success = true;
setTimeout(() => {
window.location.href = redirectURL.toString(); window.location.href = redirectURL.toString();
}, 1000); }, 1000);
} }