Refuse a PDU whose optional parameters do not end on command_length (#87)
* Regression tests for a truncated TLV tail refused rather than accepted * Refuse a PDU whose optional parameters do not end on command_length * Assert the bare TLV header refusal against jsmpp instead of recording it as a defect * Note the truncated TLV tail defect as fixed in the java-client findings * Derive the padding position, share the bare TLV fixture and trim the decision record * Regression tests for a PDU whose trailing C-Octet String a peer left out * An absent trailing C-Octet String consumes no octet, so a bodyless PDU still parses * Bound the TLV loop by the buffer it was given rather than a second spelling of its length * Answer the stability review's questions in the record and pin the array contract
This commit is contained in:
@@ -19,7 +19,8 @@ const bindParams = {
|
||||
|
||||
/**
|
||||
* Key order inside each `params` object is the order the fields appear on the wire. Reordering
|
||||
* them corrupts every PDU of that command.
|
||||
* them corrupts every PDU of that command, and moving a field after `short_message` also stops the
|
||||
* codec skipping the NULL octet some peers append to it.
|
||||
*/
|
||||
const specs = {
|
||||
alert_notification: {
|
||||
|
||||
+3
-1
@@ -146,7 +146,6 @@ function wantUnsuccessSmes(value: ParamValue): Result<{ smes: UnsuccessSme[] }>
|
||||
}
|
||||
|
||||
function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number; value: string }> {
|
||||
// An offset at the end exactly is an absent trailing field, which real peers do send.
|
||||
if (outOfRange(buffer, offset, 0)) {
|
||||
return {
|
||||
err: new Error(
|
||||
@@ -155,6 +154,9 @@ function readCstring(buffer: Buffer, offset: number): Result<{ bytesRead: number
|
||||
};
|
||||
}
|
||||
|
||||
// An offset at the end exactly is an absent trailing field, which real peers do send.
|
||||
if (offset === buffer.length) return { bytesRead: 0, value: '' };
|
||||
|
||||
let length = 0;
|
||||
|
||||
while (buffer[offset + length]) {
|
||||
|
||||
+42
-32
@@ -245,19 +245,15 @@ export function objToPdu<C extends CommandName>(obj: PduObjectInput<C>): Result<
|
||||
);
|
||||
}
|
||||
|
||||
function parseTlvs(
|
||||
pdu: Buffer,
|
||||
start: number,
|
||||
cmdLength: number,
|
||||
): Result<{ offset: number; tlvs: Record<string, Tlv> }> {
|
||||
function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Record<string, Tlv> }> {
|
||||
const tlvs: Record<string, Tlv> = {};
|
||||
let offset = start;
|
||||
|
||||
while (offset + 4 <= cmdLength) {
|
||||
while (offset + 4 <= pdu.length) {
|
||||
const tagId = pdu.readUInt16BE(offset);
|
||||
const tagLength = pdu.readUInt16BE(offset + 2);
|
||||
|
||||
if (offset + 4 + tagLength > cmdLength) {
|
||||
if (offset + 4 + tagLength > pdu.length) {
|
||||
return { err: new Error(`TLV ${String(tagId)} runs past the end of the PDU`) };
|
||||
}
|
||||
|
||||
@@ -281,9 +277,9 @@ function parseTlvs(
|
||||
function readParams(
|
||||
cmdName: CommandName,
|
||||
pdu: Buffer,
|
||||
trailingNull: boolean,
|
||||
): Result<{ offset: number; params: Record<string, ParamValue> }> {
|
||||
): Result<{ lastParam: string | undefined; offset: number; params: Record<string, ParamValue> }> {
|
||||
const params: Record<string, ParamValue> = {};
|
||||
let lastParam: string | undefined;
|
||||
let offset = 16;
|
||||
|
||||
for (const [name, type] of Object.entries(cmds[cmdName]?.params ?? {})) {
|
||||
@@ -293,13 +289,39 @@ function readParams(
|
||||
return { err: new Error(`Parameter "${name}" of "${cmdName}": ${read.err.message}`) };
|
||||
}
|
||||
|
||||
lastParam = name;
|
||||
params[name] = read.value;
|
||||
offset += read.bytesRead;
|
||||
|
||||
if (name === 'short_message' && trailingNull) offset++;
|
||||
}
|
||||
|
||||
return { offset, params };
|
||||
return { lastParam, offset, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* SMPP 3.4 4.3: the optional parameters run to command_length exactly, so an octet left over is a
|
||||
* TLV stream this codec could not read rather than slack to drop.
|
||||
*/
|
||||
function readOptionalParams(
|
||||
pdu: Buffer,
|
||||
start: number,
|
||||
afterShortMessage: boolean,
|
||||
): Result<{ tlvs: Record<string, Tlv> }> {
|
||||
const plain = parseTlvs(pdu, start);
|
||||
|
||||
if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs };
|
||||
|
||||
// Some peers append a NULL octet after short_message; that octet, and no other, is skipped.
|
||||
if (afterShortMessage && pdu[start] === 0) {
|
||||
const padded = parseTlvs(pdu, start + 1);
|
||||
|
||||
if (!padded.err && padded.offset === pdu.length) return { tlvs: padded.tlvs };
|
||||
}
|
||||
|
||||
return {
|
||||
err: plain.err ?? new Error(
|
||||
`${String(pdu.length - plain.offset)} octets are left over after the optional parameters`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function headerOf(pdu: Buffer): PduHeader {
|
||||
@@ -314,7 +336,7 @@ function headerOf(pdu: Buffer): PduHeader {
|
||||
};
|
||||
}
|
||||
|
||||
function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolean; pduObj: PduObject }> {
|
||||
function parsePdu(pdu: Buffer): Result<{ pduObj: PduObject }> {
|
||||
const header = headerOf(pdu);
|
||||
const { cmdId, cmdLength, cmdName, cmdStatusId, seqNr } = header;
|
||||
|
||||
@@ -322,20 +344,20 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
|
||||
return { err: new PduRefusedError(header, 'command', new Error('Unknown PDU command id')) };
|
||||
}
|
||||
|
||||
const declared = pdu.subarray(0, cmdLength);
|
||||
// SMPP 3.4 4.4.2 and friends: a response with a non-zero status carries no body at all.
|
||||
const read = cmdStatusId !== 0 && cmdLength === 16
|
||||
? { offset: 16, params: {} }
|
||||
: readParams(cmdName, pdu, trailingNull);
|
||||
? { lastParam: undefined, offset: 16, params: {} }
|
||||
: readParams(cmdName, declared);
|
||||
|
||||
if (read.err) return { err: new PduRefusedError(header, 'body', read.err) };
|
||||
|
||||
const parsed = parseTlvs(pdu, read.offset, cmdLength);
|
||||
|
||||
if (parsed.err) return { err: new PduRefusedError(header, 'tlvs', parsed.err) };
|
||||
|
||||
const params = read.params;
|
||||
const message = params.short_message;
|
||||
const octets = Buffer.isBuffer(message) ? message : undefined;
|
||||
const parsed = readOptionalParams(declared, read.offset, read.lastParam === 'short_message');
|
||||
|
||||
if (parsed.err) return { err: new PduRefusedError(header, 'tlvs', parsed.err) };
|
||||
|
||||
// A message carrying a UDH stays a buffer; the session needs the header intact to reassemble.
|
||||
if (octets && !hasUdh(paramNumber(params.esm_class, 0))) {
|
||||
@@ -343,7 +365,6 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
|
||||
}
|
||||
|
||||
return {
|
||||
aligned: parsed.offset === cmdLength,
|
||||
pduObj: {
|
||||
cmdId,
|
||||
cmdLength,
|
||||
@@ -380,18 +401,7 @@ export function pduToObj(pdu: Buffer): Result<{ pduObj: PduObject }> {
|
||||
|
||||
if (framing.err) return { err: framing.err };
|
||||
|
||||
const plain = parseOnce(pdu, false);
|
||||
|
||||
if (!plain.err && plain.aligned) return { pduObj: plain.pduObj };
|
||||
|
||||
// Some peers append a NULL octet after short_message; allow for it before giving up.
|
||||
const padded = parseOnce(pdu, true);
|
||||
|
||||
if (!padded.err && padded.aligned) return { pduObj: padded.pduObj };
|
||||
if (!plain.err) return { pduObj: plain.pduObj };
|
||||
if (!padded.err) return { pduObj: padded.pduObj };
|
||||
|
||||
return { err: plain.err };
|
||||
return parsePdu(pdu);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user