From 6c9147483c0a370e2b5011d13898279d2acc445d Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Wed, 10 Sep 2025 19:14:54 +0200 Subject: [PATCH] fix: add validation for callback URLs (#929) --- backend/internal/dto/oidc_dto.go | 4 +- backend/internal/dto/validations.go | 47 ++++++++++++++----- frontend/src/lib/utils/zod-util.ts | 19 ++++++++ .../oidc-clients/oidc-client-form.svelte | 6 +-- 4 files changed, 60 insertions(+), 16 deletions(-) diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index a02e765c..9afcb6f3 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -31,8 +31,8 @@ type OidcClientWithAllowedGroupsCountDto struct { type OidcClientUpdateDto struct { Name string `json:"name" binding:"required,max=50" unorm:"nfc"` - CallbackURLs []string `json:"callbackURLs"` - LogoutCallbackURLs []string `json:"logoutCallbackURLs"` + CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"` + LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"` IsPublic bool `json:"isPublic"` PkceEnabled bool `json:"pkceEnabled"` RequiresReauthentication bool `json:"requiresReauthentication"` diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 7a497282..429a9dcf 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -1,7 +1,9 @@ package dto import ( + "net/url" "regexp" + "strings" "time" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -23,32 +25,34 @@ func init() { // Maximum allowed value for TTLs const maxTTL = 31 * 24 * time.Hour - // Errors here are development-time ones - err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool { + if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool { return ValidateUsername(fl.Field().String()) - }) - if err != nil { + }); err != nil { panic("Failed to register custom validation for username: " + err.Error()) } - err = v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool { + if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool { return ValidateClientID(fl.Field().String()) - }) - if err != nil { + }); err != nil { panic("Failed to register custom validation for client_id: " + err.Error()) } - err = v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool { + if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool { ttl, ok := fl.Field().Interface().(utils.JSONDuration) if !ok { return false } // Allow zero, which means the field wasn't set - return ttl.Duration == 0 || ttl.Duration > time.Second && ttl.Duration <= maxTTL - }) - if err != nil { + return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL) + }); err != nil { panic("Failed to register custom validation for ttl: " + err.Error()) } + + if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool { + return ValidateCallbackURL(fl.Field().String()) + }); err != nil { + panic("Failed to register custom validation for callback_url: " + err.Error()) + } } // ValidateUsername validates username inputs @@ -60,3 +64,24 @@ func ValidateUsername(username string) bool { func ValidateClientID(clientID string) bool { return validateClientIDRegex.MatchString(clientID) } + +// ValidateCallbackURL validates callback URLs with support for wildcards +func ValidateCallbackURL(raw string) bool { + if raw == "*" { + return true + } + + // Replace all '*' with 'x' to check if the rest is still a valid URI + test := strings.ReplaceAll(raw, "*", "x") + + u, err := url.Parse(test) + if err != nil { + return false + } + + if !u.IsAbs() { + return false + } + + return true +} diff --git a/frontend/src/lib/utils/zod-util.ts b/frontend/src/lib/utils/zod-util.ts index 9806106d..6ea18cb8 100644 --- a/frontend/src/lib/utils/zod-util.ts +++ b/frontend/src/lib/utils/zod-util.ts @@ -1,3 +1,4 @@ +import { m } from '$lib/paraglide/messages'; import z from 'zod/v4'; export const emptyToUndefined = (validation: z.ZodType) => @@ -7,3 +8,21 @@ export const optionalUrl = z .url() .optional() .or(z.literal('').transform(() => undefined)); + +export const callbackUrlSchema = z + .string() + .nonempty() + .refine( + (val) => { + if (val === '*') return true; + try { + new URL(val.replace(/\*/g, 'x')); + return true; + } catch { + return false; + } + }, + { + message: m.invalid_redirect_url() + } + ); diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index c4cefb83..d0f141d3 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -15,7 +15,7 @@ import { preventDefault } from '$lib/utils/event-util'; import { createForm } from '$lib/utils/form-util'; import { cn } from '$lib/utils/style'; - import { emptyToUndefined, optionalUrl } from '$lib/utils/zod-util'; + import { callbackUrlSchema, emptyToUndefined, optionalUrl } from '$lib/utils/zod-util'; import { LucideChevronDown } from '@lucide/svelte'; import { slide } from 'svelte/transition'; import { z } from 'zod/v4'; @@ -65,8 +65,8 @@ .optional() ), name: z.string().min(2).max(50), - callbackURLs: z.array(z.string().nonempty()).default([]), - logoutCallbackURLs: z.array(z.string().nonempty()), + callbackURLs: z.array(callbackUrlSchema).default([]), + logoutCallbackURLs: z.array(callbackUrlSchema).default([]), isPublic: z.boolean(), pkceEnabled: z.boolean(), requiresReauthentication: z.boolean(),