fix(common): prevent open redirect in enter page (#5982)
This commit is contained in:
parent
402955d55f
commit
e03ffc5d85
3 changed files with 139 additions and 7 deletions
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
24
packages/hoppscotch-common/src/pages/enter-redirect.ts
Normal file
24
packages/hoppscotch-common/src/pages/enter-redirect.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue