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>
This commit is contained in:
parent
c690937fd1
commit
60c607c185
4 changed files with 134 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue