Stop a thrown listener escaping, bound what a peer can pin, and drop the body from failure responses

This commit is contained in:
2026-08-27 10:55:34 +02:00
parent 5af75a3c57
commit 884afdb87b
17 changed files with 420 additions and 87 deletions
+11 -3
View File
@@ -24,7 +24,8 @@ describe('header', () => {
test('writes command length, id, status and sequence number', () => {
const pdu = encode({ cmdName: 'bind_transceiver_resp', cmdStatus: 'ESME_RALYBND', seqNr: 1 });
assert.equal(pdu.readUInt32BE(0), 17);
// A failure response is header-only, so 16 rather than 17 with an empty system_id.
assert.equal(pdu.readUInt32BE(0), 16);
assert.equal(pdu.readUInt32BE(4).toString(16), '80000009');
assert.equal(pdu.readUInt32BE(8), 5);
assert.equal(pdu.readUInt32BE(12), 1);
@@ -397,7 +398,7 @@ describe('pduReturn()', () => {
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: 'foo' },
seqNr: 9,
}));
const { buffer, err } = pduReturn(request, 'ESME_RINVDSTADR', { message_id: 'abc123' });
const { buffer, err } = pduReturn(request, 'ESME_ROK', { message_id: 'abc123' });
assert.equal(err, undefined);
assert.ok(buffer);
@@ -405,9 +406,16 @@ describe('pduReturn()', () => {
const pduObj = decode(buffer);
assert.equal(pduObj.cmdName, 'submit_sm_resp');
assert.equal(pduObj.cmdStatus, 'ESME_RINVDSTADR');
assert.equal(pduObj.cmdStatus, 'ESME_ROK');
assert.equal(pduObj.params.message_id, 'abc123');
assert.equal(pduObj.seqNr, 9);
// The spec drops the body of a failure response, so the id a caller passes is not sent.
const refused = pduReturn(request, 'ESME_RINVDSTADR', { message_id: 'abc123' });
assert.ok(refused.buffer);
assert.equal(refused.buffer.length, 16);
assert.equal(decode(refused.buffer).params.message_id, undefined);
});
test('refuses a command that has no response', () => {
+34 -1
View File
@@ -1,7 +1,9 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test, { describe } from 'node:test';
import type { Dlr } from '../src/dlr.ts';
import type { ErrorName } from '../src/defs/errors.ts';
import type { MessageState } from '../src/defs/constants.ts';
import type { MessageDlr } from '../src/session.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Result } from '../src/result.ts';
@@ -9,7 +11,9 @@ import type { SendSmsResult } from '../src/send-sms.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
import { Reassembler, decodeSegments } from '../src/reassembly.ts';
import { DlrMerger } from '../src/dlr-merger.ts';
import { client } from '../src/client.ts';
import { consts } from '../src/defs/constants.ts';
import { errors } from '../src/defs/errors.ts';
import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts';
@@ -108,6 +112,34 @@ describe('merged delivery reports', () => {
});
});
describe('merging segment statuses', () => {
function receipt(smsId: string, statusMsg: MessageState): Dlr {
return {
doneDate: undefined,
errorCode: undefined,
receipt: undefined,
smsId,
statusId: consts.MESSAGE_STATE[statusMsg],
statusMsg,
};
}
// MESSAGE_STATE is a flat enum: ACCEPTED is 6 where UNDELIVERABLE is 5, so reducing on the
// wire value called a part-failed message delivered.
test('reports the worse of two states the wire numbers the other way round', () => {
const merger = new DlrMerger({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 });
merger.expect(['msg-1', 'msg-2']);
assert.equal(merger.collect(receipt('msg-1', 'UNDELIVERABLE')), undefined);
const merged = merger.collect(receipt('msg-2', 'ACCEPTED'));
assert.ok(merged);
assert.equal(merged.statusMsg, 'UNDELIVERABLE');
});
});
describe('sendSms()', () => {
function submitResp(seqNr: number, messageId: string, status: ErrorName = 'ESME_ROK'): PduObject {
return {
@@ -332,7 +364,8 @@ describe('reassembly bounds', () => {
const reassembler = new Reassembler({
log: silentLog,
max: 10,
maxOctets: 30,
// One segment is 36 octets: 14 of short_message plus the two 11-octet addresses.
maxOctets: 80,
now: () => 0,
timeout: 60_000,
});
+90 -4
View File
@@ -353,12 +353,14 @@ describe('bind', () => {
await named.close();
});
test('answers a refused bind with its own system_id too', async () => {
// The echo leak cannot reach a refusal at all: the spec gives a failure response no body.
test('answers a refused bind with no body to leak', async () => {
const smpp = await startServer({ authenticate: () => false, systemId: 'the-smsc' });
const refused = await bindRaw(smpp, 0x34);
assert.equal(refused.cmdStatus, 'ESME_RBINDFAIL');
assert.equal(refused.params.system_id, 'the-smsc');
assert.equal(refused.cmdLength, 16);
assert.deepEqual(refused.params, {});
await smpp.close();
});
@@ -373,7 +375,7 @@ describe('bind', () => {
await smpp.close();
});
test('answers a second bind with ESME_RALYBND and its own system_id', async () => {
test('answers a second bind with ESME_RALYBND and no body', async () => {
const smpp = await startServer({ systemId: 'the-smsc' });
const peer = rawPeer(smpp.port);
@@ -384,7 +386,7 @@ describe('bind', () => {
const again = await peer.next();
assert.equal(again.cmdStatus, 'ESME_RALYBND');
assert.equal(again.params.system_id, 'the-smsc');
assert.deepEqual(again.params, {});
peer.close();
await smpp.close();
@@ -905,6 +907,90 @@ describe('application hooks that throw', () => {
await smpp.close();
});
// The guard for a throwing sms listener used to emit sessionError from inside its own catch.
test('survives a sessionError listener that throws as well', async () => {
const smpp = await startServer();
smpp.on('session', session => {
session.on('sessionError', () => { throw new Error('the reporter exploded too'); });
session.on('sms', () => { throw new Error('listener exploded'); });
});
const { session } = await connect(smpp, { responseTimeout: 200 });
assert.ok(session);
const sent = await session.sendSms({
from: '46701113311',
message: 'blows up both listeners',
to: '46709771337',
});
assert.ok(sent.err instanceof Error);
session.close();
await smpp.close();
});
test('closes even when an application close listener throws', async () => {
const smpp = await startServer();
smpp.on('session', session => {
session.on('close', () => { throw new Error('close listener exploded'); });
});
const { session } = await connect(smpp);
assert.ok(session);
await smpp.close();
assert.equal(smpp.sessions.size, 0);
session.close();
});
test('refuses a send window that can never free a slot', async () => {
const smpp = await startServer();
const { err, session } = await connect(smpp, { maxOutstanding: 0 });
assert.ok(err instanceof Error);
assert.match(err.message, /maxOutstanding/);
assert.equal(session, undefined);
await smpp.close();
});
test('keeps the message id off a submit_sm_resp that refuses the message', async () => {
const smpp = await startServer();
smpp.on('session', bound => {
bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); });
});
const peer = rawPeer(smpp.port);
peer.write(bindOf(0x34));
await peer.next();
peer.write({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
short_message: 'full queue',
source_addr: '46701113311',
},
seqNr: 2,
});
const refused = await peer.next();
assert.equal(refused.cmdName, 'submit_sm_resp');
assert.equal(refused.cmdStatus, 'ESME_RMSGQFUL');
assert.equal(refused.cmdLength, 16);
assert.deepEqual(refused.params, {});
peer.close();
await smpp.close();
});
test('keeps the reconnect loop alive when connect throws', async () => {
let attempts = 0;
const loop = new ReconnectLoop({
+8
View File
@@ -49,6 +49,14 @@ describe('string (Octet String)', () => {
assert.deepEqual(types.string.read(encoded, 0), { bytesRead: 9, value: expected });
});
// The length is one octet, so a longer value has nowhere to say how long it is.
test('refuses a value longer than the length octet can count', () => {
const tooLong = 'x'.repeat(256);
assert.ok(types.string.size(tooLong).err instanceof Error);
assert.ok(types.string.write(tooLong, Buffer.alloc(300), 0).err instanceof Error);
});
test('sizes as the string plus its length octet', () => {
assert.deepEqual(types.string.size(expected), { size: 9 });
});