diff options
Diffstat (limited to 'packages/shared')
| -rw-r--r-- | packages/shared/utils/redirectUrl.test.ts | 89 | ||||
| -rw-r--r-- | packages/shared/utils/redirectUrl.ts | 35 |
2 files changed, 124 insertions, 0 deletions
diff --git a/packages/shared/utils/redirectUrl.test.ts b/packages/shared/utils/redirectUrl.test.ts new file mode 100644 index 00000000..97d52cf2 --- /dev/null +++ b/packages/shared/utils/redirectUrl.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { isMobileAppRedirect, validateRedirectUrl } from "./redirectUrl"; + +describe("validateRedirectUrl", () => { + it("should return undefined for null input", () => { + expect(validateRedirectUrl(null)).toBe(undefined); + }); + + it("should return undefined for undefined input", () => { + expect(validateRedirectUrl(undefined)).toBe(undefined); + }); + + it("should return undefined for empty string", () => { + expect(validateRedirectUrl("")).toBe(undefined); + }); + + it("should allow relative paths starting with '/'", () => { + expect(validateRedirectUrl("/")).toBe("/"); + expect(validateRedirectUrl("/dashboard")).toBe("/dashboard"); + expect(validateRedirectUrl("/settings/profile")).toBe("/settings/profile"); + expect(validateRedirectUrl("/path?query=value")).toBe("/path?query=value"); + expect(validateRedirectUrl("/path#hash")).toBe("/path#hash"); + }); + + it("should reject protocol-relative URLs (//)", () => { + expect(validateRedirectUrl("//evil.com")).toBe(undefined); + expect(validateRedirectUrl("//evil.com/path")).toBe(undefined); + }); + + it("should allow karakeep:// scheme for mobile app", () => { + expect(validateRedirectUrl("karakeep://")).toBe("karakeep://"); + expect(validateRedirectUrl("karakeep://callback")).toBe( + "karakeep://callback", + ); + expect(validateRedirectUrl("karakeep://callback/path")).toBe( + "karakeep://callback/path", + ); + expect(validateRedirectUrl("karakeep://callback?param=value")).toBe( + "karakeep://callback?param=value", + ); + }); + + it("should reject http:// scheme", () => { + expect(validateRedirectUrl("http://example.com")).toBe(undefined); + expect(validateRedirectUrl("http://localhost:3000")).toBe(undefined); + }); + + it("should reject https:// scheme", () => { + expect(validateRedirectUrl("https://example.com")).toBe(undefined); + expect(validateRedirectUrl("https://evil.com/phishing")).toBe(undefined); + }); + + it("should reject javascript: scheme", () => { + expect(validateRedirectUrl("javascript:alert(1)")).toBe(undefined); + }); + + it("should reject data: scheme", () => { + expect( + validateRedirectUrl("data:text/html,<script>alert(1)</script>"), + ).toBe(undefined); + }); + + it("should reject other custom schemes", () => { + expect(validateRedirectUrl("file:///etc/passwd")).toBe(undefined); + expect(validateRedirectUrl("ftp://example.com")).toBe(undefined); + expect(validateRedirectUrl("mailto:test@example.com")).toBe(undefined); + }); + + it("should reject paths not starting with /", () => { + expect(validateRedirectUrl("dashboard")).toBe(undefined); + expect(validateRedirectUrl("path/to/page")).toBe(undefined); + }); +}); + +describe("isMobileAppRedirect", () => { + it("should return true for karakeep:// URLs", () => { + expect(isMobileAppRedirect("karakeep://")).toBe(true); + expect(isMobileAppRedirect("karakeep://callback")).toBe(true); + expect(isMobileAppRedirect("karakeep://callback/path")).toBe(true); + }); + + it("should return false for other URLs", () => { + expect(isMobileAppRedirect("/")).toBe(false); + expect(isMobileAppRedirect("/dashboard")).toBe(false); + expect(isMobileAppRedirect("https://example.com")).toBe(false); + expect(isMobileAppRedirect("http://localhost")).toBe(false); + }); +}); diff --git a/packages/shared/utils/redirectUrl.ts b/packages/shared/utils/redirectUrl.ts new file mode 100644 index 00000000..c2adffc0 --- /dev/null +++ b/packages/shared/utils/redirectUrl.ts @@ -0,0 +1,35 @@ +/** + * Validates a redirect URL to prevent open redirect attacks. + * Only allows: + * - Relative paths starting with "/" (but not "//" to prevent protocol-relative URLs) + * - The karakeep:// scheme for the mobile app + * + * @returns The validated URL if valid, otherwise undefined. + */ +export function validateRedirectUrl( + url: string | null | undefined, +): string | undefined { + if (!url) { + return undefined; + } + + // Allow relative paths starting with "/" but not "//" (protocol-relative URLs) + if (url.startsWith("/") && !url.startsWith("//")) { + return url; + } + + // Allow karakeep:// scheme for mobile app deep links + if (url.startsWith("karakeep://")) { + return url; + } + + // Reject all other schemes (http, https, javascript, data, etc.) + return undefined; +} + +/** + * Checks if the redirect URL is a mobile app deep link. + */ +export function isMobileAppRedirect(url: string): boolean { + return url.startsWith("karakeep://"); +} |
