mirror of
https://github.com/nestjs/nest.git
synced 2026-10-28 09:45:43 +08:00
fix(fastify): set the security headers on router error responses
Fastify's router answers a malformed URL (400 FST_ERR_BAD_URL) and a route parameter longer than maxParamLength (414 FST_ERR_MAX_PARAM_LENGTH) itself, before any onRequest hook runs, so these responses had none of the headers set by useSecurityHeaders(). FastifyAdapter now sets the frameworkErrors option to run the security hook for its headers, then sends the error through Fastify's default error handler. The body keeps its fields, in that handler's order, and the 414 one now says "URI Too Long". A frameworkErrors option passed to the adapter still wins. These errors still bypass exception filters. Discovered by Anthropic's OSS Scanner, as bug ANT-2026-RVC0JPUX.
This commit is contained in:
3 files changed
+94
No files matched your search
@@ -94,6 +94,20 @@ describe.each(adapters)('Security headers (%s)', (_name, createAdapter) => {
|
||||
expectDefaultHeaders(res.headers);
|
||||
});
|
||||
|
||||
// Fastify's router answers these before any hook runs.
|
||||
it('sets them on requests rejected by the router', async () => {
|
||||
const server = app.getHttpServer();
|
||||
const badUrl = await request(server).post('/hooks/github/%').expect(400);
|
||||
expectDefaultHeaders(badUrl.headers);
|
||||
|
||||
if (app.getHttpAdapter().getType() === 'fastify') {
|
||||
const longParam = await request(server)
|
||||
.post(`/hooks/${'a'.repeat(101)}`)
|
||||
.expect(414);
|
||||
expectDefaultHeaders(longParam.headers);
|
||||
}
|
||||
});
|
||||
|
||||
it('sets them on Server-Sent Events streams', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/events')
|
||||
|
||||
@@ -313,6 +313,23 @@ export class FastifyAdapter<
|
||||
instanceOrOptions && (instanceOrOptions as TInstance).server
|
||||
? instanceOrOptions
|
||||
: fastify({
|
||||
// The router answers a malformed URL (400) or an over-long route
|
||||
// parameter (414) before any hook runs, so the security hook (see
|
||||
// `registerSecurityHook()`) runs here, for its headers.
|
||||
frameworkErrors: (
|
||||
error: FastifyError,
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
try {
|
||||
this.securityHook?.(request as unknown as TRequest, reply.raw);
|
||||
} catch {
|
||||
// The request is rejected either way, and a throw here would
|
||||
// escape the router. `request.params` and `request.query`
|
||||
// are null, so a CSRF `exclude` predicate may throw.
|
||||
}
|
||||
reply.send(error);
|
||||
},
|
||||
...(instanceOrOptions as FastifyServerOptions),
|
||||
routerOptions: {
|
||||
...this.getTopLevelRouterOptions(
|
||||
|
||||
@@ -573,6 +573,69 @@ describe('FastifyAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('requests rejected by the router', () => {
|
||||
const securityHook = (_req: unknown, res: any) => {
|
||||
res.setHeader('x-security-hook', 'ran');
|
||||
return undefined;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyAdapter.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a malformed percent-encoding', '/items/%', 400, 'FST_ERR_BAD_URL'],
|
||||
[
|
||||
'an over-long parameter',
|
||||
`/items/${'a'.repeat(101)}`,
|
||||
414,
|
||||
'FST_ERR_MAX_PARAM_LENGTH',
|
||||
],
|
||||
])(
|
||||
'should run the security hook for a request with %s',
|
||||
async (_name, url, statusCode, code) => {
|
||||
const handler = vi.fn(() => 'found');
|
||||
const hook = vi.fn(securityHook);
|
||||
fastifyAdapter.get('/items/:id', handler);
|
||||
fastifyAdapter.registerSecurityHook(hook);
|
||||
|
||||
const res = await fastifyAdapter.inject({ method: 'GET', url });
|
||||
|
||||
expect(res.statusCode).toBe(statusCode);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ statusCode, code });
|
||||
expect(res.headers['x-security-hook']).toBe('ran');
|
||||
expect(hook).toHaveBeenCalledOnce();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("should send the router's error when the security hook throws", async () => {
|
||||
fastifyAdapter.registerSecurityHook((req, res) => {
|
||||
securityHook(req, res);
|
||||
throw new TypeError('the request has no params');
|
||||
});
|
||||
|
||||
const res = await fastifyAdapter.inject({ method: 'POST', url: '/%' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ code: 'FST_ERR_BAD_URL' });
|
||||
expect(res.headers['x-security-hook']).toBe('ran');
|
||||
});
|
||||
|
||||
it('should keep a "frameworkErrors" option passed to the adapter', async () => {
|
||||
const frameworkErrors = vi.fn((_error, _req, reply: FastifyReply) =>
|
||||
reply.code(400).send('custom'),
|
||||
);
|
||||
fastifyAdapter = new FastifyAdapter({ frameworkErrors });
|
||||
fastifyAdapter.registerSecurityHook(securityHook);
|
||||
|
||||
const res = await fastifyAdapter.inject({ method: 'GET', url: '/%' });
|
||||
|
||||
expect(res.body).toBe('custom');
|
||||
expect(frameworkErrors).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyVersionFilter', () => {
|
||||
const registerVersionNeutralRoute = (
|
||||
type: VersioningType.MEDIA_TYPE | VersioningType.HEADER,
|
||||
|
||||
Reference in new issue
Block a user