diff --git a/packages/hoppscotch-common/src/pages/__tests__/enter-redirect.spec.ts b/packages/hoppscotch-common/src/pages/__tests__/enter-redirect.spec.ts new file mode 100644 index 00000000..371307fb --- /dev/null +++ b/packages/hoppscotch-common/src/pages/__tests__/enter-redirect.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest" +import { getSafeRedirectUrl } from "../enter-redirect" + +const ROOT = "hoppscotch.io" + +describe("getSafeRedirectUrl", () => { + it("allows a valid org subdomain", () => { + const result = getSafeRedirectUrl("acme.hoppscotch.io/enter", ROOT) + expect(result).not.toBeNull() + expect(result!.hostname).toBe("acme.hoppscotch.io") + }) + + it("allows the root domain itself", () => { + const result = getSafeRedirectUrl("hoppscotch.io/enter", ROOT) + expect(result).not.toBeNull() + expect(result!.hostname).toBe("hoppscotch.io") + }) + + it("rejects an external domain", () => { + expect(getSafeRedirectUrl("example.com", ROOT)).toBeNull() + }) + + it("rejects a domain that ends with the root but is not a subdomain", () => { + expect(getSafeRedirectUrl("evil-hoppscotch.io", ROOT)).toBeNull() + }) + + it("rejects a domain that contains the root as an interior label", () => { + expect( + getSafeRedirectUrl("example.com.hoppscotch.io.attacker.com", ROOT) + ).toBeNull() + }) + + it("rejects backslashes (WHATWG normalization bypass)", () => { + expect( + getSafeRedirectUrl("acme.hoppscotch.io\\@example.com", ROOT) + ).toBeNull() + }) + + it("rejects tab characters (WHATWG strip bypass)", () => { + expect(getSafeRedirectUrl("evil.com\tacme.hoppscotch.io", ROOT)).toBeNull() + }) + + it("rejects newline characters (WHATWG strip bypass)", () => { + expect(getSafeRedirectUrl("evil.com\nacme.hoppscotch.io", ROOT)).toBeNull() + }) + + it("rejects carriage-return characters (WHATWG strip bypass)", () => { + expect(getSafeRedirectUrl("evil.com\racme.hoppscotch.io", ROOT)).toBeNull() + }) + + it("rejects percent-encoded backslashes", () => { + expect(getSafeRedirectUrl("%5C%5Cexample.com", ROOT)).toBeNull() + }) + + it("rejects auth-parameter attacks (@)", () => { + const result = getSafeRedirectUrl("hoppscotch.io@example.com", ROOT) + expect(result).toBeNull() + }) + + it("rejects userinfo on an allowed hostname", () => { + expect( + getSafeRedirectUrl("acme.hoppscotch.io@hoppscotch.io", ROOT) + ).toBeNull() + }) + + it("rejects userinfo with credentials on an allowed hostname", () => { + expect(getSafeRedirectUrl("user:pass@hoppscotch.io", ROOT)).toBeNull() + }) + + it("rejects percent-encoded null bytes", () => { + expect(getSafeRedirectUrl("example.com%00.hoppscotch.io", ROOT)).toBeNull() + }) + + it("rejects empty input", () => { + expect(getSafeRedirectUrl("", ROOT)).toBeNull() + }) + + it("returns null when rootDomain is undefined", () => { + expect(getSafeRedirectUrl("acme.hoppscotch.io", undefined)).toBeNull() + }) + + it("returns null when rootDomain is empty string", () => { + expect(getSafeRedirectUrl("acme.hoppscotch.io", "")).toBeNull() + }) + + it("preserves path and query from the redirect value", () => { + const result = getSafeRedirectUrl("acme.hoppscotch.io/enter?foo=bar", ROOT) + expect(result).not.toBeNull() + expect(result!.pathname).toBe("/enter") + expect(result!.searchParams.get("foo")).toBe("bar") + }) +}) diff --git a/packages/hoppscotch-common/src/pages/enter-redirect.ts b/packages/hoppscotch-common/src/pages/enter-redirect.ts new file mode 100644 index 00000000..a83da24b --- /dev/null +++ b/packages/hoppscotch-common/src/pages/enter-redirect.ts @@ -0,0 +1,24 @@ +export function getSafeRedirectUrl( + rawRedirect: string, + rootDomain: string | undefined +): URL | null { + if (!rootDomain) return null + + // Reject characters the WHATWG URL parser normalizes or strips silently: + // backslash (\ -> /), tab, newline, carriage-return + if (/[\\\t\r\n]/.test(rawRedirect)) return null + + try { + const target = new URL("https://" + rawRedirect) + + if (target.username || target.password) return null + + const isAllowed = + target.hostname.endsWith("." + rootDomain) || + target.hostname === rootDomain + + return isAllowed ? target : null + } catch { + return null + } +} diff --git a/packages/hoppscotch-common/src/pages/enter.vue b/packages/hoppscotch-common/src/pages/enter.vue index d3b49137..489d39b1 100644 --- a/packages/hoppscotch-common/src/pages/enter.vue +++ b/packages/hoppscotch-common/src/pages/enter.vue @@ -12,6 +12,7 @@ import { defineComponent } from "vue" import { useRoute } from "vue-router" import { initializeApp } from "~/helpers/app" import { platform } from "~/platform" +import { getSafeRedirectUrl } from "./enter-redirect" export default defineComponent({ setup() { @@ -32,15 +33,30 @@ export default defineComponent({ async mounted() { const { redirect, ...queryParams } = this.route.query - if (redirect && Object.keys(queryParams).length) { - const url = new URL(("https://" + redirect) as string) + // Org subdomain magic-link flow: redirect back to the originating subdomain + if ( + platform.organization && + !platform.organization.isDefaultCloudInstance && + typeof redirect === "string" + ) { + const redirectTarget = getSafeRedirectUrl( + redirect, + platform.organization.getRootDomain() + ) - Object.entries(queryParams).forEach(([key, value]) => { - url.searchParams.set(key, value as string) - }) + if ( + redirectTarget && + platform.auth.isSignInWithEmailLink(window.location.href) + ) { + Object.entries(queryParams).forEach(([key, value]) => { + if (typeof value === "string") { + redirectTarget.searchParams.set(key, value) + } + }) - window.location.href = url.href - return + window.location.href = redirectTarget.href + return + } } this.signingInWithEmail = true