From 4f177a7cb9b2c2e9e790c29030189191f7644dd8 Mon Sep 17 00:00:00 2001 From: Marshall Thompson Date: Mon, 10 Aug 2026 18:52:29 -0600 Subject: [PATCH 1/2] 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 --- packages/authentication-oauth/src/strategy.ts | 42 +++++- .../test/strategy.test.ts | 124 ++++++++++++++++++ 2 files changed, 163 insertions(+), 3 deletions(-) diff --git a/packages/authentication-oauth/src/strategy.ts b/packages/authentication-oauth/src/strategy.ts index db49630d1..005130895 100644 --- a/packages/authentication-oauth/src/strategy.ts +++ b/packages/authentication-oauth/src/strategy.ts @@ -11,6 +11,41 @@ import qs from 'qs' const debug = createDebug('@feathersjs/authentication-oauth/strategy') +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) + +/** Strip IPv6 brackets so `[::1]` and `::1` compare the same. */ +function normalizeHostname(hostname: string) { + return hostname.toLowerCase().replace(/^\[|\]$/g, '') +} + +function isLoopbackHost(hostname: string) { + return LOOPBACK_HOSTS.has(normalizeHostname(hostname)) +} + +/** + * Match key for origin allowlisting. + * Non-loopback hosts use the full WHATWG origin (scheme + host + port). + * Loopback hosts drop the port so local frontends on any port can match a single allowlist entry. + */ +function originMatchKey(value: string) { + const url = new URL(value) + const host = normalizeHostname(url.hostname) + + if (isLoopbackHost(host)) { + return `${url.protocol}//${host}` + } + + return url.origin.toLowerCase() +} + +function isOriginAllowed(refererOrigin: string, configured: string) { + try { + return originMatchKey(refererOrigin) === originMatchKey(configured) + } catch { + return false + } +} + /** * Validates that appending a user-supplied path to a base URL does not change the origin. * Uses both URL resolution and string concatenation checks to catch all open redirect vectors: @@ -120,14 +155,15 @@ export class OAuthStrategy extends AuthenticationBaseStrategy { throw new NotAuthenticated(`Invalid referer "${referer}".`) } - // Compare full origins - const allowedOrigin = origins.find((current) => refererOrigin.toLowerCase() === current.toLowerCase()) + // Exact origin match; loopback hosts also match any port (see originMatchKey). + // Always return the referer origin so redirects use the port the client came from. + const allowedOrigin = origins.find((current) => isOriginAllowed(refererOrigin, current)) if (!allowedOrigin) { throw new NotAuthenticated(`Referer "${referer}" is not allowed.`) } - return allowedOrigin + return refererOrigin } return redirect diff --git a/packages/authentication-oauth/test/strategy.test.ts b/packages/authentication-oauth/test/strategy.test.ts index b03b104bb..3c7173757 100644 --- a/packages/authentication-oauth/test/strategy.test.ts +++ b/packages/authentication-oauth/test/strategy.test.ts @@ -238,6 +238,130 @@ describe('@feathersjs/authentication-oauth/strategy security', () => { assert.equal(redirect, 'https://target.com#access_token=testing') }) }) + + describe('loopback origin port matching (#3684)', () => { + afterEach(() => { + delete app.get('authentication').oauth.origins + }) + + it('should allow any port on localhost when configured without a port', async () => { + app.get('authentication').oauth.origins = ['http://localhost'] + + const redirect = await strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'http://localhost:5173/login' + } + } + ) + + // Redirect must use the referer port, not the config string + assert.equal(redirect, 'http://localhost:5173#access_token=testing') + }) + + it('should allow a different loopback port than the configured one', async () => { + app.get('authentication').oauth.origins = ['http://localhost:3030'] + + const redirect = await strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'http://localhost:3000/app' + } + } + ) + + assert.equal(redirect, 'http://localhost:3000#access_token=testing') + }) + + it('should allow any port on 127.0.0.1', async () => { + app.get('authentication').oauth.origins = ['http://127.0.0.1:8080'] + + const redirect = await strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'http://127.0.0.1:5173/' + } + } + ) + + assert.equal(redirect, 'http://127.0.0.1:5173#access_token=testing') + }) + + it('should allow any port on IPv6 loopback', async () => { + app.get('authentication').oauth.origins = ['http://[::1]'] + + const redirect = await strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'http://[::1]:4173/path' + } + } + ) + + assert.equal(redirect, 'http://[::1]:4173#access_token=testing') + }) + + it('should not treat localhost and 127.0.0.1 as the same host', async () => { + app.get('authentication').oauth.origins = ['http://localhost'] + + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'http://127.0.0.1:3000/login' + } + } + ), + { + message: 'Referer "http://127.0.0.1:3000/login" is not allowed.' + } + ) + }) + + it('should still require exact port match for non-loopback hosts', async () => { + app.get('authentication').oauth.origins = ['https://app.example.com'] + + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'https://app.example.com:8443/login' + } + } + ), + { + message: 'Referer "https://app.example.com:8443/login" is not allowed.' + } + ) + }) + + it('should require matching scheme on loopback', async () => { + app.get('authentication').oauth.origins = ['https://localhost'] + + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'http://localhost:3000/login' + } + } + ), + { + message: 'Referer "http://localhost:3000/login" is not allowed.' + } + ) + }) + }) }) describe('@feathersjs/authentication-oauth/strategy', () => { From a99321230afaa984b0104344417f3efc909ae5b3 Mon Sep 17 00:00:00 2001 From: Marshall Thompson Date: Mon, 10 Aug 2026 19:09:29 -0600 Subject: [PATCH 2/2] 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. --- packages/authentication-oauth/src/strategy.ts | 18 +++++-- .../test/strategy.test.ts | 51 ++++++++++++++++--- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/authentication-oauth/src/strategy.ts b/packages/authentication-oauth/src/strategy.ts index 005130895..de293f6bf 100644 --- a/packages/authentication-oauth/src/strategy.ts +++ b/packages/authentication-oauth/src/strategy.ts @@ -11,7 +11,8 @@ import qs from 'qs' const debug = createDebug('@feathersjs/authentication-oauth/strategy') -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) +// Local machine addresses: match any port when scheme + host are allowlisted. +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']) /** Strip IPv6 brackets so `[::1]` and `::1` compare the same. */ function normalizeHostname(hostname: string) { @@ -46,6 +47,15 @@ function isOriginAllowed(refererOrigin: string, configured: string) { } } +function originNotAllowedMessage(refererOrigin: string, origins: string[]) { + return ( + `Referer origin "${refererOrigin}" is not allowed. ` + + `Configured origins: ${origins.join(', ')}. ` + + `Use a full origin (scheme + host + port when non-default). ` + + `Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.` + ) +} + /** * Validates that appending a user-supplied path to a base URL does not change the origin. * Uses both URL resolution and string concatenation checks to catch all open redirect vectors: @@ -152,7 +162,9 @@ export class OAuthStrategy extends AuthenticationBaseStrategy { try { refererOrigin = new URL(referer).origin } catch { - throw new NotAuthenticated(`Invalid referer "${referer}".`) + throw new NotAuthenticated( + `Invalid referer "${referer}". Expected an absolute URL (e.g. http://localhost:3000).` + ) } // Exact origin match; loopback hosts also match any port (see originMatchKey). @@ -160,7 +172,7 @@ export class OAuthStrategy extends AuthenticationBaseStrategy { const allowedOrigin = origins.find((current) => isOriginAllowed(refererOrigin, current)) if (!allowedOrigin) { - throw new NotAuthenticated(`Referer "${referer}" is not allowed.`) + throw new NotAuthenticated(originNotAllowedMessage(refererOrigin, origins)) } return refererOrigin diff --git a/packages/authentication-oauth/test/strategy.test.ts b/packages/authentication-oauth/test/strategy.test.ts index 3c7173757..4651db5b0 100644 --- a/packages/authentication-oauth/test/strategy.test.ts +++ b/packages/authentication-oauth/test/strategy.test.ts @@ -202,7 +202,11 @@ describe('@feathersjs/authentication-oauth/strategy security', () => { } ), { - message: 'Referer "https://target.com.attacker.com/login" is not allowed.' + message: + 'Referer origin "https://target.com.attacker.com" is not allowed. ' + + 'Configured origins: https://target.com. ' + + 'Use a full origin (scheme + host + port when non-default). ' + + 'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.' } ) }) @@ -220,7 +224,11 @@ describe('@feathersjs/authentication-oauth/strategy security', () => { } ), { - message: 'Referer "https://target.com-evil.attacker.com/login" is not allowed.' + message: + 'Referer origin "https://target.com-evil.attacker.com" is not allowed. ' + + 'Configured origins: https://target.com. ' + + 'Use a full origin (scheme + host + port when non-default). ' + + 'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.' } ) }) @@ -305,6 +313,21 @@ describe('@feathersjs/authentication-oauth/strategy security', () => { assert.equal(redirect, 'http://[::1]:4173#access_token=testing') }) + it('should allow any port on 0.0.0.0', async () => { + app.get('authentication').oauth.origins = ['http://0.0.0.0:3030'] + + const redirect = await strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'http://0.0.0.0:5173/app' + } + } + ) + + assert.equal(redirect, 'http://0.0.0.0:5173#access_token=testing') + }) + it('should not treat localhost and 127.0.0.1 as the same host', async () => { app.get('authentication').oauth.origins = ['http://localhost'] @@ -319,7 +342,11 @@ describe('@feathersjs/authentication-oauth/strategy security', () => { } ), { - message: 'Referer "http://127.0.0.1:3000/login" is not allowed.' + message: + 'Referer origin "http://127.0.0.1:3000" is not allowed. ' + + 'Configured origins: http://localhost. ' + + 'Use a full origin (scheme + host + port when non-default). ' + + 'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.' } ) }) @@ -338,7 +365,11 @@ describe('@feathersjs/authentication-oauth/strategy security', () => { } ), { - message: 'Referer "https://app.example.com:8443/login" is not allowed.' + message: + 'Referer origin "https://app.example.com:8443" is not allowed. ' + + 'Configured origins: https://app.example.com. ' + + 'Use a full origin (scheme + host + port when non-default). ' + + 'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.' } ) }) @@ -357,7 +388,11 @@ describe('@feathersjs/authentication-oauth/strategy security', () => { } ), { - message: 'Referer "http://localhost:3000/login" is not allowed.' + message: + 'Referer origin "http://localhost:3000" is not allowed. ' + + 'Configured origins: https://localhost. ' + + 'Use a full origin (scheme + host + port when non-default). ' + + 'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.' } ) }) @@ -483,7 +518,11 @@ describe('@feathersjs/authentication-oauth/strategy', () => { } ), { - message: 'Referer "https://example.com" is not allowed.' + message: + 'Referer origin "https://example.com" is not allowed. ' + + 'Configured origins: https://feathersjs.com, https://feathers.cloud. ' + + 'Use a full origin (scheme + host + port when non-default). ' + + 'Loopback hosts (localhost, 127.0.0.1, ::1, 0.0.0.0) match any port.' } ) })