Feathers: Open Redirect in OAuth callback enables account takeover
Description
Feathersjs is a framework for creating web APIs and real-time applications with TypeScript or JavaScript. Versions 5.0.39 and below the redirect query parameter is appended to the base origin without validation, allowing attackers to steal access tokens via URL authority injection. This leads to full account takeover, as the attacker obtains the victim's access token and can impersonate them. The application constructs the final redirect URL by concatenating the base origin with the user-supplied redirect parameter. This is exploitable when the origins array is configured and origin values do not end with /. An attacker can supply @attacker.com as the redirect value results in https://target.com@attacker.com#access_token=..., where the browser interprets attacker.com as the host, leading to full account takeover. This issue has been fixed in version 5.0.40.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Feathersjs OAuth redirect parameter concatenation without validation allows URL authority injection, leading to access token theft and full account takeover.
Vulnerability
Analysis
CVE-2026-27191 is an open redirect vulnerability in the Feathersjs framework (versions 5.0.39 and below) that enables full account takeover. The root cause is that the OAuth callback endpoint constructs the final redirect URL by directly concatenating a user-supplied redirect query parameter with the configured base origin, without validating the input for malicious characters [1][4]. Specifically, the code does const redirectUrl = \${redirect}${queryRedirect}\`, where redirect is the base origin (e.g., https://target.com) and queryRedirect` is the attacker-controlled value [4].
Exploitation
An attacker can exploit this by supplying a redirect parameter containing an @ character, such as @attacker.com. When the base origin does not end with a /, the resulting URL becomes https://target.com@attacker.com#access_token=.... Browsers interpret the part before the @ as a username and the part after as the host, effectively redirecting the victim to attacker.com while appending the access token in the URL fragment [1][2]. The attack requires the application to have an origins array configured and for the origin values to lack a trailing slash [1]. No authentication is needed to trigger the redirect; the attacker only needs to lure a victim to a crafted OAuth login link.
Impact
Successful exploitation allows the attacker to steal the victim's OAuth access token, which is appended to the redirect URL. With this token, the attacker can impersonate the victim and gain full access to their account, leading to complete account takeover [1][4]. The vulnerability is classified as critical due to the ease of exploitation and severe impact.
Mitigation
The issue has been fixed in Feathersjs version 5.0.40 [1][3]. The fix, implemented in commit ee19a0a, adds validation to reject redirect parameters containing @, //, or backslash characters, preventing URL authority injection and protocol-relative URL attacks [2]. Users are strongly advised to upgrade to version 5.0.40 or later immediately.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@feathersjs/authentication-oauthnpm | < 5.0.40 | 5.0.40 |
Affected products
2- Range: <=5.0.39
- feathersjs/feathersv5Range: < 5.0.40
Patches
1ee19a0ae9bc2fix(oauth): Patch open redirect and origin validation (#3653)
4 files changed · +245 −2
packages/authentication-oauth/src/service.ts+4 −1 modified@@ -170,7 +170,10 @@ export class OAuthService { session.redirect = redirect session.query = restQuery - session.headers = headers + // Only store the referer header needed for origin validation + session.headers = { + referer: headers?.referer + } return this.handler('GET', handlerParams, {}) }
packages/authentication-oauth/src/strategy.ts+18 −1 modified@@ -72,7 +72,17 @@ export class OAuthStrategy extends AuthenticationBaseStrategy { if (Array.isArray(origins)) { const referer = params?.headers?.referer || origins[0] - const allowedOrigin = origins.find((current) => referer.toLowerCase().startsWith(current.toLowerCase())) + + // Parse the referer to get its origin for proper comparison + let refererOrigin: string + try { + refererOrigin = new URL(referer).origin + } catch { + throw new NotAuthenticated(`Invalid referer "${referer}".`) + } + + // Compare full origins + const allowedOrigin = origins.find((current) => refererOrigin.toLowerCase() === current.toLowerCase()) if (!allowedOrigin) { throw new NotAuthenticated(`Referer "${referer}" is not allowed.`) @@ -95,6 +105,13 @@ export class OAuthStrategy extends AuthenticationBaseStrategy { return null } + // Validate redirect parameter to prevent open redirect via URL authority injection + // Reject characters that could change the URL's authority: @, //, \ + // e.g., @attacker.com would make https://target.com@attacker.com redirect to attacker.com + if (queryRedirect && /[@\\]|^\/\/|\/\//.test(queryRedirect)) { + throw new NotAuthenticated('Invalid redirect path.') + } + const redirectUrl = `${redirect}${queryRedirect}` const separator = redirectUrl.endsWith('?') ? '' : redirect.indexOf('#') !== -1 ? '?' : '#' const authResult: AuthenticationResult = data
packages/authentication-oauth/test/service.test.ts+78 −0 modified@@ -3,6 +3,84 @@ import axios, { AxiosResponse } from 'axios' import { CookieJar } from 'tough-cookie' import { expressFixture } from './utils/fixture' +describe('@feathersjs/authentication-oauth service security', () => { + const port = 9780 + const req = axios.create({ + withCredentials: true, + maxRedirects: 0 + }) + let app: Awaited<ReturnType<typeof expressFixture>> + + const fetchErrorResponse = async (url: string, headers?: Record<string, string>): Promise<AxiosResponse> => { + try { + await req.get(url, { headers }) + } catch (error: any) { + return error.response + } + assert.fail('Should never get here') + } + + before(async () => { + app = await expressFixture(port, 5117) + }) + + after(async () => { + await app.teardown() + }) + + describe('internal headers exposure via session cookie', () => { + it('should not store sensitive internal headers in session cookie', async () => { + const host = `http://localhost:${port}` + const location = `${host}/oauth/github` + + // Make request with internal/sensitive headers that might be added by proxies + const oauthResponse = await fetchErrorResponse(location, { + 'x-forwarded-for': '10.0.0.1', + 'x-internal-api-key': 'sk_live_secret123', + 'x-real-ip': '192.168.1.1' + }) + + assert.equal(oauthResponse.status, 303) + + // Get the session cookie + const cookies = oauthResponse.headers['set-cookie'] + assert.ok(cookies, 'Should have set-cookie header') + + // Find the oauth session cookie (express cookie-session uses 'feathers.oauth') + const oauthCookie = cookies.find((c: string) => c.startsWith('feathers.oauth=')) + assert.ok(oauthCookie, 'Should have feathers.oauth session cookie') + + // Extract the cookie value and decode it + const match = oauthCookie.match(/feathers\.oauth=([^;]+)/) + assert.ok(match, 'Should be able to extract cookie value') + + const cookieValue = decodeURIComponent(match[1]) + // Cookie session uses base64 encoding + const decoded = Buffer.from(cookieValue, 'base64').toString('utf-8') + const sessionData = JSON.parse(decoded) + + // The vulnerability: all headers are stored in session.headers + // This test should FAIL if headers object contains sensitive internal headers + assert.ok(sessionData.headers, 'Session should have headers stored') + + // These assertions verify the FIX is in place - they should FAIL currently + // because the vulnerable code stores ALL headers + const storedHeaderKeys = Object.keys(sessionData.headers).map((k) => k.toLowerCase()) + + // Only 'referer' should be stored (if needed for origin validation) + // Any other headers being stored is a security issue + const sensitiveHeaders = ['x-forwarded-for', 'x-internal-api-key', 'x-real-ip', 'authorization', 'cookie'] + const exposedSensitiveHeaders = sensitiveHeaders.filter((h) => storedHeaderKeys.includes(h)) + + assert.deepEqual( + exposedSensitiveHeaders, + [], + `Sensitive headers should not be stored in session cookie, but found: ${exposedSensitiveHeaders.join(', ')}` + ) + }) + }) +}) + describe('@feathersjs/authentication-oauth service', () => { const port = 9778 const req = axios.create({
packages/authentication-oauth/test/strategy.test.ts+145 −0 modified@@ -2,6 +2,151 @@ import { strict as assert } from 'assert' import { expressFixture, TestOAuthStrategy } from './utils/fixture' import { AuthenticationService } from '@feathersjs/authentication' +describe('@feathersjs/authentication-oauth/strategy security', () => { + let app: Awaited<ReturnType<typeof expressFixture>> + let authService: AuthenticationService + let strategy: TestOAuthStrategy + + before(async () => { + app = await expressFixture(9779, 5116) + authService = app.service('authentication') + strategy = authService.getStrategy('github') as TestOAuthStrategy + }) + + after(async () => { + await app.teardown() + }) + + describe('open redirect via URL authority injection', () => { + beforeEach(() => { + app.get('authentication').oauth.origins = ['https://target.com'] + }) + + afterEach(() => { + delete app.get('authentication').oauth.origins + }) + + it('should reject redirect parameter containing @ character', async () => { + // Attack: ?redirect=@attacker.com would result in https://target.com@attacker.com + // which browsers parse as username "target.com" and host "attacker.com" + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + redirect: '@attacker.com', + headers: { + referer: 'https://target.com/login' + } + } + ), + { + name: 'NotAuthenticated' + } + ) + }) + + it('should reject redirect parameter containing // for protocol-relative URLs', async () => { + // Attack: ?redirect=//attacker.com would result in https://target.com//attacker.com + // which some parsers might interpret as protocol-relative URL + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + redirect: '//attacker.com', + headers: { + referer: 'https://target.com/login' + } + } + ), + { + name: 'NotAuthenticated' + } + ) + }) + + it('should reject redirect with backslash characters', async () => { + // Some browsers treat backslash as forward slash + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + redirect: '\\\\attacker.com', + headers: { + referer: 'https://target.com/login' + } + } + ), + { + name: 'NotAuthenticated' + } + ) + }) + }) + + describe('origin validation bypass via startsWith', () => { + beforeEach(() => { + app.get('authentication').oauth.origins = ['https://target.com'] + }) + + afterEach(() => { + delete app.get('authentication').oauth.origins + }) + + it('should reject referer from domain that shares prefix with allowed origin', async () => { + // Attack: attacker registers target.com.attacker.com + // startsWith('https://target.com') would incorrectly return true + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'https://target.com.attacker.com/login' + } + } + ), + { + message: 'Referer "https://target.com.attacker.com/login" is not allowed.' + } + ) + }) + + it('should reject referer with extra subdomain-like prefix', async () => { + // Another variant: target.com-evil.attacker.com + await assert.rejects( + () => + strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'https://target.com-evil.attacker.com/login' + } + } + ), + { + message: 'Referer "https://target.com-evil.attacker.com/login" is not allowed.' + } + ) + }) + + it('should accept exact origin match with path', async () => { + // Legitimate use case should still work + const redirect = await strategy.getRedirect( + { accessToken: 'testing' }, + { + headers: { + referer: 'https://target.com/some/path' + } + } + ) + assert.equal(redirect, 'https://target.com#access_token=testing') + }) + }) +}) + describe('@feathersjs/authentication-oauth/strategy', () => { let app: Awaited<ReturnType<typeof expressFixture>> let authService: AuthenticationService
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-ppf9-4ffw-hh4pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27191ghsaADVISORY
- github.com/feathersjs/feathers/commit/ee19a0ae9bc2ebf23b1fe598a1f7361981b65401ghsax_refsource_MISCWEB
- github.com/feathersjs/feathers/releases/tag/v5.0.40ghsax_refsource_MISCWEB
- github.com/feathersjs/feathers/security/advisories/GHSA-ppf9-4ffw-hh4pghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.