Feathers has an origin validation bypass via prefix matching
Description
Feathersjs is a framework for creating web APIs and real-time applications with TypeScript or JavaScript. In versions 5.0.39 and below, origin validation uses startsWith() for comparison, allowing attackers to bypass the check by registering a domain that shares a common prefix with an allowed origin.The getAllowedOrigin() function checks if the Referer header starts with any allowed origin, and this comparison is insufficient as it only validates the prefix. This is exploitable when the origins array is configured and an attacker registers a domain starting with an allowed origin string (e.g., https://target.com.attacker.com bypasses https://target.com). On its own, tokens are still redirected to a configured origin. However, in specific scenarios an attacker can initiate the OAuth flow from an unauthorized origin and exfiltrate tokens, achieving full account takeover. This issue has bee fixed in version 5.0.40.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Feathersjs OAuth origin validation uses startsWith(), allowing domain prefix bypass and potential account takeover via token exfiltration.
Vulnerability
Overview
CVE-2026-27192 affects Feathersjs versions 5.0.39 and below. The getAllowedOrigin() function in the OAuth strategy validates the Referer header by checking if it starts with any allowed origin using startsWith(). This prefix-based comparison is insufficient, as an attacker can register a domain that shares a common prefix with an allowed origin (e.g., https://target.com.attacker.com bypasses https://target.com). The flaw is present when the origins array is configured [1][4].
Exploitation
Scenario
An attacker can initiate the OAuth flow from an unauthorized origin by crafting a Referer header that starts with an allowed origin string. While tokens are still redirected to a configured origin, in specific scenarios the attacker can exfiltrate tokens during the OAuth flow, leading to full account takeover. The attack requires the attacker to control a domain with a matching prefix and to be able to initiate the OAuth flow from that domain [1][4].
Impact
Successful exploitation allows an attacker to bypass origin validation and potentially steal OAuth tokens, resulting in full account takeover of the victim user. The vulnerability is rated with a CVSS 4.0 score indicating high severity [1].
Mitigation
The issue has been fixed in Feathersjs version 5.0.40. The fix includes improved origin validation that no longer relies on exact matching rather than prefix matching, as shown in the commit that also addresses related open redirect issues [2][3]. Users are strongly advised to upgrade to version 5.0.40 or later.
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-mp4x-c34x-wv3xghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27192ghsaADVISORY
- 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-mp4x-c34x-wv3xghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.