fix(common): prevent open redirect in enter page (#5982)

This commit is contained in:
James George 2026-03-20 12:41:31 +05:30 committed by GitHub
parent 402955d55f
commit e03ffc5d85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 139 additions and 7 deletions

View file

@ -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")
})
})

View file

@ -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
}
}

View file

@ -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