diff --git a/packages/authentication-oauth/src/strategy.ts b/packages/authentication-oauth/src/strategy.ts index db49630d1..de293f6bf 100644 --- a/packages/authentication-oauth/src/strategy.ts +++ b/packages/authentication-oauth/src/strategy.ts @@ -11,6 +11,51 @@ import qs from 'qs' const debug = createDebug('@feathersjs/authentication-oauth/strategy') +// 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) { + 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 + } +} + +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: @@ -117,17 +162,20 @@ 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).` + ) } - // 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.`) + throw new NotAuthenticated(originNotAllowedMessage(refererOrigin, origins)) } - 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..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.' } ) }) @@ -238,6 +246,157 @@ 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 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'] + + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'http://127.0.0.1:3000/login' + } + } + ), + { + 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.' + } + ) + }) + + 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 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.' + } + ) + }) + + 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 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.' + } + ) + }) + }) }) describe('@feathersjs/authentication-oauth/strategy', () => { @@ -359,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.' } ) })