Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 53 additions & 5 deletions packages/authentication-oauth/src/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
169 changes: 166 additions & 3 deletions packages/authentication-oauth/test/strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
}
)
})
Expand All @@ -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.'
}
)
})
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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.'
}
)
})
Expand Down
Loading