Feathers exposes internal headers via unencrypted session cookie
Description
Feathersjs is a framework for creating web APIs and real-time applications with TypeScript or JavaScript. In versions 5.0.39 and below, all HTTP request headers are stored in the session cookie, which is signed but not encrypted, exposing internal proxy/gateway headers to clients. The OAuth service stores the complete headers object in the session, then the session is persisted using cookie-session, which base64-encodes the data. While the cookie is signed to prevent tampering, the contents are readable by anyone by simply decoding the base64 value. Under specific deployment configurations (e.g., behind reverse proxies or API gateways), this can lead to exposure of sensitive internal infrastructure details such as API keys, service tokens, and internal IP addresses. This issue has been fixed in version 5.0.40.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Feathers.js ≤5.0.39 stores all HTTP request headers in a signed, but not encrypted, session cookie, exposing sensitive internal proxy headers to clients.
Vulnerability
Overview In Feathers.js versions 5.0.39 and below, the OAuth service stores the complete HTTP request headers object into the user's session without sanitization [1][4]. This session is then persisted using the cookie-session middleware, which base64-encodes the data. While the resulting cookie is cryptographically signed to prevent tampering, it is not encrypted, meaning anyone who obtains the cookie can decode its contents using standard base64 decoding [1][4].
Exploitation
Context An attacker exploiting this vulnerability does not need to compromise the server; only access to a valid session cookie is required. By simply base64-decoding the cookie value, the attacker can read all headers that were present in the original HTTP request that created the session. Under specific deployment configurations—such as behind reverse proxies, API gateways, or load balancers—these headers commonly include sensitive internal infrastructure details, including API keys, service tokens, and internal IP addresses [1][4].
Impact
If exploited, this vulnerability exposes internal proxy/gateway headers to the client, potentially revealing credentials, service tokens, and internal network addresses. This information could be used to pivot attacks on internal services, or to facilitate lateral movement within the infrastructure [1][4]. The issue is assigned a high severity due to the potential for sensitive data exposure.
Remediation
The vulnerability has been fixed in Feathers.js version 5.0.40 [3]. Users are strongly advised to update to this or any later version. If an immediate update is not possible, consider ensuring that sessions are not transmitted over untrusted networks and monitor for any signs of session cookie leakage.
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-9m9c-vpv5-9g85ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27193ghsaADVISORY
- 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-9m9c-vpv5-9g85ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.