Skip to content

Commit 7671c9d

Browse files
committed
fix: TXT decode preserves string-boundary info
1 parent cc8dd46 commit 7671c9d

6 files changed

Lines changed: 91 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
44

55
### Unreleased
66

7+
### [3.0.0] - 2026-05-26
8+
9+
- **BREAKING**, TXT `data` is now always an array of strings
10+
- fix(packet): TXT decode preserves character-string boundaries (RFC 1035 §3.3.14)
11+
712
### [2.4.0] - 2026-05-26
813

914
- feat(ESM): dual published with ESM support

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dns2",
3-
"version": "2.4.0",
3+
"version": "3.0.0",
44
"description": "A DNS Server and Client Implementation in Pure JavaScript with no dependencies.",
55
"main": "index.js",
66
"types": "ts/index.d.ts",

packet.js

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -768,22 +768,36 @@ Packet.Resource.PTR = Packet.Resource.CNAME = {
768768
* @docs https://tools.ietf.org/html/rfc1035#section-3.3.14
769769
*/
770770
Packet.Resource.SPF = Packet.Resource.TXT = {
771+
// RFC 1035 §3.3.14: TXT RDATA is one or more length-prefixed
772+
// <character-string> items. Preserve those boundaries by returning an
773+
// array — joining them silently corrupts SPF/DKIM and other multi-string
774+
// records whose semantics depend on segmentation.
771775
decode: function (reader, length) {
772-
const parts = [];
776+
const strings = [];
773777
let bytesRead = 0;
774-
let chunkLength;
775-
776778
while (bytesRead < length) {
777-
chunkLength = reader.read(8); // text length
779+
const chunkLength = reader.read(8);
778780
bytesRead++;
779-
780-
while (chunkLength--) {
781-
parts.push(reader.read(8));
782-
bytesRead++;
781+
// A character-string whose length runs past the end of RDATA would
782+
// make us read into the next record. Skip the remainder of the rdata
783+
// before throwing so the next record decodes from the correct offset
784+
// instead of cascading the error through every following RR.
785+
if (chunkLength > length - bytesRead) {
786+
const remaining = length - bytesRead;
787+
for (let i = 0; i < remaining; i++) reader.read(8);
788+
throw new Error(
789+
`TXT decode: character-string of ${chunkLength} octets overruns ` +
790+
`RDATA (${remaining} octets remaining)`,
791+
);
792+
}
793+
const bytes = Buffer.alloc(chunkLength);
794+
for (let i = 0; i < chunkLength; i++) {
795+
bytes[i] = reader.read(8);
783796
}
797+
bytesRead += chunkLength;
798+
strings.push(bytes.toString('utf8'));
784799
}
785-
786-
this.data = Buffer.from(parts).toString('utf8');
800+
this.data = strings;
787801
return this;
788802
},
789803
encode: function (record, writer) {

test/packet.js

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,8 @@ test('Packet#encode', function () {
317317
type: Packet.TYPE.TXT,
318318
class: Packet.CLASS.IN,
319319
ttl: 300,
320-
data: '#v=spf1 include:_spf.google.com ~all',
320+
// TXT data is an array of <character-string> items (RFC 1035 §3.3.14).
321+
data: ['#v=spf1 include:_spf.google.com ~all'],
321322
});
322323

323324
assert.deepEqual(Packet.parse(response.toBuffer()), response);
@@ -343,10 +344,7 @@ test('Packet#encode array of character strings', function () {
343344
data: dkim,
344345
});
345346

346-
assert.equal(
347-
Packet.parse(response.toBuffer()).answers[0].data,
348-
dkim.join(''),
349-
);
347+
assert.deepEqual(Packet.parse(response.toBuffer()).answers[0].data, dkim);
350348
});
351349

352350
test('EDNS.ECS#encode', function () {
@@ -511,9 +509,9 @@ test('Resource#TXT round-trip single string', function () {
511509
type: Packet.TYPE.TXT,
512510
class: Packet.CLASS.IN,
513511
ttl: 300,
514-
data: 'hello world',
512+
data: 'hello world', // encoder normalizes string → [string]
515513
});
516-
assert.equal(out.data, 'hello world');
514+
assert.deepEqual(out.data, ['hello world']);
517515
});
518516

519517
test('Resource#TXT round-trip with utf-8', function () {
@@ -524,7 +522,57 @@ test('Resource#TXT round-trip with utf-8', function () {
524522
ttl: 300,
525523
data: 'café résumé 日本',
526524
});
527-
assert.equal(out.data, 'café résumé 日本');
525+
assert.deepEqual(out.data, ['café résumé 日本']);
526+
});
527+
528+
test('Resource#TXT preserves character-string boundaries', function () {
529+
// SPF/DKIM-style multi-string TXT records must not be merged on decode.
530+
const chunks = ['part-one ', 'part-two ', 'part-three'];
531+
const out = roundTripAnswer({
532+
name: 'multi.example',
533+
type: Packet.TYPE.TXT,
534+
class: Packet.CLASS.IN,
535+
ttl: 60,
536+
data: chunks,
537+
});
538+
assert.deepEqual(out.data, chunks);
539+
});
540+
541+
test('Resource#TXT decode rejects character-string overruns RDATA', function () {
542+
// Hand-built TXT rdata: rdlength=5, but the first character-string claims
543+
// length 10. Direct caller should see a thrown error.
544+
const reader = new Packet.Reader(Buffer.from([0x0a, 0x61, 0x62, 0x63, 0x64]));
545+
assert.throws(
546+
() => Packet.Resource.TXT.decode.call({}, reader, 5),
547+
/overruns RDATA/,
548+
);
549+
});
550+
551+
test('Resource#TXT decode error does not cascade to following RRs', function () {
552+
// A malformed TXT record (rdlength=5, chunkLength=10) must consume its
553+
// declared rdlength before throwing, so the A record after it parses
554+
// cleanly. Without the bounds check the reader would be misaligned and
555+
// the second record would be lost.
556+
const pkt = Buffer.from([
557+
// header: id=1, ancount=2, others 0
558+
0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
559+
// answer 1: name "t", TYPE=TXT, CLASS=IN, TTL=60, RDLENGTH=5,
560+
// rdata = [chunkLen=10, 4 bogus bytes] ← chunkLen overruns
561+
0x01, 0x74, 0x00, 0x00, 0x10, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00,
562+
0x05, 0x0a, 0x61, 0x62, 0x63, 0x64,
563+
// answer 2: name "a", TYPE=A, CLASS=IN, TTL=60, RDLENGTH=4, 192.0.2.7
564+
0x01, 0x61, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00,
565+
0x04, 0xc0, 0x00, 0x02, 0x07,
566+
]);
567+
const parsed = Packet.parse(pkt);
568+
assert.equal(
569+
parsed.answers.length,
570+
1,
571+
'the malformed TXT should be dropped, leaving only the A record',
572+
);
573+
assert.equal(parsed.answers[0].type, Packet.TYPE.A);
574+
assert.equal(parsed.answers[0].name, 'a');
575+
assert.equal(parsed.answers[0].address, '192.0.2.7');
528576
});
529577

530578
test('Resource#SOA round-trip', function () {

test/server.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ test('server/udp-tcp#simple-request-async-response', async () => {
151151
const tcpClient = TCPClient({ dns: '127.0.0.1', port: servers.tcp.port });
152152
const udpClient = UDPClient({ dns: '127.0.0.1', port: servers.udp.port });
153153
const expected = [
154-
{ name: 'test.com', ttl: 300, type: 16, class: 1, data: 'Hello World' },
154+
{ name: 'test.com', ttl: 300, type: 16, class: 1, data: ['Hello World'] },
155155
];
156156
assert.deepEqual((await tcpClient('test.com')).answers, expected);
157157
assert.deepEqual((await udpClient('test.com')).answers, expected);
@@ -456,7 +456,7 @@ test('server/doh#POST end-to-end', async () => {
456456
type: Packet.TYPE.TXT,
457457
class: Packet.CLASS.IN,
458458
ttl: 60,
459-
data: 'post-ok',
459+
data: ['post-ok'],
460460
});
461461
send(response);
462462
});
@@ -499,7 +499,7 @@ test('server/doh#POST end-to-end', async () => {
499499
});
500500
const parsed = Packet.parse(body);
501501
assert.equal(parsed.answers.length, 1);
502-
assert.equal(parsed.answers[0].data, 'post-ok');
502+
assert.deepEqual(parsed.answers[0].data, ['post-ok']);
503503
server.close();
504504
});
505505

0 commit comments

Comments
 (0)