Skip to content

Commit fa761dc

Browse files
authored
fix(fastify,express): Respond 400 instead of 500 to requests that cannot be web requests (#9290)
1 parent 38f347c commit fa761dc

7 files changed

Lines changed: 169 additions & 23 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/fastify': patch
3+
'@clerk/express': patch
4+
---
5+
6+
Respond with 400 Bad Request instead of surfacing a 500 when an incoming request cannot be represented as a fetch `Request`. Vulnerability-scanner probes such as hostless `//` request targets, targets that parse as credentialed URLs, and forbidden methods like TRACE previously threw inside the middleware and polluted error logs.

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

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type * as ClerkBackend from '@clerk/backend';
22
import type { Request, RequestHandler, Response } from 'express';
3+
import express from 'express';
4+
import supertest from 'supertest';
35
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
46

57
const { mockClerkFrontendApiProxy } = vi.hoisted(() => ({
@@ -565,19 +567,46 @@ describe('clerkMiddleware', () => {
565567
});
566568
});
567569

568-
it('calls next with an error when request URL is invalid', () => {
569-
const req = {
570-
url: '//',
571-
cookies: {},
572-
headers: { host: 'example.com' },
573-
} as Request;
574-
const res = {} as Response;
575-
const mockNext = vi.fn();
570+
describe('requests that cannot be converted to a web Request', () => {
571+
it('responds 400 without calling next when the request URL is invalid', async () => {
572+
const req = {
573+
method: 'GET',
574+
url: '//',
575+
cookies: {},
576+
headers: { host: 'example.com' },
577+
} as Request;
578+
const status = vi.fn().mockReturnThis();
579+
const end = vi.fn();
580+
const res = { status, end } as unknown as Response;
581+
const mockNext = vi.fn();
582+
583+
await clerkMiddleware()(req, res, mockNext);
584+
585+
expect(status).toHaveBeenCalledWith(400);
586+
expect(end).toHaveBeenCalled();
587+
expect(mockNext).not.toHaveBeenCalled();
588+
});
589+
590+
it('responds 400 to a hostless // request target', async () => {
591+
await runMiddlewareOnPath(clerkMiddleware(), '//').expect(400);
592+
});
576593

577-
clerkMiddleware()(req, res, mockNext);
594+
it('responds 400 to a request target that parses as a credentialed URL', async () => {
595+
await runMiddlewareOnPath(clerkMiddleware(), '//$%7B%23context@example.com%7D.action').expect(400);
596+
});
597+
598+
it('responds 400 to a forbidden method (TRACE)', async () => {
599+
const app = express();
600+
app.use(clerkMiddleware());
601+
app.use((_req, res) => res.end('Hello world!'));
602+
603+
await supertest(app).trace('/').expect(400);
604+
});
578605

579-
expect(mockNext.mock.calls[0][0].message).toBe('Invalid URL');
606+
it('responds 400 to a hostless // request target when proxy is enabled', async () => {
607+
await runMiddlewareOnPath(clerkMiddleware({ frontendApiProxy: { enabled: true } }), '//').expect(400);
580608

581-
mockNext.mockReset();
609+
expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled();
610+
});
582611
});
583612
});

packages/express/src/authenticateRequest.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createClerkClient } from '@clerk/backend';
2-
import type { RequestState } from '@clerk/backend/internal';
2+
import type { ClerkRequest, 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';
@@ -51,7 +51,7 @@ export const authenticateRequest = (opts: AuthenticateRequestParams) => {
5151
...restOptions
5252
} = options || {};
5353

54-
const clerkRequest = createClerkRequest(incomingMessageToRequest(request));
54+
const clerkRequest = opts.clerkRequest ?? createClerkRequest(incomingMessageToRequest(request));
5555
const env = { ...loadApiEnv(), ...loadClientEnv() };
5656

5757
const secretKey = secretKeyInput || env.secretKey;
@@ -163,21 +163,42 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions =
163163
);
164164
}
165165

166+
// Node accepts request targets/methods (`//`, TRACE) the fetch spec cannot represent; reject those instead of 500ing.
167+
let clerkRequest: ClerkRequest;
168+
try {
169+
clerkRequest = createClerkRequest(incomingMessageToRequest(request));
170+
} catch {
171+
response.status(400).end();
172+
return;
173+
}
174+
166175
const env = { ...loadApiEnv(), ...loadClientEnv() };
167176
const publishableKey = options.publishableKey || env.publishableKey;
168177
const secretKey = options.secretKey || env.secretKey;
169178

170179
// Handle Frontend API proxy requests early, before authentication
171180
if (frontendApiProxy) {
172-
const requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`);
181+
let requestUrl: URL;
182+
try {
183+
requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`);
184+
} catch {
185+
response.status(400).end();
186+
return;
187+
}
173188
const isEnabled =
174189
typeof frontendApiProxy.enabled === 'function'
175190
? frontendApiProxy.enabled(requestUrl)
176191
: frontendApiProxy.enabled;
177192

178193
if (isEnabled && (requestUrl.pathname === proxyPath || requestUrl.pathname.startsWith(proxyPath + '/'))) {
179194
// Convert Express request to Fetch API Request
180-
const proxyRequest = requestToProxyRequest(request);
195+
let proxyRequest: Request;
196+
try {
197+
proxyRequest = requestToProxyRequest(request);
198+
} catch {
199+
response.status(400).end();
200+
return;
201+
}
181202

182203
// Call the core proxy function
183204
const proxyResponse = await clerkFrontendApiProxy(proxyRequest, {
@@ -220,7 +241,13 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions =
220241
// against the request's public origin (from x-forwarded-* headers).
221242
let resolvedOptions = options;
222243
if (frontendApiProxy && !options.proxyUrl) {
223-
const requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`);
244+
let requestUrl: URL;
245+
try {
246+
requestUrl = new URL(request.originalUrl || request.url, `http://${request.headers.host}`);
247+
} catch {
248+
response.status(400).end();
249+
return;
250+
}
224251
const isProxyEnabled =
225252
typeof frontendApiProxy.enabled === 'function'
226253
? frontendApiProxy.enabled(requestUrl)
@@ -235,6 +262,7 @@ export const authenticateAndDecorateRequest = (options: ClerkMiddlewareOptions =
235262
clerkClient,
236263
request,
237264
options: resolvedOptions,
265+
clerkRequest,
238266
});
239267

240268
const err = setResponseHeaders(requestState, response);

packages/express/src/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { createClerkClient } from '@clerk/backend';
2-
import type { AuthenticateRequestOptions, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal';
2+
import type {
3+
AuthenticateRequestOptions,
4+
ClerkRequest,
5+
SignedInAuthObject,
6+
SignedOutAuthObject,
7+
} from '@clerk/backend/internal';
38
import type { ShouldProxyFn } from '@clerk/shared/proxy';
49
import type { PendingSessionOptions } from '@clerk/shared/types';
510
import type { Request as ExpressRequest } from 'express';
@@ -59,4 +64,6 @@ export type AuthenticateRequestParams = {
5964
clerkClient: ClerkClient;
6065
request: ExpressRequest;
6166
options?: ClerkMiddlewareOptions;
67+
/** Prebuilt ClerkRequest, so callers that already converted the request can skip re-conversion. */
68+
clerkRequest?: ClerkRequest;
6269
};

packages/fastify/src/__tests__/frontendApiProxy.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,25 @@ describe('Frontend API proxy handling', () => {
158158
expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled();
159159
});
160160

161+
it('responds 400 to a hostless // request target when proxy is enabled', async () => {
162+
const response = await injectOnPath({ frontendApiProxy: { enabled: true } }, '//');
163+
164+
expect(response.statusCode).toEqual(400);
165+
expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled();
166+
expect(authenticateRequestMock).not.toHaveBeenCalled();
167+
});
168+
169+
it('responds 400 to a forbidden method (TRACE) on the proxy path', async () => {
170+
const fastify = Fastify();
171+
await fastify.register(clerkPlugin, { frontendApiProxy: { enabled: true } });
172+
173+
const response = await fastify.inject({ method: 'TRACE' as 'GET', path: '/__clerk/v1/client' });
174+
175+
expect(response.statusCode).toEqual(400);
176+
expect(mockClerkFrontendApiProxy).not.toHaveBeenCalled();
177+
expect(authenticateRequestMock).not.toHaveBeenCalled();
178+
});
179+
161180
it('auto-derives proxyUrl for authentication when proxy is enabled', async () => {
162181
authenticateRequestMock.mockResolvedValueOnce({
163182
headers: new Headers(),

packages/fastify/src/__tests__/withClerkMiddleware.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,47 @@ describe('withClerkMiddleware(options)', () => {
243243
);
244244
});
245245

246+
describe('requests that cannot be converted to a web Request', () => {
247+
const setup = async () => {
248+
const fastify = Fastify();
249+
await fastify.register(clerkPlugin);
250+
fastify.get('/', (request: FastifyRequest, reply: FastifyReply) => {
251+
reply.send({ auth: getAuth(request) });
252+
});
253+
return fastify;
254+
};
255+
256+
test('responds 400 to a hostless // request target instead of throwing', async () => {
257+
const fastify = await setup();
258+
259+
const response = await fastify.inject({ method: 'GET', path: '//' });
260+
261+
expect(response.statusCode).toEqual(400);
262+
expect(authenticateRequestMock).not.toHaveBeenCalled();
263+
});
264+
265+
test('responds 400 to a request target that parses as a credentialed URL', async () => {
266+
const fastify = await setup();
267+
268+
const response = await fastify.inject({
269+
method: 'GET',
270+
path: "//$%7B%23context['xwork.MethodAccessor.denyMethodExecution']@example.com%7D.action",
271+
});
272+
273+
expect(response.statusCode).toEqual(400);
274+
expect(authenticateRequestMock).not.toHaveBeenCalled();
275+
});
276+
277+
test('responds 400 to a forbidden method (TRACE) instead of throwing', async () => {
278+
const fastify = await setup();
279+
280+
const response = await fastify.inject({ method: 'TRACE' as 'GET', path: '/' });
281+
282+
expect(response.statusCode).toEqual(400);
283+
expect(authenticateRequestMock).not.toHaveBeenCalled();
284+
});
285+
});
286+
246287
test('handles signout case by populating the req.auth', async () => {
247288
authenticateRequestMock.mockResolvedValueOnce({
248289
headers: new Headers(),

packages/fastify/src/withClerkMiddleware.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,28 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => {
3131
// Handle Frontend API proxy requests and auto-derive proxyUrl
3232
let resolvedProxyUrl = options.proxyUrl;
3333
if (frontendApiProxy) {
34-
const requestUrl = new URL(
35-
fastifyRequest.url,
36-
`${fastifyRequest.protocol}://${fastifyRequest.hostname || 'localhost'}`,
37-
);
34+
let requestUrl: URL;
35+
try {
36+
requestUrl = new URL(
37+
fastifyRequest.url,
38+
`${fastifyRequest.protocol}://${fastifyRequest.hostname || 'localhost'}`,
39+
);
40+
} catch {
41+
return reply.code(400).send();
42+
}
3843
const isEnabled =
3944
typeof frontendApiProxy.enabled === 'function'
4045
? frontendApiProxy.enabled(requestUrl)
4146
: frontendApiProxy.enabled;
4247

4348
if (isEnabled) {
4449
if (requestUrl.pathname === proxyPath || requestUrl.pathname.startsWith(proxyPath + '/')) {
45-
const proxyRequest = requestToProxyRequest(fastifyRequest);
50+
let proxyRequest: Request;
51+
try {
52+
proxyRequest = requestToProxyRequest(fastifyRequest);
53+
} catch {
54+
return reply.code(400).send();
55+
}
4656

4757
const proxyResponse = await clerkFrontendApiProxy(proxyRequest, {
4858
proxyPath,
@@ -84,7 +94,13 @@ export const withClerkMiddleware = (options: ClerkFastifyOptions) => {
8494
}
8595
}
8696

87-
const req = fastifyRequestToRequest(fastifyRequest);
97+
// Node accepts request targets/methods (`//`, TRACE) the fetch spec cannot represent; reject those instead of 500ing.
98+
let req: Request;
99+
try {
100+
req = fastifyRequestToRequest(fastifyRequest);
101+
} catch {
102+
return reply.code(400).send();
103+
}
88104

89105
const requestState = await clerkClient.authenticateRequest(req, {
90106
...options,

0 commit comments

Comments
 (0)