Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Unreleased

### [3.0.0] - 2026-05-26

- **BREAKING**, TXT `data` is now always an array of strings
- fix(packet): TXT decode preserves character-string boundaries (RFC 1035 §3.3.14)

### [2.4.0] - 2026-05-26

- feat(ESM): dual published with ESM support
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dns2",
"version": "2.4.0",
"version": "3.0.0",
"description": "A DNS Server and Client Implementation in Pure JavaScript with no dependencies.",
Comment thread
msimerson marked this conversation as resolved.
"main": "index.js",
"types": "ts/index.d.ts",
Expand Down
34 changes: 24 additions & 10 deletions packet.js
Original file line number Diff line number Diff line change
Expand Up @@ -768,22 +768,36 @@ Packet.Resource.PTR = Packet.Resource.CNAME = {
* @docs https://tools.ietf.org/html/rfc1035#section-3.3.14
*/
Packet.Resource.SPF = Packet.Resource.TXT = {
// RFC 1035 §3.3.14: TXT RDATA is one or more length-prefixed
// <character-string> items. Preserve those boundaries by returning an
// array — joining them silently corrupts SPF/DKIM and other multi-string
// records whose semantics depend on segmentation.
decode: function (reader, length) {
const parts = [];
const strings = [];
let bytesRead = 0;
let chunkLength;

while (bytesRead < length) {
chunkLength = reader.read(8); // text length
const chunkLength = reader.read(8);
bytesRead++;

while (chunkLength--) {
parts.push(reader.read(8));
bytesRead++;
// A character-string whose length runs past the end of RDATA would
// make us read into the next record. Skip the remainder of the rdata
// before throwing so the next record decodes from the correct offset
// instead of cascading the error through every following RR.
if (chunkLength > length - bytesRead) {
const remaining = length - bytesRead;
for (let i = 0; i < remaining; i++) reader.read(8);
throw new Error(
`TXT decode: character-string of ${chunkLength} octets overruns ` +
`RDATA (${remaining} octets remaining)`,
);
}
const bytes = Buffer.alloc(chunkLength);
for (let i = 0; i < chunkLength; i++) {
bytes[i] = reader.read(8);
}
bytesRead += chunkLength;
strings.push(bytes.toString('utf8'));
}

this.data = Buffer.from(parts).toString('utf8');
this.data = strings;
return this;
},
encode: function (record, writer) {
Expand Down
64 changes: 56 additions & 8 deletions test/packet.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,8 @@ test('Packet#encode', function () {
type: Packet.TYPE.TXT,
class: Packet.CLASS.IN,
ttl: 300,
data: '#v=spf1 include:_spf.google.com ~all',
// TXT data is an array of <character-string> items (RFC 1035 §3.3.14).
data: ['#v=spf1 include:_spf.google.com ~all'],
});

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

assert.equal(
Packet.parse(response.toBuffer()).answers[0].data,
dkim.join(''),
);
assert.deepEqual(Packet.parse(response.toBuffer()).answers[0].data, dkim);
});

test('EDNS.ECS#encode', function () {
Expand Down Expand Up @@ -511,9 +509,9 @@ test('Resource#TXT round-trip single string', function () {
type: Packet.TYPE.TXT,
class: Packet.CLASS.IN,
ttl: 300,
data: 'hello world',
data: 'hello world', // encoder normalizes string → [string]
});
assert.equal(out.data, 'hello world');
assert.deepEqual(out.data, ['hello world']);
});

test('Resource#TXT round-trip with utf-8', function () {
Expand All @@ -524,7 +522,57 @@ test('Resource#TXT round-trip with utf-8', function () {
ttl: 300,
data: 'café résumé 日本',
});
assert.equal(out.data, 'café résumé 日本');
assert.deepEqual(out.data, ['café résumé 日本']);
});

test('Resource#TXT preserves character-string boundaries', function () {
// SPF/DKIM-style multi-string TXT records must not be merged on decode.
const chunks = ['part-one ', 'part-two ', 'part-three'];
const out = roundTripAnswer({
name: 'multi.example',
type: Packet.TYPE.TXT,
class: Packet.CLASS.IN,
ttl: 60,
data: chunks,
});
assert.deepEqual(out.data, chunks);
});

test('Resource#TXT decode rejects character-string overruns RDATA', function () {
// Hand-built TXT rdata: rdlength=5, but the first character-string claims
// length 10. Direct caller should see a thrown error.
const reader = new Packet.Reader(Buffer.from([0x0a, 0x61, 0x62, 0x63, 0x64]));
assert.throws(
() => Packet.Resource.TXT.decode.call({}, reader, 5),
/overruns RDATA/,
);
});

test('Resource#TXT decode error does not cascade to following RRs', function () {
// A malformed TXT record (rdlength=5, chunkLength=10) must consume its
// declared rdlength before throwing, so the A record after it parses
// cleanly. Without the bounds check the reader would be misaligned and
// the second record would be lost.
const pkt = Buffer.from([
// header: id=1, ancount=2, others 0
0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
// answer 1: name "t", TYPE=TXT, CLASS=IN, TTL=60, RDLENGTH=5,
// rdata = [chunkLen=10, 4 bogus bytes] ← chunkLen overruns
0x01, 0x74, 0x00, 0x00, 0x10, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00,
0x05, 0x0a, 0x61, 0x62, 0x63, 0x64,
// answer 2: name "a", TYPE=A, CLASS=IN, TTL=60, RDLENGTH=4, 192.0.2.7
0x01, 0x61, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00,
0x04, 0xc0, 0x00, 0x02, 0x07,
]);
const parsed = Packet.parse(pkt);
assert.equal(
parsed.answers.length,
1,
'the malformed TXT should be dropped, leaving only the A record',
);
assert.equal(parsed.answers[0].type, Packet.TYPE.A);
assert.equal(parsed.answers[0].name, 'a');
assert.equal(parsed.answers[0].address, '192.0.2.7');
});

test('Resource#SOA round-trip', function () {
Expand Down
6 changes: 3 additions & 3 deletions test/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ test('server/udp-tcp#simple-request-async-response', async () => {
const tcpClient = TCPClient({ dns: '127.0.0.1', port: servers.tcp.port });
const udpClient = UDPClient({ dns: '127.0.0.1', port: servers.udp.port });
const expected = [
{ name: 'test.com', ttl: 300, type: 16, class: 1, data: 'Hello World' },
{ name: 'test.com', ttl: 300, type: 16, class: 1, data: ['Hello World'] },
];
assert.deepEqual((await tcpClient('test.com')).answers, expected);
assert.deepEqual((await udpClient('test.com')).answers, expected);
Expand Down Expand Up @@ -456,7 +456,7 @@ test('server/doh#POST end-to-end', async () => {
type: Packet.TYPE.TXT,
class: Packet.CLASS.IN,
ttl: 60,
data: 'post-ok',
data: ['post-ok'],
});
send(response);
});
Expand Down Expand Up @@ -499,7 +499,7 @@ test('server/doh#POST end-to-end', async () => {
});
const parsed = Packet.parse(body);
assert.equal(parsed.answers.length, 1);
assert.equal(parsed.answers[0].data, 'post-ok');
assert.deepEqual(parsed.answers[0].data, ['post-ok']);
server.close();
});

Expand Down