Skip to content

Commit 5a7b5c4

Browse files
Jacek Radkowobsoriano
andauthored
fix(express): prevent auth bypass from foreign req.auth values (#8804)
Co-authored-by: Robert Soriano <sorianorobertc@gmail.com>
1 parent d070601 commit 5a7b5c4

7 files changed

Lines changed: 149 additions & 10 deletions

File tree

‎.changeset/spicy-llamas-brush.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/express': patch
3+
---
4+
5+
Fix an authentication bypass where a `req.auth` value set by another library (such as `express-jwt`, Passport, or `express-openid-connect`) caused `clerkMiddleware()` and `requireAuth()` to silently skip Clerk authentication, and `getAuth()` to return the unverified foreign value. Clerk now brands the `req.auth` handler it installs and only trusts that handler: `clerkMiddleware()` authenticates the request and overwrites a foreign `req.auth` (logging a warning when it does), and `getAuth()` throws its "middleware required" error instead of reading foreign data.

‎packages/express/src/__tests__/clerkMiddleware.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,17 @@ vi.mock('@clerk/backend', async () => {
2525
};
2626
});
2727

28+
const { mockWarnOnce } = vi.hoisted(() => ({
29+
mockWarnOnce: vi.fn(),
30+
}));
31+
vi.mock('@clerk/shared/logger', () => ({
32+
logger: { warnOnce: mockWarnOnce, logOnce: vi.fn() },
33+
}));
34+
2835
import { authenticateAndDecorateRequest, authenticateRequest } from '../authenticateRequest';
2936
import { clerkMiddleware } from '../clerkMiddleware';
3037
import { getAuth } from '../getAuth';
38+
import { requireAuth } from '../requireAuth';
3139
import { assertNoDebugHeaders, assertSignedOutDebugHeaders, runMiddleware, runMiddlewareOnPath } from './helpers';
3240

3341
describe('clerkMiddleware', () => {
@@ -331,6 +339,87 @@ describe('clerkMiddleware', () => {
331339
expect(response.header).toHaveProperty('location', expect.stringContaining('/v1/client/handshake?redirect_url='));
332340
});
333341

342+
describe('foreign req.auth values (authentication bypass hardening)', () => {
343+
beforeEach(() => {
344+
mockWarnOnce.mockClear();
345+
});
346+
347+
it('authenticates the request even when a previous middleware set req.auth to a plain object (express-jwt style)', async () => {
348+
const foreignAuth: RequestHandler = (req, _res, next) => {
349+
Object.assign(req, { auth: { sub: 'attacker' } });
350+
next();
351+
};
352+
353+
const response = await runMiddleware([foreignAuth, clerkMiddleware()], {
354+
Cookie: '__clerk_db_jwt=deadbeef;',
355+
}).expect(200, 'Hello world!');
356+
357+
assertSignedOutDebugHeaders(response);
358+
expect(mockWarnOnce).toHaveBeenCalledTimes(1);
359+
});
360+
361+
it('overwrites a foreign req.auth function so getAuth returns Clerk state', async () => {
362+
const foreignAuth: RequestHandler = (req, _res, next) => {
363+
Object.assign(req, { auth: () => ({ userId: 'attacker' }) });
364+
next();
365+
};
366+
367+
let capturedUserId: string | null | undefined;
368+
const capture: RequestHandler = (req, _res, next) => {
369+
capturedUserId = getAuth(req).userId;
370+
next();
371+
};
372+
373+
const response = await runMiddleware([foreignAuth, clerkMiddleware(), capture], {
374+
Cookie: '__clerk_db_jwt=deadbeef;',
375+
}).expect(200, 'Hello world!');
376+
377+
assertSignedOutDebugHeaders(response);
378+
expect(capturedUserId).toBeNull();
379+
});
380+
381+
it('does not re-authenticate when clerkMiddleware runs twice', async () => {
382+
const authenticateRequestSpy = vi.fn().mockResolvedValue({
383+
headers: new Headers(),
384+
status: 'signed-out',
385+
toAuth: () => ({ userId: null }),
386+
});
387+
const clerkClient = { authenticateRequest: authenticateRequestSpy } as any;
388+
389+
await runMiddleware([clerkMiddleware({ clerkClient }), clerkMiddleware({ clerkClient })]).expect(
390+
200,
391+
'Hello world!',
392+
);
393+
394+
expect(authenticateRequestSpy).toHaveBeenCalledTimes(1);
395+
expect(mockWarnOnce).not.toHaveBeenCalled();
396+
});
397+
398+
it('requireAuth is not bypassed by a foreign req.auth that reports a userId', async () => {
399+
const foreignAuth: RequestHandler = (req, _res, next) => {
400+
Object.assign(req, { auth: () => ({ userId: 'attacker' }) });
401+
next();
402+
};
403+
404+
const response = await runMiddleware([foreignAuth, requireAuth({ signInUrl: '/sign-in' })]);
405+
406+
expect(response.status).toBe(302);
407+
expect(response.headers.location).toBe('/sign-in');
408+
});
409+
410+
it('requireAuth redirects (does not 500) when a foreign req.auth is a plain object', async () => {
411+
const foreignAuth: RequestHandler = (req, _res, next) => {
412+
Object.assign(req, { auth: { sub: 'attacker' } });
413+
next();
414+
};
415+
416+
const response = await runMiddleware([foreignAuth, requireAuth({ signInUrl: '/sign-in' })]);
417+
418+
expect(response.status).toBe(302);
419+
expect(response.headers.location).toBe('/sign-in');
420+
});
421+
});
422+
334423
describe('Frontend API proxy handling', () => {
335424
beforeEach(() => {
336425
mockClerkFrontendApiProxy.mockReset();

‎packages/express/src/__tests__/getAuth.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AuthenticatedMachineObject } from '@clerk/backend/internal';
2+
import type { Request as ExpressRequest } from 'express';
23
import { describe, expect, it } from 'vitest';
34

45
import { getAuth } from '../getAuth';
@@ -9,6 +10,16 @@ describe('getAuth', () => {
910
expect(() => getAuth(mockRequest())).toThrow(/The "clerkMiddleware" should be registered before using "getAuth"/);
1011
});
1112

13+
it('throws error if req.auth is a plain object set by another library (express-jwt style)', () => {
14+
const req = { auth: { sub: 'attacker' } } as unknown as ExpressRequest;
15+
expect(() => getAuth(req)).toThrow(/The "clerkMiddleware" should be registered before using "getAuth"/);
16+
});
17+
18+
it('throws error if req.auth is a function set by another library', () => {
19+
const req = { auth: () => ({ userId: 'attacker' }) } as unknown as ExpressRequest;
20+
expect(() => getAuth(req)).toThrow(/The "clerkMiddleware" should be registered before using "getAuth"/);
21+
});
22+
1223
it('returns auth from request for signed-out request', () => {
1324
const req = mockRequestWithAuth({ userId: null, tokenType: 'session_token' });
1425
const auth = getAuth(req);

‎packages/express/src/__tests__/helpers.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import supertest from 'supertest';
55
import { expect, vi } from 'vitest';
66

77
import type { ExpressRequestWithAuth } from '../types';
8+
import { brandRequestAuth } from '../utils';
89

910
// Inspired by https://github.com/helmetjs/helmet/blob/main/test/helpers.ts
1011
export function runMiddleware(middleware: RequestHandler | RequestHandler[], headers: Record<string, string> = {}) {
@@ -41,13 +42,13 @@ export function mockRequest(): ExpressRequest {
4142

4243
export function mockRequestWithAuth(auth: Partial<AuthObject> = {}): ExpressRequestWithAuth {
4344
return {
44-
auth: () => ({
45+
auth: brandRequestAuth(() => ({
4546
getToken: () => Promise.resolve(''),
4647
has: () => false,
4748
debug: () => ({}),
4849
tokenType: 'session_token',
4950
...auth,
50-
}),
51+
})),
5152
} as unknown as ExpressRequestWithAuth;
5253
}
5354

‎packages/express/src/__tests__/requireAuth.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
44

55
import { clerkMiddleware } from '../clerkMiddleware';
66
import { requireAuth } from '../requireAuth';
7-
import type { ExpressRequestWithAuth } from '../types';
7+
import { brandRequestAuth, requestHasAuthObject } from '../utils';
88
import { mockRequestWithAuth, runMiddleware } from './helpers';
99

1010
let mockAuthenticateAndDecorateRequest: Mock;
@@ -87,11 +87,11 @@ describe('requireAuth', () => {
8787

8888
mockAuthenticateAndDecorateRequest.mockImplementation((): RequestHandler => {
8989
return (req, _res, next) => {
90-
if ((req as ExpressRequestWithAuth).auth) {
90+
if (requestHasAuthObject(req)) {
9191
return next();
9292
}
9393
const requestState = mockAuthenticateRequest({ request: req });
94-
Object.assign(req, { auth: () => requestState.toAuth() });
94+
Object.assign(req, { auth: brandRequestAuth(() => requestState.toAuth()) });
9595
next();
9696
};
9797
});

‎packages/express/src/authenticateRequest.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,23 @@ import type { RequestState } from '@clerk/backend/internal';
33
import { AuthStatus, createClerkRequest } from '@clerk/backend/internal';
44
import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, stripTrailingSlashes } from '@clerk/backend/proxy';
55
import { isDevelopmentFromSecretKey } from '@clerk/shared/keys';
6+
import { logger } from '@clerk/shared/logger';
67
import { isHttpOrHttps, isProxyUrlRelative, isValidProxyUrl } from '@clerk/shared/proxy';
78
import { handleValueOrFn } from '@clerk/shared/utils';
89
import type { RequestHandler, Response } from 'express';
910
import { Readable } from 'stream';
1011

1112
import { clerkClient as defaultClerkClient } from './clerkClient';
1213
import { satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from './errors';
13-
import type { AuthenticateRequestParams, ClerkMiddlewareOptions, ExpressRequestWithAuth } from './types';
14-
import { incomingMessageToRequest, loadApiEnv, loadClientEnv, requestToProxyRequest } from './utils';
14+
import type { AuthenticateRequestParams, ClerkMiddlewareOptions } from './types';
15+
import {
16+
brandRequestAuth,
17+
incomingMessageToRequest,
18+
loadApiEnv,
19+
loadClientEnv,
20+
requestHasAuthObject,
21+
requestToProxyRequest,
22+
} from './utils';
1523

1624
/**
1725
* @internal
@@ -140,10 +148,21 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions =
140148

141149
// eslint-disable-next-line @typescript-eslint/no-misused-promises
142150
const middleware: RequestHandler = async (request, response, next) => {
143-
if ((request as ExpressRequestWithAuth).auth) {
151+
// Skip authentication only when a Clerk middleware has already processed
152+
// this request. The brand check matters: a truthy `req.auth` is not proof
153+
// of that, since express-jwt, passport and other libraries set the same
154+
// property, and treating their value as ours would silently disable Clerk
155+
// authentication.
156+
if (requestHasAuthObject(request)) {
144157
return next();
145158
}
146159

160+
if ('auth' in request) {
161+
logger.warnOnce(
162+
'Clerk: another middleware has already set `req.auth` on this request. Clerk authentication will run anyway and overwrite it. To use another auth library alongside Clerk, configure it to store its state on a different request property.',
163+
);
164+
}
165+
147166
const env = { ...loadApiEnv(), ...loadClientEnv() };
148167
const publishableKey = options.publishableKey || env.publishableKey;
149168
const secretKey = options.secretKey || env.secretKey;
@@ -226,7 +245,7 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions =
226245
return;
227246
}
228247

229-
const auth = (opts: Parameters<typeof requestState.toAuth>[0]) => requestState.toAuth(opts);
248+
const auth = brandRequestAuth((opts: Parameters<typeof requestState.toAuth>[0]) => requestState.toAuth(opts));
230249

231250
Object.assign(request, { auth });
232251

‎packages/express/src/utils.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,22 @@ import { Readable } from 'stream';
44

55
import type { ExpressRequestWithAuth } from './types';
66

7+
/**
8+
* Brand attached to the `req.auth` handler installed by Clerk middleware.
9+
* `req.auth` is a property name shared with other auth libraries (express-jwt,
10+
* passport, express-openid-connect), so its mere presence proves nothing about
11+
* whether Clerk authenticated the request. `Symbol.for` registers the brand
12+
* globally so it stays stable across multiple copies of this package loaded in
13+
* the same process.
14+
*/
15+
const clerkAuthBrand = Symbol.for('@clerk/express.auth');
16+
17+
export const brandRequestAuth = <T extends (...args: never[]) => unknown>(authHandler: T): T =>
18+
Object.assign(authHandler, { [clerkAuthBrand]: true });
19+
720
export const requestHasAuthObject = (req: ExpressRequest): req is ExpressRequestWithAuth => {
8-
return 'auth' in req;
21+
const auth = (req as Partial<ExpressRequestWithAuth>).auth;
22+
return typeof auth === 'function' && (auth as unknown as Record<symbol, unknown>)[clerkAuthBrand] === true;
923
};
1024

1125
export const loadClientEnv = () => {

0 commit comments

Comments
 (0)