Read and write every text field on the wire as latin1 #16
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user