From f83fa53218ca31805d84b7fa2a9997bf1e112ab5 Mon Sep 17 00:00:00 2001 From: vcntdev <198897459+vcntdev@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:19:34 +0200 Subject: [PATCH 1/5] feat: add function to validate redirects --- console/src/lib/validate-redirect.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 console/src/lib/validate-redirect.ts diff --git a/console/src/lib/validate-redirect.ts b/console/src/lib/validate-redirect.ts new file mode 100644 index 00000000..e86a4f05 --- /dev/null +++ b/console/src/lib/validate-redirect.ts @@ -0,0 +1,20 @@ +export function validateRedirect(url: string | null | undefined): string { + if (!url) return "/" + + const candidate = url + if (!candidate || candidate.startsWith("//") || candidate.startsWith("\\\\")) { + return "/" + } + + try { + const parsed = new URL(candidate, window.location.origin) + + if (parsed.origin !== window.location.origin) { + return "/" + } + + return `${parsed.pathname}${parsed.search}${parsed.hash}` + } catch { + return "/" + } +} From 761b1e38e991d0fe5f721203fda7f4d006ba274a Mon Sep 17 00:00:00 2001 From: vcntdev <198897459+vcntdev@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:20:07 +0200 Subject: [PATCH 2/5] feat: utilize validation func in login.tsx & logincallback.tsx --- console/src/views/auth/Login.tsx | 5 +++-- console/src/views/auth/LoginCallback.tsx | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/console/src/views/auth/Login.tsx b/console/src/views/auth/Login.tsx index 657229ae..3ac49954 100644 --- a/console/src/views/auth/Login.tsx +++ b/console/src/views/auth/Login.tsx @@ -7,6 +7,7 @@ import { useForm } from "react-hook-form" import api from "../../api" import { type AuthDriver, AUTH_DRIVERS } from "../../types" +import { validateRedirect } from "@/lib/validate-redirect" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -35,7 +36,7 @@ export default function Login() { const [selectedDriver, setSelectedDriver] = useState() const [error, setError] = useState() const [isSubmitting, setIsSubmitting] = useState(false) - const redirect = searchParams.get("r") ?? "/" + const redirect = validateRedirect(searchParams.get("r")) const form = useForm({ defaultValues: { @@ -227,4 +228,4 @@ export default function Login() { ) -} +} \ No newline at end of file diff --git a/console/src/views/auth/LoginCallback.tsx b/console/src/views/auth/LoginCallback.tsx index 640899cf..d28279c9 100644 --- a/console/src/views/auth/LoginCallback.tsx +++ b/console/src/views/auth/LoginCallback.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react" import { useClerk } from "@clerk/clerk-react" import api from "../../api" import { AUTH_DRIVERS } from "../../types" +import { validateRedirect } from "@/lib/validate-redirect" import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" import { useTranslation } from "react-i18next" @@ -14,7 +15,7 @@ export default function LoginCallback() { const { driver } = useParams() as { driver: string } const [searchParams] = useSearchParams() const [error, setError] = useState() - const redirect = searchParams.get("r") ?? "/" + const redirect = validateRedirect(searchParams.get("r")) useEffect(() => { const handleAuth = async () => { From 72068cf6c3f595e63fe889c1706e422d7a303d30 Mon Sep 17 00:00:00 2001 From: vcntdev <198897459+vcntdev@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:26:16 +0200 Subject: [PATCH 3/5] fix: implement callback validation to backend auth --- internal/config/config.go | 13 ++-- .../http/controllers/v1/management/auth.go | 69 +++++++++++++++++-- .../controllers/v1/management/auth_test.go | 57 +++++++++++++++ 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 0cbfadba..cd31c942 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,12 +39,13 @@ func (n Node) PublicBaseURL() string { } type Auth struct { - Driver string `env:"DRIVER"` - JWTSecret string `env:"JWT_SECRET"` - JWKS claim.JWKS `env:"JWKS_URL"` - TokenLife time.Duration `env:"TOKEN_LIFE" envDefault:"24h"` - Basic BasicAuth `envPrefix:"BASIC_"` - Clerk ClerkAuth `envPrefix:"CLERK_"` + Driver string `env:"DRIVER"` + JWTSecret string `env:"JWT_SECRET"` + JWKS claim.JWKS `env:"JWKS_URL"` + TokenLife time.Duration `env:"TOKEN_LIFE" envDefault:"24h"` + AllowedRedirectHosts []string `env:"ALLOWED_REDIRECT_HOSTS"` + Basic BasicAuth `envPrefix:"BASIC_"` + Clerk ClerkAuth `envPrefix:"CLERK_"` } type BasicAuth struct { diff --git a/internal/http/controllers/v1/management/auth.go b/internal/http/controllers/v1/management/auth.go index 795a58a5..c50e4a8a 100644 --- a/internal/http/controllers/v1/management/auth.go +++ b/internal/http/controllers/v1/management/auth.go @@ -1,7 +1,10 @@ package v1 import ( + "io" "net/http" + "net/url" + "strings" "github.com/jmoiron/sqlx" "github.com/lunogram/platform/internal/config" @@ -23,14 +26,16 @@ func NewAuthController(logger *zap.Logger, db *sqlx.DB, cfg config.Node, engine } return &AuthController{ - logger: logger, - provider: provider, + logger: logger, + provider: provider, + allowedRedirectHosts: cfg.Auth.AllowedRedirectHosts, }, nil } type AuthController struct { - logger *zap.Logger - provider providers.Provider + logger *zap.Logger + provider providers.Provider + allowedRedirectHosts []string } func (c *AuthController) GetAuthMethods(w http.ResponseWriter, r *http.Request) { @@ -43,8 +48,32 @@ func (c *AuthController) AuthCallback(w http.ResponseWriter, r *http.Request, dr return } + body := r.Body + defer body.Close() + + rawBody, err := io.ReadAll(body) + if err != nil { + oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("failed to read request body"))) + return + } + + var callbackReq oapi.AuthCallbackJSONRequestBody + if len(rawBody) > 0 { + if err := json.Unmarshal(rawBody, &callbackReq); err != nil { + oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("invalid JSON body"))) + return + } + + if callbackReq.Redirect != nil && !c.validateRedirect(*callbackReq.Redirect) { + oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("redirect URL is not allowed"))) + return + } + } + + r.Body = io.NopCloser(strings.NewReader(string(rawBody))) + ctx := r.Context() - _, err := c.provider.Authenticate(ctx, w, r) + _, err = c.provider.Authenticate(ctx, w, r) if err != nil { c.logger.Error("auth validation failed", zap.String("driver", string(driver)), zap.Error(err)) c.writeAuthError(w, err) @@ -54,6 +83,34 @@ func (c *AuthController) AuthCallback(w http.ResponseWriter, r *http.Request, dr w.WriteHeader(http.StatusOK) } +func (c *AuthController) validateRedirect(redirect string) bool { + if redirect == "" { + return true + } + + parsed, err := url.Parse(redirect) + if err != nil { + return false + } + + if parsed.Scheme == "" && parsed.Host == "" { + return true + } + + if parsed.Scheme == "" && parsed.Host != "" { + return false + } + + host := parsed.Hostname() + for _, allowed := range c.allowedRedirectHosts { + if host == allowed { + return true + } + } + + return false +} + func (c *AuthController) AuthWebhook(w http.ResponseWriter, r *http.Request, driver oapi.AuthWebhookParamsDriver) { if string(driver) != c.provider.Driver() { oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("auth driver not found"))) @@ -86,4 +143,4 @@ func (c *AuthController) writeAuthError(w http.ResponseWriter, err error) { default: oapi.WriteProblem(w, problem.ErrInternal(problem.Describe("authentication failed"))) } -} +} \ No newline at end of file diff --git a/internal/http/controllers/v1/management/auth_test.go b/internal/http/controllers/v1/management/auth_test.go index 5b4ab890..6762f893 100644 --- a/internal/http/controllers/v1/management/auth_test.go +++ b/internal/http/controllers/v1/management/auth_test.go @@ -140,3 +140,60 @@ func TestAuthWebhookWithInvalidDriver(t *testing.T) { }) } } + +func TestValidateRedirect(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + allowedHosts []string + redirect string + want bool + }{ + "relative path allowed": { + allowedHosts: []string{"app.example.com"}, + redirect: "/dashboard", + want: true, + }, + "absolute URL in allowed list": { + allowedHosts: []string{"app.example.com", "staging.example.com"}, + redirect: "https://app.example.com/dashboard", + want: true, + }, + "absolute URL not in allowed list": { + allowedHosts: []string{"app.example.com"}, + redirect: "https://evil.com/redirect", + want: false, + }, + "protocol-relative rejected": { + allowedHosts: []string{"app.example.com"}, + redirect: "//evil.com/redirect", + want: false, + }, + "empty allowed hosts rejects absolute": { + allowedHosts: []string{}, + redirect: "https://app.example.com/dashboard", + want: false, + }, + "empty redirect allowed": { + allowedHosts: []string{"app.example.com"}, + redirect: "", + want: true, + }, + "multiple subdomain allowed": { + allowedHosts: []string{"app.example.com", "staging.example.com"}, + redirect: "https://staging.example.com/marketing", + want: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + controller := &AuthController{ + allowedRedirectHosts: test.allowedHosts, + } + + got := controller.validateRedirect(test.redirect) + require.Equal(t, test.want, got) + }) + } +} From e5ba89ab578295ab16e4c94a962ce9d0db8b668b Mon Sep 17 00:00:00 2001 From: vcntdev <198897459+vcntdev@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:11:02 +0200 Subject: [PATCH 4/5] fix: BE callback validation rollback --- .../http/controllers/v1/management/auth.go | 67 ++----------------- .../controllers/v1/management/auth_test.go | 59 +--------------- 2 files changed, 6 insertions(+), 120 deletions(-) diff --git a/internal/http/controllers/v1/management/auth.go b/internal/http/controllers/v1/management/auth.go index c50e4a8a..8de7da24 100644 --- a/internal/http/controllers/v1/management/auth.go +++ b/internal/http/controllers/v1/management/auth.go @@ -1,10 +1,7 @@ package v1 import ( - "io" "net/http" - "net/url" - "strings" "github.com/jmoiron/sqlx" "github.com/lunogram/platform/internal/config" @@ -26,16 +23,14 @@ func NewAuthController(logger *zap.Logger, db *sqlx.DB, cfg config.Node, engine } return &AuthController{ - logger: logger, - provider: provider, - allowedRedirectHosts: cfg.Auth.AllowedRedirectHosts, + logger: logger, + provider: provider, }, nil } type AuthController struct { - logger *zap.Logger - provider providers.Provider - allowedRedirectHosts []string + logger *zap.Logger + provider providers.Provider } func (c *AuthController) GetAuthMethods(w http.ResponseWriter, r *http.Request) { @@ -48,32 +43,8 @@ func (c *AuthController) AuthCallback(w http.ResponseWriter, r *http.Request, dr return } - body := r.Body - defer body.Close() - - rawBody, err := io.ReadAll(body) - if err != nil { - oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("failed to read request body"))) - return - } - - var callbackReq oapi.AuthCallbackJSONRequestBody - if len(rawBody) > 0 { - if err := json.Unmarshal(rawBody, &callbackReq); err != nil { - oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("invalid JSON body"))) - return - } - - if callbackReq.Redirect != nil && !c.validateRedirect(*callbackReq.Redirect) { - oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("redirect URL is not allowed"))) - return - } - } - - r.Body = io.NopCloser(strings.NewReader(string(rawBody))) - ctx := r.Context() - _, err = c.provider.Authenticate(ctx, w, r) + _, err := c.provider.Authenticate(ctx, w, r) if err != nil { c.logger.Error("auth validation failed", zap.String("driver", string(driver)), zap.Error(err)) c.writeAuthError(w, err) @@ -83,34 +54,6 @@ func (c *AuthController) AuthCallback(w http.ResponseWriter, r *http.Request, dr w.WriteHeader(http.StatusOK) } -func (c *AuthController) validateRedirect(redirect string) bool { - if redirect == "" { - return true - } - - parsed, err := url.Parse(redirect) - if err != nil { - return false - } - - if parsed.Scheme == "" && parsed.Host == "" { - return true - } - - if parsed.Scheme == "" && parsed.Host != "" { - return false - } - - host := parsed.Hostname() - for _, allowed := range c.allowedRedirectHosts { - if host == allowed { - return true - } - } - - return false -} - func (c *AuthController) AuthWebhook(w http.ResponseWriter, r *http.Request, driver oapi.AuthWebhookParamsDriver) { if string(driver) != c.provider.Driver() { oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("auth driver not found"))) diff --git a/internal/http/controllers/v1/management/auth_test.go b/internal/http/controllers/v1/management/auth_test.go index 6762f893..3b2fe0c6 100644 --- a/internal/http/controllers/v1/management/auth_test.go +++ b/internal/http/controllers/v1/management/auth_test.go @@ -139,61 +139,4 @@ func TestAuthWebhookWithInvalidDriver(t *testing.T) { require.Equal(t, test.code, res.Code, res.Body.String()) }) } -} - -func TestValidateRedirect(t *testing.T) { - t.Parallel() - - tests := map[string]struct { - allowedHosts []string - redirect string - want bool - }{ - "relative path allowed": { - allowedHosts: []string{"app.example.com"}, - redirect: "/dashboard", - want: true, - }, - "absolute URL in allowed list": { - allowedHosts: []string{"app.example.com", "staging.example.com"}, - redirect: "https://app.example.com/dashboard", - want: true, - }, - "absolute URL not in allowed list": { - allowedHosts: []string{"app.example.com"}, - redirect: "https://evil.com/redirect", - want: false, - }, - "protocol-relative rejected": { - allowedHosts: []string{"app.example.com"}, - redirect: "//evil.com/redirect", - want: false, - }, - "empty allowed hosts rejects absolute": { - allowedHosts: []string{}, - redirect: "https://app.example.com/dashboard", - want: false, - }, - "empty redirect allowed": { - allowedHosts: []string{"app.example.com"}, - redirect: "", - want: true, - }, - "multiple subdomain allowed": { - allowedHosts: []string{"app.example.com", "staging.example.com"}, - redirect: "https://staging.example.com/marketing", - want: true, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - controller := &AuthController{ - allowedRedirectHosts: test.allowedHosts, - } - - got := controller.validateRedirect(test.redirect) - require.Equal(t, test.want, got) - }) - } -} +} \ No newline at end of file From 9f2c48a6ff513b583b6f397a020508168599170d Mon Sep 17 00:00:00 2001 From: vcntdev <198897459+vcntdev@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:33:07 +0200 Subject: [PATCH 5/5] fix: remove unused redirect parameter from authcallback oapi --- internal/http/controllers/v1/management/oapi/resources.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/http/controllers/v1/management/oapi/resources.yml b/internal/http/controllers/v1/management/oapi/resources.yml index 36516e72..91d2c78b 100644 --- a/internal/http/controllers/v1/management/oapi/resources.yml +++ b/internal/http/controllers/v1/management/oapi/resources.yml @@ -8417,10 +8417,6 @@ components: type: string description: Password (required for basic auth) example: "password123" - redirect: - type: string - description: URL to redirect after successful auth - example: "/" EmailTemplate: type: object