Address the CodeRabbit review: inbound SMS, binary payloads and untrusted input

This commit is contained in:
2026-08-27 11:52:43 +02:00
parent 884afdb87b
commit 8a182604b7
23 changed files with 603 additions and 152 deletions
+27 -11
View File
@@ -87,7 +87,10 @@ const latin1: Encoding = {
const ucs2: Encoding = {
decode(buffer) {
return Buffer.from(buffer).swap16().toString('utf16le');
// A peer-controlled sm_length can cut a character in half, and swap16() refuses odd lengths.
const whole = buffer.length - (buffer.length % 2);
return Buffer.from(buffer.subarray(0, whole)).swap16().toString('utf16le');
},
encode(value) {
@@ -113,22 +116,35 @@ export function detect(value: string): EncodingName {
return 'UCS2';
}
/**
* SMPP data_coding is a flat table for 0x00-0x0E, but the 0x1X and 0xFX ranges carry a GSM message
* class and encode the alphabet in bits 3-2 (or bit 2) instead — which is how a flash UCS2 message
* arrives as 0x18. Alphabets with no codec here fall back to ASCII.
*/
export function encodingByDataCoding(dataCoding: number): EncodingName {
/** The 0x1X and 0xFX ranges carry a GSM message class and put the alphabet in bits 3-2 or bit 2. */
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
if ((dataCoding & 0xF0) === 0x10) {
return ((dataCoding >> 2) & 0x03) === 0x02 ? 'UCS2' : 'ASCII';
const alphabet = (dataCoding >> 2) & 0x03;
if (alphabet === 0x01) return 'LATIN1';
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
}
if ((dataCoding & 0xF0) === 0xF0) {
return 'ASCII';
return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII';
}
if (dataCoding === 0x03) return 'LATIN1';
return undefined;
}
/**
* SMPP data_coding is a flat table for 0x00-0x0E, and the message class ranges are how a flash UCS2
* message arrives as 0x18. The 8-bit binary codings resolve to LATIN1, the one codec here that maps
* every octet to a code point and back unchanged, so a binary payload survives; alphabets with no
* codec fall back to ASCII.
*/
export function encodingByDataCoding(dataCoding: number): EncodingName {
const messageClass = messageClassEncoding(dataCoding);
if (messageClass) return messageClass;
if (dataCoding === 0x08) return 'UCS2';
return 'ASCII';
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
}
+1 -1
View File
@@ -22,7 +22,7 @@ const specs = tlvSpecs({
source_addr_subunit: { id: 0x000D, tag: 'source_addr_subunit', type: tlv.int8 },
source_network_type: { id: 0x000E, tag: 'source_network_type', type: tlv.int8 },
source_bearer_type: { id: 0x000F, tag: 'source_bearer_type', type: tlv.int8 },
source_telematics_id: { id: 0x0010, tag: 'source_telematics_id', type: tlv.int16 },
source_telematics_id: { id: 0x0010, tag: 'source_telematics_id', type: tlv.int8 },
qos_time_to_live: { id: 0x0017, tag: 'qos_time_to_live', type: tlv.int32 },
payload_type: { id: 0x0019, tag: 'payload_type', type: tlv.int8 },
additional_status_info_text: { id: 0x001D, tag: 'additional_status_info_text', type: tlv.cstring },
+35 -3
View File
@@ -142,6 +142,15 @@ 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(
`C-Octet String starts at offset ${String(offset)}, past a ${String(buffer.length)} octet buffer`,
),
};
}
let length = 0;
while (buffer[offset + length]) {
@@ -197,6 +206,29 @@ export const int8 = intType(1, 0xFF, (b, o) => b.readUInt8(o), (b, v, o) => b.wr
export const int16 = intType(2, 0xFFFF, (b, o) => b.readUInt16BE(o), (b, v, o) => b.writeUInt16BE(v, o));
export const int32 = intType(4, 0xFFFFFFFF, (b, o) => b.readUInt32BE(o), (b, v, o) => b.writeUInt32BE(v, o));
const intByOctets: Record<number, WireType<number>> = { 1: int8, 2: int16, 4: int32 };
/**
* The TLV header's length is what the parser skips past, so it is also the width the value is read
* at — a peer that types a tag one octet wider than the table says still gets the value it meant.
*/
function tlvInt(declared: WireType<number>): WireType<number> {
return {
...declared,
read(buffer, offset, length) {
if (length === undefined) return declared.read(buffer, offset);
const width = intByOctets[length];
if (!width) {
return { err: new Error(`Integer TLV declares ${String(length)} octets, expected 1, 2 or 4`) };
}
return width.read(buffer, offset);
},
};
}
/** Octet String: a length octet followed by that many octets. */
export const string: WireType<string> = {
default: '',
@@ -513,9 +545,9 @@ export const tlv = {
return err ? { err } : writeCstring(text, buf, offset);
},
} satisfies WireType<string>,
int8,
int16,
int32,
int8: tlvInt(int8),
int16: tlvInt(int16),
int32: tlvInt(int32),
string: {
default: '',
read(buf: Buffer, offset: number, length = 0) {