diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 1195f07..c73fb62 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -132,6 +132,36 @@ describe("auth routes", () => { expect(target?.searchParams.get("redirect")).toBeNull(); }); + it("rejects malicious, protocol-relative, or external redirect URLs and falls back to /dashboard", () => { + const maliciousCases = [ + "//evil.com", + "//evil.com/path", + "https://evil.com", + "http://evil.com", + "javascript:alert(1)", + "data:text/html,evil", + "\\evil.com", + "relative-without-slash", + ]; + + for (const evil of maliciousCases) { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", `?redirect=${encodeURIComponent(evil)}`)) + ); + expect(target?.pathname).toBe("/dashboard"); + } + }); + + it("allows safe internal redirect paths", () => { + const safeCases = ["/rewards", "/courses/123", "/credentials/my-cred"]; + for (const safe of safeCases) { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", `?redirect=${encodeURIComponent(safe)}`)) + ); + expect(target?.pathname).toBe(safe); + } + }); + it("leaves /onboarding accessible while signed out", () => { expect(redirectTarget(middleware(makeRequest("/onboarding")))).toBeNull(); }); diff --git a/src/middleware.ts b/src/middleware.ts index 084db2b..61d6895 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -31,6 +31,22 @@ function isAuthRoute(pathname: string): boolean { return AUTH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } +/** + * Validate and sanitize redirect destination to prevent open redirects (issue #518). + * Must start with a single '/' and not be protocol-relative ('//') or specify a URL scheme. + */ +export function getSafeRedirect(redirect: string | null): string { + if (!redirect) return "/dashboard"; + if (!redirect.startsWith("/") || redirect.startsWith("//") || redirect.includes("\\")) { + return "/dashboard"; + } + // Reject protocol schemes e.g. "http:", "https:", "javascript:", "data:" + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(redirect)) { + return "/dashboard"; + } + return redirect; +} + export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const sessionToken = request.cookies.get("chainlearn-session")?.value; @@ -52,7 +68,7 @@ export function middleware(request: NextRequest) { if (sessionToken) { const redirect = request.nextUrl.searchParams.get("redirect"); const url = request.nextUrl.clone(); - url.pathname = redirect || "/dashboard"; + url.pathname = getSafeRedirect(redirect); url.searchParams.delete("redirect"); return NextResponse.redirect(url); }