From 60c607c185d48026f22dfb182822f8471d688f0b Mon Sep 17 00:00:00 2001 From: Nahid Hasan <52489202+nahidhasan94@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:15:46 +0600 Subject: [PATCH] fix: validate device-login redirect_uri to prevent token theft via DNS wildcard bypass (#6012) Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- .../src/auth/auth.controller.ts | 3 +- .../src/auth/redirect-uri.validator.spec.ts | 89 +++++++++++++++++++ .../src/auth/redirect-uri.validator.ts | 22 +++++ .../src/pages/device-login.vue | 25 +++++- 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts create mode 100644 packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index be0a0a31..94fbe553 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -20,6 +20,7 @@ import { GqlUser } from 'src/decorators/gql-user.decorator'; import { AuthUser } from 'src/types/AuthUser'; import { RTCookie } from 'src/decorators/rt-cookie.decorator'; import { AuthProvider, authCookieHandler, authProviderCheck } from './helper'; +import { isValidLocalhostRedirectUri } from './redirect-uri.validator'; import { GoogleSSOGuard } from './guards/google-sso.guard'; import { GithubSSOGuard } from './guards/github-sso.guard'; import { MicrosoftSSOGuard } from './guards/microsoft-sso.guard'; @@ -204,7 +205,7 @@ export class AuthController { @GqlUser() user: AuthUser, @Query('redirect_uri') redirectUri: string, ) { - if (!redirectUri || !redirectUri.startsWith('http://localhost')) { + if (!isValidLocalhostRedirectUri(redirectUri)) { throwHTTPErr({ message: 'Invalid desktop callback URL', statusCode: 400, diff --git a/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts new file mode 100644 index 00000000..ac7e9c9e --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts @@ -0,0 +1,89 @@ +import { isValidLocalhostRedirectUri } from './redirect-uri.validator'; + +describe('isValidLocalhostRedirectUri', () => { + describe('valid loopback URIs', () => { + test('Should return true for http://localhost with a port', () => { + expect(isValidLocalhostRedirectUri('http://localhost:3000/device-token')).toBe(true); + }); + + test('Should return true for http://localhost without a port', () => { + expect(isValidLocalhostRedirectUri('http://localhost/callback')).toBe(true); + }); + + test('Should return true for http://127.0.0.1 with a port', () => { + expect(isValidLocalhostRedirectUri('http://127.0.0.1:8080/callback')).toBe(true); + }); + + test('Should return true for http://[::1] IPv6 loopback', () => { + expect(isValidLocalhostRedirectUri('http://[::1]:9090/callback')).toBe(true); + }); + }); + + describe('DNS wildcard bypass vectors', () => { + test('Should reject localhost subdomain via sslip.io wildcard DNS', () => { + expect(isValidLocalhostRedirectUri('http://localhost.1.2.3.4.sslip.io:3000/steal')).toBe(false); + }); + + test('Should reject localhost subdomain via nip.io wildcard DNS', () => { + expect(isValidLocalhostRedirectUri('http://localhost.10.0.0.1.nip.io/steal')).toBe(false); + }); + + test('Should reject localhost as a subdomain of attacker domain', () => { + expect(isValidLocalhostRedirectUri('http://localhost.evil.com/steal')).toBe(false); + }); + + test('Should reject localhost with trailing dot FQDN trick', () => { + expect(isValidLocalhostRedirectUri('http://localhost./callback')).toBe(false); + }); + + test('Should reject domain that starts with localhost string', () => { + expect(isValidLocalhostRedirectUri('http://localhostevil.com/callback')).toBe(false); + }); + }); + + describe('protocol enforcement', () => { + test('Should reject https since loopback listeners do not serve TLS', () => { + expect(isValidLocalhostRedirectUri('https://localhost:3000/callback')).toBe(false); + }); + + test('Should reject ftp protocol', () => { + expect(isValidLocalhostRedirectUri('ftp://localhost/callback')).toBe(false); + }); + }); + + describe('credential and remote host rejection', () => { + test('Should reject URL with embedded credentials', () => { + expect(isValidLocalhostRedirectUri('http://user:pass@localhost:3000/callback')).toBe(false); + }); + + test('Should reject an arbitrary remote host', () => { + expect(isValidLocalhostRedirectUri('http://attacker.com:3000/callback')).toBe(false); + }); + + test('Should reject 0.0.0.0 since it is not a loopback address', () => { + expect(isValidLocalhostRedirectUri('http://0.0.0.0:3000/callback')).toBe(false); + }); + }); + + describe('malformed and empty input', () => { + test('Should return false for empty string', () => { + expect(isValidLocalhostRedirectUri('')).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(isValidLocalhostRedirectUri(undefined)).toBe(false); + }); + + test('Should return false for null', () => { + expect(isValidLocalhostRedirectUri(null)).toBe(false); + }); + + test('Should return false for a plain string that is not a URL', () => { + expect(isValidLocalhostRedirectUri('not-a-url')).toBe(false); + }); + + test('Should return false for a relative path', () => { + expect(isValidLocalhostRedirectUri('/device-token')).toBe(false); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts new file mode 100644 index 00000000..9d69719b --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts @@ -0,0 +1,22 @@ +// Only true loopback addresses are safe for native app redirects +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']; + +export function isValidLocalhostRedirectUri(uri: string | undefined | null): boolean { + if (!uri) return false; + + let url: URL; + try { + url = new URL(uri); + } catch { + return false; + } + + // no https — desktop loopback listeners don't serve TLS + if (url.protocol !== 'http:') return false; + + // block credential-stuffed URIs like http://user:pass@localhost + if (url.username || url.password) return false; + + // exact match only + return LOOPBACK_HOSTS.indexOf(url.hostname) !== -1; +} diff --git a/packages/hoppscotch-selfhost-web/src/pages/device-login.vue b/packages/hoppscotch-selfhost-web/src/pages/device-login.vue index 1b7d258c..a9af8454 100644 --- a/packages/hoppscotch-selfhost-web/src/pages/device-login.vue +++ b/packages/hoppscotch-selfhost-web/src/pages/device-login.vue @@ -96,6 +96,21 @@ const DeviceTokenResponse = z.object({ refresh_token: z.string(), }) +function isLoopbackUri(uri: string): boolean { + try { + const url = new URL(uri) + const loopbackHosts = ["localhost", "127.0.0.1", "[::1]"] + return ( + url.protocol === "http:" && + !url.username && + !url.password && + loopbackHosts.includes(url.hostname) + ) + } catch { + return false + } +} + async function proceedLogin() { loginConfirmState.value = "loading" @@ -107,8 +122,12 @@ async function proceedLogin() { throw new Error("Redirect URI not found") } + if (!isLoopbackUri(redirect_uri)) { + throw new Error("Invalid redirect URI: must be a loopback address") + } + const res = await axios.get( - `${import.meta.env.VITE_BACKEND_API_URL}/auth/desktop?redirect_uri=${redirect_uri}`, + `${import.meta.env.VITE_BACKEND_API_URL}/auth/desktop?redirect_uri=${encodeURIComponent(redirect_uri)}`, { withCredentials: true, } @@ -122,10 +141,8 @@ async function proceedLogin() { const tokens = parseResult.data - console.info("tokens", tokens) - await axios.get( - `${redirect_uri}?access_token=${tokens.access_token}&refresh_token=${tokens.refresh_token}` + `${redirect_uri}?access_token=${encodeURIComponent(tokens.access_token)}&refresh_token=${encodeURIComponent(tokens.refresh_token)}` ) loginConfirmState.value = "done"