Charge each held segment and its TLVs for their objects against the reassembly cap #27

Merged
lilleman merged 6 commits from tlv-object-charge into main 2026-09-25 19:19:16 +02:00
6 changed files with 43 additions and 26 deletions
+5
View File
@@ -35,6 +35,11 @@
- An `alert_notification` or an `outbind` from the peer is logged and left unanswered, as SMPP 3.4 - An `alert_notification` or an `outbind` from the peer is logged and left unanswered, as SMPP 3.4
gives neither a response. Each one used to emit `sessionError`, `"alert_notification" has no gives neither a response. Each one used to emit `sessionError`, `"alert_notification" has no
response command`. response command`.
- `maxOctets` charges each held segment 1000 octets beyond its own, 300 more per TLV on it, and 300
per occurrence of a repeatable one. Segments of empty fields or thousands of empty TLVs used to
count as next to nothing, so a peer could hold far more than the cap. **Raise a `maxOctets` you
tuned low**: it now holds several times fewer segments, and an incomplete message evicted over
the cap is lost, since its segments were already answered.
- `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its - `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its
other limits. `server({ maxOctets: 0 })` used to start and then refuse every multipart message. other limits. `server({ maxOctets: 0 })` used to start and then refuse every multipart message.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and - `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
+1 -1
View File
@@ -261,7 +261,7 @@ All optional. Timeouts are milliseconds.
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. | | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. | | `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. | | `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
| `maxOctets` | `67108864` | Roughly the memory incomplete multipart messages may hold per session. | | `maxOctets` | `67108864` | Roughly the memory incomplete multipart messages may hold per session: each held segment counts its octets plus 1000, 300 more per TLV on it, and 300 per occurrence of a repeatable one. |
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. | | `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | | | `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
+5 -4
View File
@@ -73,8 +73,9 @@ function detach(pduObj: PduObject): PduObject {
return { ...pduObj, params, shortMessageOctets: octets, tlvs }; return { ...pduObj, params, shortMessageOctets: octets, tlvs };
} }
// Roughly the heap a listed occurrence costs beyond its value, so a PDU of empty repeats is not free. // Measured heap beyond the octets, so a segment of empty fields or empty TLVs is not free.
const listedTlvOverhead = 200; const segmentObjectOverhead = 1000;
const tlvObjectOverhead = 300;
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU. // A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
function sizeOf(value: ParamValue): number { function sizeOf(value: ParamValue): number {
@@ -84,7 +85,7 @@ function sizeOf(value: ParamValue): number {
} }
function octetsOf(pduObj: PduObject): number { function octetsOf(pduObj: PduObject): number {
let octets = 0; let octets = segmentObjectOverhead;
for (const value of Object.values(pduObj.params)) { for (const value of Object.values(pduObj.params)) {
octets += sizeOf(value); octets += sizeOf(value);
@@ -93,7 +94,7 @@ function octetsOf(pduObj: PduObject): number {
for (const tlv of Object.values(pduObj.tlvs)) { for (const tlv of Object.values(pduObj.tlvs)) {
const listed = Array.isArray(tlv.tagValue) ? tlv.tagValue.length : 0; const listed = Array.isArray(tlv.tagValue) ? tlv.tagValue.length : 0;
octets += tlvOctets(tlv.tagValue) + listed * listedTlvOverhead; octets += tlvOctets(tlv.tagValue) + (1 + listed) * tlvObjectOverhead;
} }
return octets; return octets;
+25 -14
View File
@@ -1805,7 +1805,7 @@ describe('reassembly bounds', () => {
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost'); assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
}); });
// The two addresses are 22 octets, so only the 14 the TLV carries can overrun a cap of 30. // The two addresses and the segment's and TLV's objects are 1322 octets, so only the 14 it carries can overrun 1330.
test('counts a body carried in message_payload against the octet cap', () => { test('counts a body carried in message_payload against the octet cap', () => {
function collectPayload(maxOctets: number): Collected { function collectPayload(maxOctets: number): Collected {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
@@ -1820,12 +1820,12 @@ describe('reassembly bounds', () => {
return collectPdu(reassembler, payloadSegment(9, 1, 2)); return collectPdu(reassembler, payloadSegment(9, 1, 2));
} }
assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later'); assert.equal(collectPayload(1330).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
assert.equal(collectPayload(40).kept, true); assert.equal(collectPayload(1340).kept, true);
}); });
test('counts every occurrence of a repeatable TLV against the octet cap, empty ones included', () => { test('counts the objects a segment and each of its TLVs hold against the octet cap, empty ones included', () => {
function collectCallbacks(maxOctets: number, tagValue: Buffer[]): Collected { function collectTlvs(maxOctets: number, tlvs: PduObject['tlvs']): Collected {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
log: silentLog, log: silentLog,
max: 10, max: 10,
@@ -1834,11 +1834,17 @@ describe('reassembly bounds', () => {
onLost: () => undefined, onLost: () => undefined,
timeout: 60_000, timeout: 60_000,
}); });
const carried = segment(9, 1, 2);
return collectPdu(reassembler, { ...carried, tlvs: { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } } }); return collectPdu(reassembler, { ...segment(9, 1, 2), tlvs });
} }
function collectCallbacks(maxOctets: number, tagValue: Buffer[]): Collected {
return collectTlvs(maxOctets, { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } });
}
const unknownTags = Object.fromEntries(Array.from({ length: 10_000 }, (_, i) => {
const tagId = 0x4000 + i;
return [String(tagId), { tagId, tagName: undefined, tagValue: Buffer.alloc(0) }];
}));
const numbers = [Buffer.alloc(10_000, 0x31), Buffer.alloc(10_000, 0x32)]; const numbers = [Buffer.alloc(10_000, 0x31), Buffer.alloc(10_000, 0x32)];
assert.equal(collectCallbacks(20_000, numbers).kept, false); assert.equal(collectCallbacks(20_000, numbers).kept, false);
@@ -1848,6 +1854,11 @@ describe('reassembly bounds', () => {
false, false,
'an empty occurrence still holds an object', 'an empty occurrence still holds an object',
); );
// The segment itself is 1036 octets, so the tags must be charged 300 each to overrun 3,001,000.
assert.equal(collectTlvs(3_001_000, unknownTags).kept, false, 'an empty tag still holds an object');
assert.equal(collectTlvs(3_002_000, unknownTags).kept, true);
assert.equal(collectTlvs(1_000, {}).kept, false, 'a segment holds objects beyond its 36 octets');
assert.equal(collectTlvs(1_036, {}).kept, true);
}); });
// The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one. // The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one.
@@ -1856,8 +1867,8 @@ describe('reassembly bounds', () => {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
log: silentLog, log: silentLog,
max: 10, max: 10,
// One segment is 36 octets, so the second overruns a group already holding the first. // One segment is 1036 octets, so the second overruns a group already holding the first.
maxOctets: 50, maxOctets: 1050,
now: () => 0, now: () => 0,
onLost: one => { lost.push(one); }, onLost: one => { lost.push(one); },
timeout: 60_000, timeout: 60_000,
@@ -1921,9 +1932,9 @@ describe('reassembly bounds', () => {
assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would'); assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would');
counted.clear(); counted.clear();
// A sar_* segment is the two 11-octet addresses plus an 8-octet body, with no UDH to carry. // A sar_* segment is the two 11-octet addresses, an 8-octet body, its own object and three TLVs'.
assert.equal(collectSar(capped(20), 3, 1, 2).kept, false); assert.equal(collectSar(capped(1920), 3, 1, 2).kept, false);
assert.equal(collectSar(capped(30), 3, 1, 2).kept, true); assert.equal(collectSar(capped(1930), 3, 1, 2).kept, true);
}); });
// Nothing else says a message the peer has already been answered for was thrown away. // Nothing else says a message the peer has already been answered for was thrown away.
@@ -2039,8 +2050,8 @@ describe('reassembly bounds', () => {
const reassembler = new Reassembler({ const reassembler = new Reassembler({
log: silentLog, log: silentLog,
max: 10, max: 10,
// One segment is 36 octets: 14 of short_message plus the two 11-octet addresses. // One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object.
maxOctets: 80, maxOctets: 2100,
now: () => 0, now: () => 0,
onLost: () => undefined, onLost: () => undefined,
timeout: 60_000, timeout: 60_000,
+2 -2
View File
@@ -910,8 +910,8 @@ describe('receiving', () => {
// The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it. // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
test('refuses a data_sm segment a server has no room for with the submit code', async t => { test('refuses a data_sm segment a server has no room for with the submit code', async t => {
// The two addresses are 22 octets, so the 6-octet UDH and its text are what overrun 30. // The two addresses and the objects are 1322 octets, so the 6-octet UDH and its text are what overrun 1330.
const smpp = await startServer(t, { maxOctets: 30 }); const smpp = await startServer(t, { maxOctets: 1330 });
const { session } = await connect(t, smpp, { bindType: 'transmitter' }); const { session } = await connect(t, smpp, { bindType: 'transmitter' });
assert.ok(session); assert.ok(session);
+5 -5
View File
@@ -8,11 +8,11 @@ govern it, and nothing here is a source anything else may cite.
## Security ## Security
- [ ] **Charge a held segment's TLVs for the objects they keep, not only their value octets.** A - [ ] **Bound the heap `HeldMessages` keeps, not only its count.** It holds up to 1000 messages the
peer sending segments that carry thousands of distinct unknown tags with empty values makes application has not answered for up to 300 s, each as the PDUs it arrived in, undetached and
this library hold megabytes of heap per segment that `maxOctets` counts as nothing, up to uncharged: one 200 KB PDU of 50,000 empty unknown TLVs is 15 MB of heap, and a completed
255 segments per group. Repeatable tags are already charged per occurrence. From the stability reassembly is up to `maxOctets`. A peer faster than an application answering asynchronously
review of #25. holds gigabytes per session. From the stability review of #27.
## Status ## Status