Skip to content

Commit f556c97

Browse files
authored
fix(auth): block localhost host-confusion bypass in CLI auth redirect (#405)
1 parent bf8572f commit f556c97

3 files changed

Lines changed: 122 additions & 3 deletions

File tree

src/app/(auth)/auth/cli/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
44
import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server'
55
import { auth } from '@/core/server/auth'
66
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
7+
import { isLoopbackUrl } from '@/core/shared/schemas/url'
78
import { encodedRedirect } from '@/lib/utils/auth'
89
import { generateE2BUserAccessToken } from '@/lib/utils/server'
910
import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert'
@@ -23,7 +24,7 @@ async function handleCLIAuth(
2324
userEmail: string,
2425
supabaseAccessToken: string
2526
) {
26-
if (!next?.startsWith('http://localhost')) {
27+
if (!isLoopbackUrl(next)) {
2728
throw new Error('Invalid redirect URL')
2829
}
2930

@@ -103,7 +104,7 @@ export default async function CLIAuthPage({
103104
}
104105

105106
// Validate redirect URL
106-
if (!next?.startsWith('http://localhost')) {
107+
if (!next || !isLoopbackUrl(next)) {
107108
l.error(
108109
{
109110
key: 'cli_auth:invalid_redirect_url',

src/core/shared/schemas/url.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,29 @@ export const relativeUrlSchema = z
3232
message: 'Must be a relative URL starting with /',
3333
}
3434
)
35+
36+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]'])
37+
38+
/**
39+
* True only when `value` is an http(s) URL whose host is an actual loopback
40+
* address. Parses with the URL constructor instead of prefix-matching, so
41+
* hosts like `localhost.evil.com` or `localhost@evil.com` are rejected.
42+
*/
43+
export function isLoopbackUrl(value: string): boolean {
44+
let url: URL
45+
try {
46+
url = new URL(value)
47+
} catch {
48+
return false
49+
}
50+
51+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
52+
return false
53+
}
54+
55+
return LOOPBACK_HOSTNAMES.has(url.hostname)
56+
}
57+
58+
export const loopbackUrlSchema = z.string().refine(isLoopbackUrl, {
59+
message: 'Must be an http(s) URL pointing at localhost',
60+
})

tests/unit/url-schema.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest'
2-
import { httpUrlSchema } from '@/core/shared/schemas/url'
2+
import {
3+
httpUrlSchema,
4+
isLoopbackUrl,
5+
loopbackUrlSchema,
6+
} from '@/core/shared/schemas/url'
37

48
describe('httpUrlSchema', () => {
59
describe('accepts valid http/https URLs', () => {
@@ -71,3 +75,91 @@ describe('httpUrlSchema', () => {
7175
})
7276
})
7377
})
78+
79+
describe('isLoopbackUrl', () => {
80+
describe('accepts genuine loopback URLs', () => {
81+
it('accepts http localhost with port and path', () => {
82+
expect(isLoopbackUrl('http://localhost:3000/callback')).toBe(true)
83+
})
84+
85+
it('accepts http localhost without port', () => {
86+
expect(isLoopbackUrl('http://localhost')).toBe(true)
87+
})
88+
89+
it('accepts http 127.0.0.1 with port', () => {
90+
expect(isLoopbackUrl('http://127.0.0.1:55021')).toBe(true)
91+
})
92+
93+
it('accepts http IPv6 loopback', () => {
94+
expect(isLoopbackUrl('http://[::1]:9000')).toBe(true)
95+
})
96+
97+
it('accepts https loopback', () => {
98+
expect(isLoopbackUrl('https://localhost:3000/callback')).toBe(true)
99+
})
100+
})
101+
102+
describe('rejects host-confusion bypass attempts', () => {
103+
it('rejects a subdomain of an attacker host', () => {
104+
expect(isLoopbackUrl('http://localhost.evil.com')).toBe(false)
105+
})
106+
107+
it('rejects a subdomain with a port', () => {
108+
expect(isLoopbackUrl('http://localhost.evil.com:3000/callback')).toBe(
109+
false
110+
)
111+
})
112+
113+
it('rejects a hyphenated attacker host', () => {
114+
expect(isLoopbackUrl('http://localhost-evil.com')).toBe(false)
115+
})
116+
117+
it('rejects userinfo pointing at an attacker host', () => {
118+
expect(isLoopbackUrl('http://localhost@evil.com')).toBe(false)
119+
})
120+
121+
it('rejects an attacker host with localhost in the path', () => {
122+
expect(isLoopbackUrl('http://evil.com/localhost')).toBe(false)
123+
})
124+
})
125+
126+
describe('rejects non-http(s) and malformed inputs', () => {
127+
it('rejects a non-loopback https host', () => {
128+
expect(isLoopbackUrl('https://evil.com')).toBe(false)
129+
})
130+
131+
it('rejects javascript URLs', () => {
132+
expect(isLoopbackUrl('javascript:alert(1)')).toBe(false)
133+
})
134+
135+
it('rejects file URLs to loopback-looking paths', () => {
136+
expect(isLoopbackUrl('file://localhost/etc/passwd')).toBe(false)
137+
})
138+
139+
it('rejects protocol-relative URLs', () => {
140+
expect(isLoopbackUrl('//localhost')).toBe(false)
141+
})
142+
143+
it('rejects plain strings', () => {
144+
expect(isLoopbackUrl('not-a-url')).toBe(false)
145+
})
146+
147+
it('rejects empty strings', () => {
148+
expect(isLoopbackUrl('')).toBe(false)
149+
})
150+
})
151+
})
152+
153+
describe('loopbackUrlSchema', () => {
154+
it('parses a genuine loopback URL', () => {
155+
expect(loopbackUrlSchema.safeParse('http://localhost:3000').success).toBe(
156+
true
157+
)
158+
})
159+
160+
it('fails on a host-confusion bypass attempt', () => {
161+
expect(
162+
loopbackUrlSchema.safeParse('http://localhost.evil.com').success
163+
).toBe(false)
164+
})
165+
})

0 commit comments

Comments
 (0)