fix(microservices): handle unserializable patterns without crashing

"JSON.stringify" throws a RangeError for deeply nested message patterns.
The TCP and RMQ servers stringified client-controlled patterns without
guarding against it, and the resulting unhandled promise rejection
terminated the process.

Patterns are now stringified safely (falling back to a sentinel that
matches no handler) and rejected message handling is routed to
"handleError" instead of being left unhandled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kamil MyśliwiecandClaude Fable 5.1 committed 2026-09-14 15:38:45 +02:00
1 parent dda252090e
commit aa97b5144d
6 files changed
+117 -15

No files matched your search

+1
View File
@@ -63,3 +63,4 @@ export const BLOCKED_RMQ_MESSAGE = (reason: string) =>
export const UNBLOCKED_RMQ_MESSAGE = 'RMQ broker has unblocked the connection.';
export const NATS_DEFAULT_GRACE_PERIOD = 10000;
export const UNSERIALIZABLE_PATTERN = '[UNSERIALIZABLE_PATTERN]';
+4 -5
View File
@@ -35,7 +35,7 @@ import {
} from '../interfaces/packet.interface.js';
import { RmqRecordSerializer } from '../serializers/rmq-record.serializer.js';
import { Server } from './server.js';
import { isNil, isString, isUndefined } from '@nestjs/common/internal';
import { isNil, isUndefined } from '@nestjs/common/internal';
// To enable type safety for RMQ. This cant be uncommented by default
// because it would require the user to install the amqplib package even if they dont use RabbitMQ
@@ -283,7 +283,8 @@ export class ServerRMQ extends Server<RmqEvents, RmqStatus> {
await channel.prefetch(prefetchCount, isGlobalPrefetchCount);
channel.consume(
createdQueue,
(msg: Record<string, any> | null) => this.handleMessage(msg!, channel),
(msg: Record<string, any> | null) =>
this.handleMessage(msg!, channel).catch(err => this.handleError(err)),
{
noAck: this.noAck,
consumerTag: this.getOptionsProp(
@@ -306,9 +307,7 @@ export class ServerRMQ extends Server<RmqEvents, RmqStatus> {
const { content, properties } = message;
const rawMessage = this.parseMessageContent(content);
const packet = await this.deserializer.deserialize(rawMessage, properties);
const pattern = isString(packet.pattern)
? packet.pattern
: JSON.stringify(packet.pattern);
const pattern = this.getPatternAsString(packet.pattern);
const rmqContext = new RmqContext([message, channel, pattern]);
if (isUndefined((packet as IncomingRequest).id)) {
+4 -6
View File
@@ -25,7 +25,7 @@ import {
TransportId,
} from '../interfaces/microservice-configuration.interface.js';
import { Server } from './server.js';
import { isString, isUndefined } from '@nestjs/common/internal';
import { isUndefined } from '@nestjs/common/internal';
/**
* @publicApi
@@ -81,8 +81,8 @@ export class ServerTCP extends Server<TcpEvents, TcpStatus> {
public bindHandler(socket: Socket) {
const readSocket = this.getSocketInstance(socket);
readSocket.on('message', async (msg: ReadPacket & PacketId) =>
this.handleMessage(readSocket, msg),
readSocket.on('message', (msg: ReadPacket & PacketId) =>
this.handleMessage(readSocket, msg).catch(err => this.handleError(err)),
);
readSocket.on(TcpEventsMap.ERROR, err => {
const invalidError = new InvalidTcpDataReceptionException(err);
@@ -92,9 +92,7 @@ export class ServerTCP extends Server<TcpEvents, TcpStatus> {
public async handleMessage(socket: TcpSocket, rawMessage: unknown) {
const packet = await this.deserializer.deserialize(rawMessage);
const pattern = !isString(packet.pattern)
? JSON.stringify(packet.pattern)
: packet.pattern;
const pattern = this.getPatternAsString(packet.pattern);
const tcpContext = new TcpContext([socket, pattern]);
if (isUndefined((packet as IncomingRequest).id)) {
+27 -2
View File
@@ -16,7 +16,7 @@ import {
finalize,
mergeMap,
} from 'rxjs/operators';
import { NO_EVENT_HANDLER } from '../constants.js';
import { NO_EVENT_HANDLER, UNSERIALIZABLE_PATTERN } from '../constants.js';
import { BaseRpcContext } from '../ctx-host/base-rpc.context.js';
import { IncomingRequestDeserializer } from '../deserializers/incoming-request.deserializer.js';
import { Transport } from '../enums/index.js';
@@ -39,7 +39,11 @@ import { ConsumerSerializer } from '../interfaces/serializer.interface.js';
import { IdentitySerializer } from '../serializers/identity.serializer.js';
import { transformPatternToRoute } from '../utils/index.js';
import { ITransportServer, Logger, type LoggerService } from '@nestjs/common';
import { loadPackage, loadPackageSync } from '@nestjs/common/internal';
import {
isString,
loadPackage,
loadPackageSync,
} from '@nestjs/common/internal';
/**
* @publicApi
@@ -356,4 +360,25 @@ export abstract class Server<
protected normalizePattern(pattern: MsPattern): string {
return transformPatternToRoute(pattern);
}
/**
* Returns the string representation of an incoming message pattern.
*
* Patterns are client-controlled: serializing a deeply nested one makes
* `JSON.stringify` throw a `RangeError`, which must not escape the message
* handler (as an unhandled promise rejection that terminates the process).
*
* @param {unknown} pattern - client pattern
* @returns string
*/
protected getPatternAsString(pattern: unknown): string {
if (isString(pattern)) {
return pattern;
}
try {
return JSON.stringify(pattern);
} catch {
return UNSERIALIZABLE_PATTERN;
}
}
}
@@ -107,12 +107,16 @@ describe('ServerRMQ', () => {
});
describe('handleMessage', () => {
const createMessage = payload => ({
const createRawMessage = (content: string) => ({
content: {
toString: () => JSON.stringify(payload),
toString: () => content,
},
properties: { correlationId: 1 },
});
const createMessage = payload => createRawMessage(JSON.stringify(payload));
// "JSON.parse" copes with this depth but "JSON.stringify" throws a RangeError
const createDeeplyNestedJson = (depth: number) =>
'{"nested":'.repeat(depth) + '{}' + '}'.repeat(depth);
const pattern = 'test';
const msg = createMessage({
pattern,
@@ -139,6 +143,22 @@ describe('ServerRMQ', () => {
await server.handleMessage(createMessage({ pattern: '', data: '' }), '');
expect(handleEventSpy).toHaveBeenCalled();
});
it('should send NO_MESSAGE_HANDLER error if pattern is too deeply nested to be serialized', async () => {
const deeplyNestedMsg = createRawMessage(
`{"pattern":${createDeeplyNestedJson(100_000)},"data":"tests","id":"3"}`,
);
await server.handleMessage(deeplyNestedMsg, '');
expect(sendMessageStub).toHaveBeenCalledWith(
{
id: '3',
err: NO_MESSAGE_HANDLER,
status: 'error',
},
undefined,
1,
expect.any(RmqContext),
);
});
it('should send NO_MESSAGE_HANDLER error if key does not exists in handlers object', async () => {
await server.handleMessage(msg, '');
expect(sendMessageStub).toHaveBeenCalledWith(
@@ -278,6 +298,19 @@ describe('ServerRMQ', () => {
await server.setupChannel(channel, () => null);
expect(channel.consume).toHaveBeenCalled();
});
it('should route "handleMessage" rejections to "handleError" instead of leaving them unhandled', async () => {
const error = new Error('unexpected');
vi.spyOn(server, 'handleMessage').mockRejectedValue(error);
const handleErrorSpy = vi
.spyOn(untypedServer, 'handleError')
.mockImplementation(() => undefined);
await server.setupChannel(channel, () => null);
const [, onMessage] = channel.consume.mock.calls[0];
await onMessage({});
expect(handleErrorSpy).toHaveBeenCalledWith(error);
});
it('should call "resolve" function', async () => {
const resolve = vi.fn();
await server.setupChannel(channel, resolve);
@@ -5,6 +5,14 @@ import { TcpSocket } from '../../helpers/tcp-socket.js';
import { ServerTCP } from '../../server/server-tcp.js';
import { objectToMap } from './utils/object-to-map.js';
const createDeeplyNestedObject = (depth: number) => {
let pattern: Record<string, unknown> = {};
for (let i = 0; i < depth; i++) {
pattern = { nested: pattern };
}
return pattern;
};
describe('ServerTCP', () => {
let server: ServerTCP;
let untypedServer: any;
@@ -26,6 +34,21 @@ describe('ServerTCP', () => {
server.bindHandler(null!);
expect(socket.on).toHaveBeenCalledTimes(2);
});
it('should route "handleMessage" rejections to "handleError" instead of leaving them unhandled', async () => {
const error = new Error('unexpected');
vi.spyOn(server, 'handleMessage').mockRejectedValue(error);
const handleErrorSpy = vi
.spyOn(untypedServer, 'handleError')
.mockImplementation(() => undefined);
server.bindHandler(null!);
const [, onMessage] = socket.on.mock.calls.find(
([event]) => event === 'message',
);
await onMessage({});
expect(handleErrorSpy).toHaveBeenCalledWith(error);
});
});
describe('close', () => {
const tcpServer = { close: vi.fn() };
@@ -80,6 +103,29 @@ describe('ServerTCP', () => {
await server.handleMessage(socket, msg);
expect(handler).toHaveBeenCalledOnce();
});
it('should send NO_MESSAGE_HANDLER error if pattern is too deeply nested to be serialized', async () => {
const deeplyNestedMsg = {
...msg,
pattern: createDeeplyNestedObject(100_000),
};
await server.handleMessage(socket, deeplyNestedMsg);
expect(socket.sendMessage).toHaveBeenCalledWith({
id: msg.id,
status: 'error',
err: NO_MESSAGE_HANDLER,
});
});
it('should call "handleEvent" if pattern is too deeply nested to be serialized and identifier is not present', async () => {
const handleEventSpy = vi
.spyOn(server, 'handleEvent')
.mockImplementation(async () => undefined);
const deeplyNestedEvent = {
pattern: createDeeplyNestedObject(100_000),
data: 'tests',
};
await server.handleMessage(socket, deeplyNestedEvent);
expect(handleEventSpy).toHaveBeenCalledOnce();
});
});
describe('handleClose', () => {
describe('when is terminated', () => {