Read and write every text field on the wire as latin1 #16

Merged
lilleman merged 6 commits from cstring-latin1 into main 2026-09-21 08:28:44 +02:00
6 changed files with 56 additions and 20 deletions
Showing only changes of commit dc690b912f - Show all commits
+5
View File
@@ -12,6 +12,11 @@
`service_type` and the C-Octet String TLVs were affected the same way. A character past `U+00FF` `service_type` and the C-Octet String TLVs were affected the same way. A character past `U+00FF`
in one of those fields is now refused, where it used to go out as its low octet — which for `一`, in one of those fields is now refused, where it used to go out as its low octet — which for `一`,
` ` and most emoji is `0x00`, ending the field there. ` ` and most emoji is `0x00`, ending the field there.
**Check what you stored before you roll this out.** Values your application persisted under 0.5.0
were read with bit 7 masked, so an address or a `message_id` carrying an octet above `0x7F` is
spelled differently now: a stored id will not match the receipt it belongs to, and a stored address
will not match the sender it came from. Ids most SMSCs issue are digits or hex and are unaffected.
- A `U+0000` inside a C-Octet String — `source_addr`, `message_id`, `system_id` and the rest — is - A `U+0000` inside a C-Octet String — `source_addr`, `message_id`, `system_id` and the rest — is
refused. The peer reads such a field to its first NULL, so one sent inside the value shifted every refused. The peer reads such a field to its first NULL, so one sent inside the value shifted every
mandatory field behind it while `command_length` still counted the whole string. An Octet String mandatory field behind it while `command_length` still counted the whole string. An Octet String
+13 -6
View File
@@ -288,9 +288,11 @@ await session.sendSms({
``` ```
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address **Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
and 1 for a numeric one; the NPI fields default to 0. An address is latin1, so `Kaffeé` reaches the and 1 for a numeric one; the NPI fields default to 0. An address is latin1: `Kaffeé` goes out as the
peer as its own octets; a character past `U+00FF`, or a `U+0000`, is refused rather than sent six octets that spell it, `4B 61 66 66 65 E9`, and an address you received always sends back. One
truncated. outside `/^[\u0001-ÿ]*$/` is refused, naming the character and its index — strip or
transliterate it first. An SMSC may still refuse a non-ASCII sender of its own accord, which reaches
you as `ESME_RINVSRCADR`.
**Encoding.** **Encoding.**
@@ -344,8 +346,8 @@ you formatted. Refused before anything goes out: an invalid `Date`, `NaN`, `Infi
count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below. count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below.
Name a later instant as a `Date`, which goes out absolute. Name a later instant as a `Date`, which goes out absolute.
**What gets checked.** The library checks what it composes: an alphabet or a time you named, a string **What gets checked.** The library checks what it composes: an address you gave as `from` or `to`, an
body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you alphabet or a time you named, a string body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you
formatted, passes through as written. The same rule holds for `session.send()`. formatted, passes through as written. The same rule holds for `session.send()`.
## Session ## Session
@@ -610,6 +612,9 @@ if (isCommand(pduObj, 'submit_sm')) {
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding` - A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding`
names, detected from the text where you name none. One that alphabet cannot carry is refused, names, detected from the text where you name none. One that alphabet cannot carry is refused,
naming the character, its code point and where it is. naming the character, its code point and where it is.
- Every text field is latin1: addresses, `system_id`, `message_id`, `service_type` and the C-Octet
String TLVs. A character past `U+00FF` is refused, as is a `U+0000` in a C-Octet String, which the
peer reads as the end of the field.
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user - A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
data headers, deliberately malformed bodies. data headers, deliberately malformed bodies.
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies. - `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
@@ -707,7 +712,9 @@ Who depends on this library, and what they may rely on.
spooling, scheduling, retry policy and billing belong to whatever this is the edge of, and state spooling, scheduling, retry policy and billing belong to whatever this is the edge of, and state
shared between instances goes through the store in goal 9. shared between instances goes through the store in goal 9.
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has - **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has
to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md). to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md);
what each later minor changes is in
[CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
Personas this README serves, in order: Personas this README serves, in order:
+13 -7
View File
@@ -480,19 +480,25 @@ rule and an index of the titles below.
- **Every text field on the wire is latin1, and what the field cannot carry is refused rather than - **Every text field on the wire is latin1, and what the field cannot carry is refused rather than
truncated.** Maintainer's call, 2026-09-21, the refusals from the security and stability passes on truncated.** Maintainer's call, 2026-09-21, the refusals from the security and stability passes on
[#16](https://gitea.larvit.se/larvit/smpp-js/pulls/16). Goal 3 settles the alphabet: 3.4 calls [#16](https://gitea.larvit.se/larvit/smpp-js/pulls/16). 3.4 calls these fields ASCII, so goal 3
these fields ASCII, and an operator routing an alphanumeric sender through the upper half is settles the read alone — its generous clause is scoped to reading, and its sender clause is strict.
traffic to keep, so the read is latin1 and the write is named latin1 to match — which is what Goal 1 settles the write, being 3.4 as SMSCs actually run it: an operator routing an alphanumeric
makes the round trip idempotent, and is already the octets Node put on the wire, so no peer sees a sender through the upper half is traffic to keep, and Node's `ascii` write already emitted the low
change. Goal 2 settles the refusals, both of them a `size()` that would have agreed with a octet, so `Kaffeé` went out as `4B 61 66 66 65 E9` before this change and goes out as the same
octets after it. Naming the write latin1 is what makes the round trip idempotent, and no peer sees
a change. Goal 2 settles the refusals, both of them a `size()` that would have agreed with a
`write()` that put something else on the wire: a character past `U+00FF` written as its low octet, `write()` that put something else on the wire: a character past `U+00FF` written as its low octet,
and a caller's own `U+0000`, which the peer reads as the end of the field, shifting every mandatory and a caller's own `U+0000`, which the peer reads as the end of the field, shifting every mandatory
field behind it under a `command_length` that counted the whole string. `wantText()` and field behind it under a `command_length` that counted the whole string. Goal 4 settles them twice
over, since for `一`, ` ` and most emoji that low octet is `0x00` and the field went out malformed
on the operator's parser. `wantText()` and
`wantCstringText()` are the only two places that decide it, which is why the `dest_address` and `wantCstringText()` are the only two places that decide it, which is why the `dest_address` and
`unsuccess_sme` structures write their embedded addresses through `cstring.write()` rather than `unsuccess_sme` structures write their embedded addresses through `cstring.write()` rather than
reaching past it into `writeCstring()`. Rejected: reading latin1 and leaving the write spelled reaching past it into `writeCstring()`. Rejected: reading latin1 and leaving the write spelled
ASCII, which leaves two halves agreeing only by accident. Rejected: refusing the upper half on send ASCII, which leaves two halves agreeing only by accident. Rejected: refusing the upper half on send
to stay strict to 3.4's ASCII, which would drop exactly the traffic the read keeps. Rejected: to stay strict to 3.4's ASCII, which would be a new restriction taking away traffic this library
already sends and operators already accept, on no defect; whether an SMSC wants a non-ASCII sender
stays its own call, answered as `ESME_RINVSRCADR`. Rejected:
refusing `U+0000` in every text field, which would buy one spelling by taking a legitimate octet refusing `U+0000` in every text field, which would buy one spelling by taking a legitimate octet
away from the length-prefixed Octet String, whose length octet is what ends it. away from the length-prefixed Octet String, whose length octet is what ends it.
+6 -3
View File
@@ -1,4 +1,5 @@
import type { Result, VoidResult } from '../result.ts'; import type { Result, VoidResult } from '../result.ts';
import { unencodableText } from './encodings.ts';
export type DestAddress = export type DestAddress =
| { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string } | { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string }
@@ -84,11 +85,11 @@ function pastLatin1(text: string): { err: Error } | undefined {
if (index === -1) return undefined; if (index === -1) return undefined;
const code = (text.codePointAt(index) ?? 0).toString(16).toUpperCase().padStart(4, '0'); const char = String.fromCodePoint(text.codePointAt(index) ?? 0);
return { return {
err: new Error( err: new Error(
`Character U+${code} at index ${String(index)} is past latin1, which every text field on the wire is written in`, `latin1 cannot carry ${unencodableText({ char, index })}, and every text field on the wire is written in it; strip or transliterate it`,
), ),
}; };
} }
@@ -113,7 +114,9 @@ function wantCstringText(value: ParamValue): Result<{ text: string }> {
if (index === -1) return { text }; if (index === -1) return { text };
return { return {
err: new Error(`U+0000 at index ${String(index)} would end the C-Octet String there`), err: new Error(
`U+0000 at index ${String(index)} would end the C-Octet String there, so the peer would read every field behind it shifted`,
),
}; };
} }
+16 -1
View File
@@ -7,10 +7,10 @@ import type { SmppLog } from './log.ts';
import type { SmsIdNotation } from './sms-id.ts'; import type { SmsIdNotation } from './sms-id.ts';
import { UnansweredError } from './unanswered-error.ts'; import { UnansweredError } from './unanswered-error.ts';
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts'; import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
import { cstring, paramText } from './defs/types.ts';
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts'; import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
import { namedValue } from './error-from.ts'; import { namedValue } from './error-from.ts';
import { normaliseSmsId } from './sms-id.ts'; import { normaliseSmsId } from './sms-id.ts';
import { paramText } from './defs/types.ts';
import { maxSegments, smppTime, splitMessage } from './message.ts'; import { maxSegments, smppTime, splitMessage } from './message.ts';
export type SendSmsOptions = { export type SendSmsOptions = {
@@ -211,8 +211,23 @@ function checkFlash(encoding: EncodingName, flash: boolean): Error | undefined {
return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash'); return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash');
} }
/** Asked of the wire type itself, so the codec cannot refuse an address this let through. */
function checkAddresses(sms: SendSmsInput): Error | undefined {
for (const option of ['from', 'to'] as const) {
const { err } = cstring.size(sms[option]);
if (err) return new Error(`${option}: ${err.message}`);
}
return undefined;
}
/** Every option a send can be refused for, so nothing is built for a message that will not go. */ /** Every option a send can be refused for, so nothing is built for a message that will not go. */
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> { function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
const unwritable = checkAddresses(sms);
if (unwritable) return { err: unwritable };
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true); const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
if (mode.err) return { err: mode.err }; if (mode.err) return { err: mode.err };
+3 -3
View File
@@ -352,15 +352,15 @@ describe('an address the field cannot carry', () => {
const refusals: [string, RegExp][] = [ const refusals: [string, RegExp][] = [
['46701113311\u0000EVIL', /U\+0000 at index 11/], ['46701113311\u0000EVIL', /U\+0000 at index 11/],
['Kaffe一', /U\+4E00 at index 5/], ['Kaffe一', /"一" \(U\+4E00\) at index 5/],
['😀', /U\+1F600 at index 0/], ['😀', /"😀" \(U\+1F600\) at index 0/],
]; ];
for (const [sender, names] of refusals) { for (const [sender, names] of refusals) {
const sent = await session.sendSms({ from: sender, message: 'Hello world', to }); const sent = await session.sendSms({ from: sender, message: 'Hello world', to });
assert.ok(sent.err instanceof Error, sender); assert.ok(sent.err instanceof Error, sender);
assert.match(sent.err.message, /source_addr/); assert.match(sent.err.message, /^from: /, 'names the option the caller wrote, not the wire field');
assert.match(sent.err.message, names); assert.match(sent.err.message, names);
assert.deepEqual(sent.smsIds, [], sender); assert.deepEqual(sent.smsIds, [], sender);
} }