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
+10 -6
View File
@@ -3,7 +3,7 @@ import type { LogInt } from '@larvit/log';
import type { Result, VoidResult } from './result.ts';
import type { Socket } from 'node:net';
import { Session } from './session.ts';
import { checkSessionOptions } from './session-options.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { connect as netConnect } from 'node:net';
import { connect as tlsConnect } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -107,11 +107,10 @@ function bindParams(options: ClientOptions, systemId: string) {
async function bind(session: Session, options: ClientOptions): Promise<VoidResult> {
const bindType = options.bindType ?? defaults.bindType;
const systemId = options.username ?? defaults.username;
const sent = await session.send({
cmdName: `bind_${bindType}`,
params: bindParams(options, systemId),
...(options.signal ? { signal: options.signal } : {}),
});
const sent = await session.send(
{ cmdName: `bind_${bindType}`, params: bindParams(options, systemId) },
options.signal ? { signal: options.signal } : {},
);
if (sent.err) return { err: sent.err };
@@ -124,7 +123,12 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
}
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
: undeclaredInterfaceVersion;
session.log.info('client - bound', { bindType, systemId });
return {};
+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) {
+10 -2
View File
@@ -82,8 +82,7 @@ function receiptDate(value: string | undefined): Date | undefined {
const [, years, months, days, hours, minutes, seconds] = match;
const century = Math.floor(new Date().getUTCFullYear() / 100) * 100;
return new Date(Date.UTC(
const date = new Date(Date.UTC(
century + Number(years),
Number(months) - 1,
Number(days),
@@ -91,6 +90,15 @@ function receiptDate(value: string | undefined): Date | undefined {
Number(minutes),
Number(seconds ?? 0),
));
// Date.UTC rolls 31 February over into March rather than refusing it.
const rolled = date.getUTCMonth() !== Number(months) - 1
|| date.getUTCDate() !== Number(days)
|| date.getUTCHours() !== Number(hours)
|| date.getUTCMinutes() !== Number(minutes)
|| date.getUTCSeconds() !== Number(seconds ?? 0);
return rolled ? undefined : date;
}
/**
+137
View File
@@ -0,0 +1,137 @@
import type { DlrMerger } from './dlr-merger.ts';
import type { LogInt } from '@larvit/log';
import type { OnRequest } from './session-options.ts';
import type { PduObject } from './pdu.ts';
import type { Session } from './session.ts';
import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults } from './session-options.ts';
import { concatInfo } from './udh.ts';
import { consts } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { dlrFromPdu } from './dlr.ts';
import { paramText } from './defs/types.ts';
export type IncomingRequestsOptions = {
dlrMerger: DlrMerger;
log: LogInt;
maxOctets?: number | undefined;
maxReassembly?: number | undefined;
onRequest?: OnRequest | undefined;
reassemblyTimeout?: number | undefined;
session: Session;
systemId?: string | undefined;
};
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
export class IncomingRequests {
private readonly dlrMerger: DlrMerger;
private readonly log: LogInt;
private readonly onRequest: OnRequest | undefined;
private readonly reassembler: Reassembler;
private readonly session: Session;
private readonly systemId: string;
constructor(options: IncomingRequestsOptions) {
this.dlrMerger = options.dlrMerger;
this.log = options.log;
this.onRequest = options.onRequest;
this.reassembler = new Reassembler({
log: options.log,
max: options.maxReassembly ?? defaults.maxReassembly,
maxOctets: options.maxOctets,
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
});
this.session = options.session;
this.systemId = options.systemId ?? defaults.systemId;
}
async handle(pduObj: PduObject): Promise<void> {
if (this.onRequest && await this.onRequest(this.session, pduObj)) return;
switch (pduObj.cmdName) {
case 'deliver_sm':
await this.onDeliverSm(pduObj);
break;
case 'enquire_link':
await this.session.sendReturn(pduObj);
break;
case 'submit_sm':
this.onMessage(pduObj);
break;
case 'unbind':
await this.session.sendReturn(pduObj);
this.session.close();
break;
default:
await this.unhandled(pduObj);
}
}
/** Drops the segments of every message that never became whole. */
clear(): void {
this.reassembler.clear();
}
private async unhandled(pduObj: PduObject): Promise<void> {
if (bindCommands.includes(pduObj.cmdName)) {
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
await this.session.sendReturn(pduObj, 'ESME_RALYBND', { system_id: this.systemId });
return;
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
}
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
private async onDeliverSm(pduObj: PduObject): Promise<void> {
const dlr = dlrFromPdu(pduObj);
if (!dlr) {
this.onMessage(pduObj);
return;
}
this.session.emit('dlr', dlr, pduObj);
const merged = this.dlrMerger.collect(dlr);
if (merged) this.session.emit('messageDlr', merged);
await this.session.sendReturn(pduObj);
}
private onMessage(pduObj: PduObject): void {
const message = pduObj.params.short_message;
const esmClass = pduObj.params.esm_class;
const hasUdh = typeof esmClass === 'number'
&& (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR;
const concat = hasUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
if (!concat) {
this.emitSms([pduObj]);
return;
}
const whole = this.reassembler.collect(pduObj, concat);
if (whole) this.emitSms(whole);
}
private emitSms(pduObjs: PduObject[]): void {
const first = pduObjs[0];
if (!first) return;
this.session.emit('sms', createSms({
from: paramText(first.params.source_addr),
message: decodeSegments(pduObjs),
pduObjs,
session: this.session,
to: paramText(first.params.destination_addr),
}));
}
}
+9
View File
@@ -122,6 +122,15 @@ export class Reassembler {
collect(pduObj: PduObject, concat: ConcatInfo): PduObject[] | undefined {
this.sweep();
if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) {
this.log.warn('reassembler - dropping a segment the UDH numbers impossibly', {
part: concat.part,
total: concat.total,
});
return undefined;
}
const key = groupKey(pduObj, concat.reference);
const group = this.groups.get(key) ?? this.open(key, concat.total);
const replaced = group.parts.get(concat.part);
+16 -1
View File
@@ -90,7 +90,7 @@ export class ReconnectLoop {
return false;
}
const up = await this.options.onConnected(opened.sock);
const up = await this.bringUp(opened.sock);
if (up.err) {
this.options.log.warn('reconnect - could not come back up', { message: up.err.message });
@@ -102,4 +102,19 @@ export class ReconnectLoop {
return false;
}
/** The loop owns the socket until the owner is up on it, so a failed handover must not leak it. */
private async bringUp(sock: Socket): Promise<VoidResult> {
try {
const up = await this.options.onConnected(sock);
if (up.err) sock.destroy();
return up;
} catch (thrown: unknown) {
sock.destroy();
return { err: thrown instanceof Error ? thrown : new Error(String(thrown)) };
}
}
}
+4 -2
View File
@@ -5,7 +5,7 @@ import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls';
import { EventEmitter } from 'node:events';
import { Session, bindCommands, defaultSystemId } from './session.ts';
import { checkSessionOptions } from './session-options.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -148,7 +148,9 @@ async function acceptBind(
const declared = pduObj.params.interface_version;
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number' ? declared : undefined;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
: undeclaredInterfaceVersion;
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
}
+11 -6
View File
@@ -27,6 +27,13 @@ export const bindCommands: readonly string[] = [
export type SendOptions = { signal?: AbortSignal | undefined };
/**
* First refusal on every incoming request. Returning true means the hook answered it and the
* built-in handling is skipped — this is how the server owns bind without the session also
* replying "invalid command".
*/
export type OnRequest = (session: Session, pduObj: PduObject) => Promise<boolean>;
/**
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller
* supplies how to open a socket and what to do once it is open (bind, for a client).
@@ -45,12 +52,7 @@ export type SessionOptions = {
maxOctets?: number | undefined;
maxOutstanding?: number | undefined;
maxReassembly?: number | undefined;
/**
* First refusal on every incoming request. Returning true means the hook answered it and the
* built-in handling is skipped — this is how the server owns bind without the session also
* replying "invalid command".
*/
onRequest?: ((session: Session, pduObj: PduObject) => Promise<boolean>) | undefined;
onRequest?: OnRequest | undefined;
reassemblyTimeout?: number | undefined;
reconnect?: ReconnectOptions | undefined;
responseTimeout?: number | undefined;
@@ -61,6 +63,9 @@ export type SessionOptions = {
export const defaultSystemId = '';
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
export const undeclaredInterfaceVersion = 0x00;
export const defaults = {
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
dlrMergeTimeout: 86_400_000,
+19 -103
View File
@@ -9,19 +9,15 @@ import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { Socket } from 'node:net';
import { DlrMerger } from './dlr-merger.ts';
import { EventEmitter } from 'node:events';
import { IncomingRequests } from './incoming-requests.ts';
import { LinkTimers } from './link-timers.ts';
import { PduFramer } from './pdu-framer.ts';
import { PendingRequests } from './pending-requests.ts';
import { ReconnectLoop } from './reconnect-loop.ts';
import { Reassembler, decodeSegments } from './reassembly.ts';
import { SendWindow } from './send-window.ts';
import { concatInfo } from './udh.ts';
import { consts, optionalParamsMinVersion } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { optionalParamsMinVersion } from './defs/constants.ts';
import { bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { dlrFromPdu } from './dlr.ts';
import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
import { paramText } from './defs/types.ts';
import { silentLog } from './log.ts';
import { submitSms } from './send-sms.ts';
@@ -42,14 +38,14 @@ export class Session extends EventEmitter<SessionEvents> {
readonly log: LogInt;
loggedIn = false;
/** The interface_version the peer declared when binding; undefined until a bind is accepted. */
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
peerInterfaceVersion: number | undefined = undefined;
userData: unknown = undefined;
private readonly dlrMerger: DlrMerger;
private readonly incoming: IncomingRequests;
private readonly options: SessionOptions;
private readonly pending: PendingRequests;
private readonly reassembler: Reassembler;
private readonly reconnectLoop: ReconnectLoop | undefined;
private readonly timers: LinkTimers;
private readonly window: SendWindow;
@@ -87,13 +83,17 @@ export class Session extends EventEmitter<SessionEvents> {
max: defaults.maxDlrMerges,
timeout: defaults.dlrMergeTimeout,
});
this.pending = new PendingRequests(this.log);
this.reassembler = new Reassembler({
this.incoming = new IncomingRequests({
dlrMerger: this.dlrMerger,
log: this.log,
max: options.maxReassembly ?? defaults.maxReassembly,
maxOctets: options.maxOctets,
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
maxReassembly: options.maxReassembly,
onRequest: options.onRequest,
reassemblyTimeout: options.reassemblyTimeout,
session: this,
systemId: options.systemId,
});
this.pending = new PendingRequests(this.log);
this.reconnectLoop = this.loopFor(options.reconnect);
this.sock = options.sock;
this.timers = new LinkTimers({
@@ -243,6 +243,11 @@ export class Session extends EventEmitter<SessionEvents> {
input: PduObjectInput,
options: SendOptions,
): Promise<Result<{ pduObj: PduObject }>> {
// pending.wait() alone settles the caller while the request still goes out to the peer.
if (options.signal?.aborted === true) {
return { err: new Error('Aborted before the request was sent') };
}
const seqNr = this.pending.nextSeqNr();
const built = objToPdu({ ...input, seqNr });
@@ -270,7 +275,7 @@ export class Session extends EventEmitter<SessionEvents> {
this.timers.clear();
this.pending.settleAll(new Error('Session closed before a response arrived'));
this.dlrMerger.clear();
this.reassembler.clear();
this.incoming.clear();
this.sock.destroy();
this.emit('close');
}
@@ -343,7 +348,7 @@ export class Session extends EventEmitter<SessionEvents> {
this.emit('incomingPduObj', pduObj);
// Every application hook and listener reached from an incoming PDU funnels through here.
void this.handle(pduObj).catch((thrown: unknown) => {
void this.incoming.handle(pduObj).catch((thrown: unknown) => {
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
this.log.error('session - a handler threw', { message: err.message });
@@ -351,95 +356,6 @@ export class Session extends EventEmitter<SessionEvents> {
});
}
private async handle(pduObj: PduObject): Promise<void> {
const onRequest = this.options.onRequest;
if (onRequest && await onRequest(this, pduObj)) return;
switch (pduObj.cmdName) {
case 'deliver_sm':
await this.onDeliverSm(pduObj);
break;
case 'enquire_link':
await this.sendReturn(pduObj);
break;
case 'submit_sm':
this.onSubmitSm(pduObj);
break;
case 'unbind':
await this.sendReturn(pduObj);
this.close();
break;
default:
await this.unhandled(pduObj);
}
}
private async unhandled(pduObj: PduObject): Promise<void> {
if (bindCommands.includes(pduObj.cmdName)) {
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
await this.sendReturn(pduObj, 'ESME_RALYBND', {
system_id: this.options.systemId ?? defaults.systemId,
});
return;
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
await this.sendReturn(pduObj, 'ESME_RINVCMDID');
}
private onSubmitSm(pduObj: PduObject): void {
const message = pduObj.params.short_message;
const esmClass = pduObj.params.esm_class;
const hasUdh = typeof esmClass === 'number'
&& (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR;
const concat = hasUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
if (!concat) {
this.emitSms([pduObj]);
return;
}
const whole = this.reassembler.collect(pduObj, concat);
if (whole) this.emitSms(whole);
}
private emitSms(pduObjs: PduObject[]): void {
const first = pduObjs[0];
if (!first) return;
this.emit('sms', createSms({
from: paramText(first.params.source_addr),
message: decodeSegments(pduObjs),
pduObjs,
session: this,
to: paramText(first.params.destination_addr),
}));
}
private async onDeliverSm(pduObj: PduObject): Promise<void> {
const dlr = dlrFromPdu(pduObj);
if (!dlr) {
this.log.info('session - deliver_sm carries no delivery report', { seqNr: pduObj.seqNr });
await this.sendReturn(pduObj, 'ESME_RINVTLVSTREAM');
return;
}
this.emit('dlr', dlr, pduObj);
const merged = this.dlrMerger.collect(dlr);
if (merged) this.emit('messageDlr', merged);
await this.sendReturn(pduObj);
}
private resetTimers(): void {
if (this.closed) return;