Compare commits

...

4 Commits

Author SHA1 Message Date
Damien Arrachequesne
522edcdbb8 chore(release): socket.io-parser@4.2.6
Diff: https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.5...socket.io-parser@4.2.6
2026-03-17 10:53:22 +01:00
Damien Arrachequesne
3fff7cafa9 fix(parser): add a limit to the number of binary attachments
When a packet contains binary elements, the built-in parser does not modify them and simply sends them in their own WebSocket frame.

Example: `socket.emit("some event", Buffer.of(1,2,3))`

is encoded and transferred as:

- 1st frame: 51-["some event",{"_placeholder":true,"num":0}]
- 2nd frame: <buffer 01 02 03>

where:

- `5` is the type of the packet (binary message)
- `1` is the number of binary attachments
- `-` is the separator
- `["some event",{"_placeholder":true,"num":0}]` is the payload (including the placeholder)

On the receiving end, the parser reads the number of attachments and buffers them until they are all received.

Before this change, the built-in parser accepted any number of binary attachments, which could be exploited to make the server run out of memory.

The number of attachments is now limited to 10, which should be sufficient for most use cases.

The limit can be increased with a custom `parser`:

```js
import { Encoder, Decoder } from "socket.io-parser";

const io = new Server({
  parser: {
    Encoder,
    Decoder: class extends Decoder {
      constructor() {
        super({
          maxAttachments: 20
        });
      }
    }
  }
});
```
2026-03-17 10:27:15 +01:00
Damien Arrachequesne
37aad11417 fix: cleanup pending acks on timeout to prevent memory leak
Related: https://github.com/socketio/socket.io/issues/4984
2026-03-11 18:26:52 +01:00
Damien Arrachequesne
ba9cd6900d revert: fix: cleanup pending acks on timeout to prevent memory leak
This reverts commit da04267ffc.

The reverted fix was incorrect because the rooms might have changed between the emit() and the timeout.
2026-03-11 18:26:30 +01:00
7 changed files with 125 additions and 22 deletions

View File

@@ -508,7 +508,7 @@ export abstract class ClusterAdapter extends Adapter {
}, opts.flags!.timeout);
}
return super.broadcastWithAck(packet, opts, clientCountCallback, ack);
super.broadcastWithAck(packet, opts, clientCountCallback, ack);
}
override async addSockets(opts: BroadcastOptions, rooms: Room[]) {

View File

@@ -229,14 +229,6 @@ export class Adapter extends EventEmitter {
});
clientCountCallback(clientCount);
return {
cleanup: () => {
this.apply(opts, (socket) => {
socket.acks.delete(packet.id);
});
},
};
}
private _encode(packet: unknown, packetOpts: Record<string, unknown>) {

View File

@@ -2,6 +2,7 @@
| Version | Release date |
|-------------------------------------------------------------------------------------------------------------|----------------|
| [4.2.6](#426-2026-03-17) | March 2026 |
| [4.2.5](#425-2025-12-23) | December 2025 |
| [3.3.4](#334-2024-07-22) (from the [3.3.x](https://github.com/socketio/socket.io-parser/tree/3.3.x) branch) | July 2024 |
| [4.2.4](#424-2023-05-31) | May 2023 |
@@ -34,6 +35,15 @@
| [3.3.0](#330-2018-11-07) | November 2018 |
## [4.2.6](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.5...socket.io-parser@4.2.6) (2026-03-17)
### Bug Fixes
* **parser:** add a limit to the number of binary attachments ([3fff7ca](https://github.com/socketio/socket/commit/3fff7cafa98f1ba5840475b6917c651fe841a943))
## [4.2.5](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.4...socket.io-parser@4.2.5) (2025-12-23)
This release contains a bump of `debug` from `~4.3.1` to `~4.4.1`.

View File

@@ -135,6 +135,20 @@ interface DecoderReservedEvents {
decoded: (packet: Packet) => void;
}
type JSONReviver = (this: any, key: string, value: any) => any;
export interface DecoderOptions {
/**
* Custom reviver to pass down to JSON.parse()
*/
reviver?: JSONReviver;
/**
* Maximum number of binary attachments per packet
* @default 10
*/
maxAttachments?: number;
}
/**
* A socket.io Decoder instance
*
@@ -142,14 +156,20 @@ interface DecoderReservedEvents {
*/
export class Decoder extends Emitter<{}, {}, DecoderReservedEvents> {
private reconstructor: BinaryReconstructor;
private opts: Required<DecoderOptions>;
/**
* Decoder constructor
*
* @param {function} reviver - custom reviver to pass down to JSON.stringify
*/
constructor(private reviver?: (this: any, key: string, value: any) => any) {
constructor(opts?: DecoderOptions | JSONReviver) {
super();
this.opts = Object.assign(
{
reviver: undefined,
maxAttachments: 10,
},
typeof opts === "function" ? { reviver: opts } : opts,
);
}
/**
@@ -224,7 +244,13 @@ export class Decoder extends Emitter<{}, {}, DecoderReservedEvents> {
if (buf != Number(buf) || str.charAt(i) !== "-") {
throw new Error("Illegal attachments");
}
p.attachments = Number(buf);
const n = Number(buf);
if (!isInteger(n) || n < 0) {
throw new Error("Illegal attachments");
} else if (n > this.opts.maxAttachments) {
throw new Error("too many attachments");
}
p.attachments = n;
}
// look up namespace (if any)
@@ -271,7 +297,7 @@ export class Decoder extends Emitter<{}, {}, DecoderReservedEvents> {
private tryParse(str) {
try {
return JSON.parse(str, this.reviver);
return JSON.parse(str, this.opts.reviver);
} catch (e) {
return false;
}

View File

@@ -1,6 +1,6 @@
{
"name": "socket.io-parser",
"version": "4.2.5",
"version": "4.2.6",
"description": "socket.io protocol parser",
"homepage": "https://github.com/socketio/socket.io/tree/main/packages/socket.io-client#readme",
"repository": {

View File

@@ -107,6 +107,56 @@ describe("socket.io-parser", () => {
}
});
it("throws an error when receiving too many attachments", () => {
const decoder = new Decoder({ maxAttachments: 2 });
expect(() => {
decoder.add(
'53-["hello",{"_placeholder":true,"num":0},{"_placeholder":true,"num":1},{"_placeholder":true,"num":2}]',
);
}).to.throwException(/^too many attachments$/);
});
it("decodes with a custom reviver", () => {
const decoder = new Decoder((key, value) => {
if (key === "a") {
return value.toUpperCase();
} else {
return value;
}
});
return new Promise((resolve) => {
decoder.on("decoded", (packet) => {
expect(packet.data).to.eql(["b", { a: "VAL" }]);
resolve();
});
decoder.add('2["b",{"a":"val"}]');
});
});
it("decodes with a custom reviver (options object)", () => {
const decoder = new Decoder({
reviver: (key, value) => {
if (key === "a") {
return value.toUpperCase();
} else {
return value;
}
},
});
return new Promise((resolve) => {
decoder.on("decoded", (packet) => {
expect(packet.data).to.eql(["b", { a: "VAL" }]);
resolve();
});
decoder.add('2["b",{"a":"val"}]');
});
});
it("throw an error upon parsing error", () => {
const isInvalidPayload = (str) =>
expect(() => new Decoder().add(str)).to.throwException(
@@ -125,6 +175,16 @@ describe("socket.io-parser", () => {
isInvalidPayload('2["connect"]');
isInvalidPayload('2["disconnect","123"]');
const isInvalidAttachmentCount = (str) =>
expect(() => new Decoder().add(str)).to.throwException(
/^Illegal attachments$/,
);
isInvalidAttachmentCount("5");
isInvalidAttachmentCount("51");
isInvalidAttachmentCount("5a-");
isInvalidAttachmentCount("51.23-");
expect(() => new Decoder().add("999")).to.throwException(
/^unknown packet type 9$/,
);

View File

@@ -13,6 +13,9 @@ import type {
FirstNonErrorArg,
EventNamesWithError,
} from "./typed-events";
import debugModule from "debug";
const debug = debugModule("socket.io:broadcast-operator");
export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
implements TypedEventBroadcaster<EmitEvents>
@@ -232,11 +235,20 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
const ack = data.pop() as (...args: any[]) => void;
let timedOut = false;
let responses: any[] = [];
let cleanupPendingAcks: (() => void) | undefined;
const timer = setTimeout(() => {
timedOut = true;
cleanupPendingAcks?.();
debug("operation has timed out");
// @ts-expect-error
const packetId = packet.id;
if (packetId !== undefined) {
this.adapter.nsp.sockets.forEach((socket) => {
socket.acks.delete(packetId);
});
}
ack.apply(this, [
new Error("operation has timed out"),
this.flags.expectSingleResponse ? null : responses,
@@ -248,6 +260,13 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
let expectedClientCount = 0;
const checkCompleteness = () => {
debug(
"responses: servers: %d / %d ; clients: %d / %d",
actualServerCount,
expectedServerCount,
responses.length,
expectedClientCount,
);
if (
!timedOut &&
expectedServerCount === actualServerCount &&
@@ -261,7 +280,7 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
}
};
const result = this.adapter.broadcastWithAck(
this.adapter.broadcastWithAck(
packet,
{
rooms: this.rooms,
@@ -281,10 +300,6 @@ export class BroadcastOperator<EmitEvents extends EventsMap, SocketData>
},
);
if (result && typeof result.cleanup === "function") {
cleanupPendingAcks = result.cleanup;
}
this.adapter.serverCount().then((serverCount) => {
expectedServerCount = serverCount;
checkCompleteness();