Skip to content

Commit 1a58896

Browse files
fix(authentication-oauth): allow any port on loopback OAuth origins (#3699)
* fix(authentication-oauth): allow any port on loopback OAuth origins Exact origin matching from the 5.0.40 security fix rejected common local dev setups where the frontend runs on a different port than the configured origin (e.g. http://localhost vs http://localhost:5173). For localhost, 127.0.0.1, and ::1 only, match on scheme + host and ignore port, then redirect using the referer origin so the token returns to the correct local port. Non-loopback hosts still require an exact origin match. Closes #3684 * fix(authentication-oauth): treat 0.0.0.0 as loopback and improve origin errors Include 0.0.0.0 in the loopback port-flex allowlist used for local OAuth redirects. When a referer is rejected, report the normalized origin, configured allowlist, and a short hint about ports and loopback matching.
1 parent 15f5ee9 commit 1a58896

2 files changed

Lines changed: 219 additions & 8 deletions

File tree

packages/authentication-oauth/src/strategy.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,51 @@ import qs from 'qs'
1111

1212
const debug = createDebug('@feathersjs/authentication-oauth/strategy')
1313

14+
// Local machine addresses: match any port when scheme + host are allowlisted.
15+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0'])
16+
17+
/** Strip IPv6 brackets so `[::1]` and `::1` compare the same. */
18+
function normalizeHostname(hostname: string) {
19+
return hostname.toLowerCase().replace(/^\[|\]$/g, '')
20+
}
21+
22+
function isLoopbackHost(hostname: string) {
23+
return LOOPBACK_HOSTS.has(normalizeHostname(hostname))
24+
}
25+
26+
/**
27+
* Match key for origin allowlisting.
28+
* Non-loopback hosts use the full WHATWG origin (scheme + host + port).
29+
* Loopback hosts drop the port so local frontends on any port can match a single allowlist entry.
30+
*/
31+
function originMatchKey(value: string) {
32+
const url = new URL(value)
33+
const host = normalizeHostname(url.hostname)
34+
35+
if (isLoopbackHost(host)) {
36+
return `${url.protocol}//${host}`
37+
}
38+
39+
return url.origin.toLowerCase()
40+
}
41+
42+
function isOriginAllowed(refererOrigin: string, configured: string) {
43+
try {
44+
return originMatchKey(refererOrigin) === originMatchKey(configured)
45+
} catch {
46+
return false
47+
}
48+
}
49+
50+
function originNotAllowedMessage(refererOrigin: string, origins: string[]) {
51+
return (
52+
`Referer origin "${refererOrigin}" is not allowed. ` +
53+
`Configured origins: ${origins.join(', ')}. ` +
54+
`Use a full origin (scheme + host + port when non-default). ` +
55+
`Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.`
56+
)
57+
}
58+
1459
/**
1560
* Validates that appending a user-supplied path to a base URL does not change the origin.
1661
* Uses both URL resolution and string concatenation checks to catch all open redirect vectors:
@@ -117,17 +162,20 @@ export class OAuthStrategy extends AuthenticationBaseStrategy {
117162
try {
118163
refererOrigin = new URL(referer).origin
119164
} catch {
120-
throw new NotAuthenticated(`Invalid referer "${referer}".`)
165+
throw new NotAuthenticated(
166+
`Invalid referer "${referer}". Expected an absolute URL (e.g. http://localhost:3000).`
167+
)
121168
}
122169

123-
// Compare full origins
124-
const allowedOrigin = origins.find((current) => refererOrigin.toLowerCase() === current.toLowerCase())
170+
// Exact origin match; loopback hosts also match any port (see originMatchKey).
171+
// Always return the referer origin so redirects use the port the client came from.
172+
const allowedOrigin = origins.find((current) => isOriginAllowed(refererOrigin, current))
125173

126174
if (!allowedOrigin) {
127-
throw new NotAuthenticated(`Referer "${referer}" is not allowed.`)
175+
throw new NotAuthenticated(originNotAllowedMessage(refererOrigin, origins))
128176
}
129177

130-
return allowedOrigin
178+
return refererOrigin
131179
}
132180

133181
return redirect

packages/authentication-oauth/test/strategy.test.ts

Lines changed: 166 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ describe('@feathersjs/authentication-oauth/strategy security', () => {
202202
}
203203
),
204204
{
205-
message: 'Referer "https://target.com.attacker.com/login" is not allowed.'
205+
message:
206+
'Referer origin "https://target.com.attacker.com" is not allowed. ' +
207+
'Configured origins: https://target.com. ' +
208+
'Use a full origin (scheme + host + port when non-default). ' +
209+
'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.'
206210
}
207211
)
208212
})
@@ -220,7 +224,11 @@ describe('@feathersjs/authentication-oauth/strategy security', () => {
220224
}
221225
),
222226
{
223-
message: 'Referer "https://target.com-evil.attacker.com/login" is not allowed.'
227+
message:
228+
'Referer origin "https://target.com-evil.attacker.com" is not allowed. ' +
229+
'Configured origins: https://target.com. ' +
230+
'Use a full origin (scheme + host + port when non-default). ' +
231+
'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.'
224232
}
225233
)
226234
})
@@ -238,6 +246,157 @@ describe('@feathersjs/authentication-oauth/strategy security', () => {
238246
assert.equal(redirect, 'https://target.com#access_token=testing')
239247
})
240248
})
249+
250+
describe('loopback origin port matching (#3684)', () => {
251+
afterEach(() => {
252+
delete app.get('authentication').oauth.origins
253+
})
254+
255+
it('should allow any port on localhost when configured without a port', async () => {
256+
app.get('authentication').oauth.origins = ['http://localhost']
257+
258+
const redirect = await strategy.getRedirect(
259+
{ accessToken: 'testing' },
260+
{
261+
headers: {
262+
referer: 'http://localhost:5173/login'
263+
}
264+
}
265+
)
266+
267+
// Redirect must use the referer port, not the config string
268+
assert.equal(redirect, 'http://localhost:5173#access_token=testing')
269+
})
270+
271+
it('should allow a different loopback port than the configured one', async () => {
272+
app.get('authentication').oauth.origins = ['http://localhost:3030']
273+
274+
const redirect = await strategy.getRedirect(
275+
{ accessToken: 'testing' },
276+
{
277+
headers: {
278+
referer: 'http://localhost:3000/app'
279+
}
280+
}
281+
)
282+
283+
assert.equal(redirect, 'http://localhost:3000#access_token=testing')
284+
})
285+
286+
it('should allow any port on 127.0.0.1', async () => {
287+
app.get('authentication').oauth.origins = ['http://127.0.0.1:8080']
288+
289+
const redirect = await strategy.getRedirect(
290+
{ accessToken: 'testing' },
291+
{
292+
headers: {
293+
referer: 'http://127.0.0.1:5173/'
294+
}
295+
}
296+
)
297+
298+
assert.equal(redirect, 'http://127.0.0.1:5173#access_token=testing')
299+
})
300+
301+
it('should allow any port on IPv6 loopback', async () => {
302+
app.get('authentication').oauth.origins = ['http://[::1]']
303+
304+
const redirect = await strategy.getRedirect(
305+
{ accessToken: 'testing' },
306+
{
307+
headers: {
308+
referer: 'http://[::1]:4173/path'
309+
}
310+
}
311+
)
312+
313+
assert.equal(redirect, 'http://[::1]:4173#access_token=testing')
314+
})
315+
316+
it('should allow any port on 0.0.0.0', async () => {
317+
app.get('authentication').oauth.origins = ['http://0.0.0.0:3030']
318+
319+
const redirect = await strategy.getRedirect(
320+
{ accessToken: 'testing' },
321+
{
322+
headers: {
323+
referer: 'http://0.0.0.0:5173/app'
324+
}
325+
}
326+
)
327+
328+
assert.equal(redirect, 'http://0.0.0.0:5173#access_token=testing')
329+
})
330+
331+
it('should not treat localhost and 127.0.0.1 as the same host', async () => {
332+
app.get('authentication').oauth.origins = ['http://localhost']
333+
334+
await assert.rejects(
335+
() =>
336+
strategy.getRedirect(
337+
{ accessToken: 'testing' },
338+
{
339+
headers: {
340+
referer: 'http://127.0.0.1:3000/login'
341+
}
342+
}
343+
),
344+
{
345+
message:
346+
'Referer origin "http://127.0.0.1:3000" is not allowed. ' +
347+
'Configured origins: http://localhost. ' +
348+
'Use a full origin (scheme + host + port when non-default). ' +
349+
'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.'
350+
}
351+
)
352+
})
353+
354+
it('should still require exact port match for non-loopback hosts', async () => {
355+
app.get('authentication').oauth.origins = ['https://app.example.com']
356+
357+
await assert.rejects(
358+
() =>
359+
strategy.getRedirect(
360+
{ accessToken: 'testing' },
361+
{
362+
headers: {
363+
referer: 'https://app.example.com:8443/login'
364+
}
365+
}
366+
),
367+
{
368+
message:
369+
'Referer origin "https://app.example.com:8443" is not allowed. ' +
370+
'Configured origins: https://app.example.com. ' +
371+
'Use a full origin (scheme + host + port when non-default). ' +
372+
'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.'
373+
}
374+
)
375+
})
376+
377+
it('should require matching scheme on loopback', async () => {
378+
app.get('authentication').oauth.origins = ['https://localhost']
379+
380+
await assert.rejects(
381+
() =>
382+
strategy.getRedirect(
383+
{ accessToken: 'testing' },
384+
{
385+
headers: {
386+
referer: 'http://localhost:3000/login'
387+
}
388+
}
389+
),
390+
{
391+
message:
392+
'Referer origin "http://localhost:3000" is not allowed. ' +
393+
'Configured origins: https://localhost. ' +
394+
'Use a full origin (scheme + host + port when non-default). ' +
395+
'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.'
396+
}
397+
)
398+
})
399+
})
241400
})
242401

243402
describe('@feathersjs/authentication-oauth/strategy', () => {
@@ -359,7 +518,11 @@ describe('@feathersjs/authentication-oauth/strategy', () => {
359518
}
360519
),
361520
{
362-
message: 'Referer "https://example.com" is not allowed.'
521+
message:
522+
'Referer origin "https://example.com" is not allowed. ' +
523+
'Configured origins: https://feathersjs.com, https://feathers.cloud. ' +
524+
'Use a full origin (scheme + host + port when non-default). ' +
525+
'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.'
363526
}
364527
)
365528
})

0 commit comments

Comments
 (0)