diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df9c06..69d228c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +### [3.1.0] - 2026-07-26 + +- chore: declare Node.js 22 as the floor +- feat(packet): Extended DNS Errors (RFC 8914) — `Packet.EDE` INFO-CODEs, an EDE + EDNS option codec, and `Packet.createErrorResponseFromRequest` +- feat(packet): `Packet.RCODE` completed from the IANA registry — 6-11 and 16-23 +- feat(ts): `Packet.RCODE` was missing from the type declarations entirely +- feat(packet): Packet.parse stops at a failure that leaves the reader misaligned +- feat(packet): RDLENGTH bounds every rdata decoder, so a malformed record no longer cascades (RFC 1035 §4.1.3) +- feat(packet): `Packet.typeName`, `Packet.TYPE_NAME`, `Packet.EDNS_OPTION_NAME` +- feat(client/udp): a query timeout names the last dropped response and why +- feat(index): `resolve()` uses `Promise.any`, so one dead NS no longer fails all +- fix(packet): parse throws `Packet.DecodeError` for a message with no usable header +- fix(packet): parse no longer swallows per-record decode failures, reports on `packet.errors` +- fix(packet): encoding a record or question whose TYPE/CLASS is not a 16-bit int throws +- fix(packet): encoding an A/AAAA record with an invalid address throws, was `0.0.0.0` +- fix(packet): RRSIG was unreachable — `Packet.TYPE.RRSIG` (46) was missing +- fix(packet): RRSIG timestamps mixed the local-time year with UTC fields +- fix(packet): RRSIG retains raw rdata so re-serializing keeps the signature +- fix(packet): EDNS skipped unknown options by bits instead of octets +- fix(packet): length validation for A, AAAA, CAA, DNSKEY, RRSIG and ECS rdata +- fix(packet): `Packet.readStream` rejects a truncated message +- fix: DNS option timeout is ms and now reaches the client (default `3000`) +- fix(index): `resolveA(domain, clientIp)` never sent the ECS option +- fix(client/doh): a non-200 response rejected and then resolved +- fix(packet): drop the `Array.prototype.flatMap` polyfill for Node 10 + ### [3.0.0] - 2026-05-26 -- **BREAKING**, TXT `data` is now always an array of strings +- 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 @@ -82,3 +109,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). [2.3.0]: https://github.com/lsongdev/node-dns/releases/tag/v2.3.0 [2.2.0]: https://github.com/lsongdev/node-dns/releases/tag/v2.2.0 [2.2.1]: https://github.com/lsongdev/node-dns/releases/tag/v2.2.1 +[3.1.0]: https://github.com/lsongdev/node-dns/releases/tag/v3.1.0 diff --git a/README.md b/README.md index 3e3166b..b2a58f8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ const options = { // nameServers: ['8.8.8.8'] — array of DNS server IPs (default: Google + 114dns) // port: 53 — DNS server port (number) // recursive: true — Recursion Desired flag (boolean, default true) + // timeout: 3000 — per-name-server timeout in milliseconds }; const dns = new dns2(options); @@ -53,6 +54,128 @@ The high-level `DNS` class exposes convenience methods for common record types: For any record type not listed above, use `dns.resolve(domain, 'TYPE')` directly. +Every name server is queried in parallel and the first successful reply wins. If +all of them fail, the rejection names each server and its reason. + +### When a packet fails to decode + +`Packet.parse` throws a `Packet.DecodeError` when a message cannot be decoded at +all — it is shorter than a 12-octet header, or not a Buffer: + +```js +try { + Packet.parse(buffer); +} catch (err) { + // "message is 7 octets, too short for the 12-octet header (RFC 1035 §4.1.1)" + console.error(err.message); +} +``` + +Once the header decodes, a malformed _record_ no longer disappears silently. +Records that cannot be decoded are dropped — a half-populated record would be +worse than none — and the reason is reported on `packet.errors`: + +```js +const packet = Packet.parse(buffer); + +for (const err of packet.errors) { + console.error(err.message); + // "answers[0] at offset 12: TXT decode: character-string of 10 octets + // overruns RDATA (4 octets remaining)" + err.section; // 'questions' | 'answers' | 'authorities' | 'additionals' + err.index; // position within that section + err.offset; // octet offset in the message where the record started + err.recovered; // see below +} +``` + +`packet.errors` is empty for a clean parse, so `packet.errors.length` is the test +for "did anything go wrong". Comparing a section's length against its header +count (`packet.answers.length` vs `packet.header.ancount`) shows how much of the +message survived. + +`err.recovered` distinguishes the two kinds of failure: + +- `true` — the damage was confined to one record's RDATA. RDLENGTH says where + the next record begins, so decoding continued and later records are intact. +- `false` — the failure left the reader misaligned (a truncated message, or a + name that ran off the end). Nothing after that point can be located, so + decoding stopped there instead of emitting junk records. + +Servers surface the same information. A query that cannot be decoded at all +raises `requestError`; one that partially decodes reaches the handler with +`request.errors` populated, which is enough to answer `FORMERR` if you prefer: + +```js +dns2 + .createServer({ + udp: true, + handle: (request, send) => { + if (request.errors.length) { + const response = Packet.createResponseFromRequest(request); + response.header.rcode = Packet.RCODE.FORMERR; + return send(response); + } + // ... + }, + }) + .on('requestError', err => console.error('undecodable query:', err.message)); +``` + +#### Telling the client why: Extended DNS Errors + +`FORMERR` says "malformed" in four bits and nothing more. RFC 8914 Extended DNS +Errors add an EDNS option carrying an INFO-CODE plus free-form text, which is +where a decode reason belongs. `Packet.EDE.INVALID_DATA` (24) is the code for +data that could not be interpreted. + +`Packet.createErrorResponseFromRequest` composes the whole reply: + +```js +const server = dns2.createServer({ + udp: true, + handle: (request, send) => { + if (request.errors.length) { + return send( + Packet.createErrorResponseFromRequest(request, Packet.RCODE.FORMERR, { + infoCode: Packet.EDE.INVALID_DATA, + extraText: request.errors.map(e => e.message).join('; '), + }), + ); + } + // ... normal handling + }, +}); +``` + +A client reads it back off any response: + +```js +const response = await resolve('example.com'); +const opt = response.additionals.find(r => r.type === Packet.TYPE.EDNS); +for (const option of opt?.rdata ?? []) { + if (option.ednsCode !== Packet.EDNS_OPTION_CODE.EDE) continue; + console.error( + `${Packet.EDE_NAME[option.infoCode] ?? option.infoCode}: ${option.extraText}`, + ); + // "INVALID_DATA: answers[0] at offset 12: TXT decode: character-string ..." +} +``` + +Extended errors are additive — they annotate a response without changing its +RCODE, and appear on `NOERROR` responses too. Three details the builder handles: + +- The option is attached **only when the request carried an OPT record** + (RFC 8914 §3), since a client that didn't signal EDNS cannot be sent EDNS + options. A request too malformed to have a readable OPT gets a bare RCODE. +- An OPT is added regardless when the RCODE exceeds 15, so its high byte + survives serialization — otherwise `BADVERS` would go out as `NOERROR`. +- `extraText` is truncated to `Packet.EDE_MAX_TEXT` (256), because the reply + still has to fit the negotiated UDP payload size. + +`Packet.EDE` holds the full registry of INFO-CODEs and `Packet.EDE_NAME` maps a +received code back to its name. + #### Example: SOA record lookup SOA (Start of Authority) records contain the authoritative zone information for a domain. @@ -227,14 +350,35 @@ will be found in `request.questions[0].name`. Use `Packet.RCODE` to send standard DNS error responses from your handler: -| Constant | Value | Meaning | -| ----------------------- | ----- | ------------------- | -| `Packet.RCODE.NOERROR` | 0 | No error | -| `Packet.RCODE.FORMERR` | 1 | Format error | -| `Packet.RCODE.SERVFAIL` | 2 | Server failure | -| `Packet.RCODE.NXDOMAIN` | 3 | Non-existent domain | -| `Packet.RCODE.NOTIMP` | 4 | Not implemented | -| `Packet.RCODE.REFUSED` | 5 | Query refused | +| Constant | Value | Meaning | +| ------------------------ | ----- | ---------------------------------- | +| `Packet.RCODE.NOERROR` | 0 | No error | +| `Packet.RCODE.FORMERR` | 1 | Format error | +| `Packet.RCODE.SERVFAIL` | 2 | Server failure | +| `Packet.RCODE.NXDOMAIN` | 3 | Non-existent domain | +| `Packet.RCODE.NOTIMP` | 4 | Not implemented | +| `Packet.RCODE.REFUSED` | 5 | Query refused | +| `Packet.RCODE.YXDOMAIN` | 6 | Name exists when it should not | +| `Packet.RCODE.YXRRSET` | 7 | RRset exists when it should not | +| `Packet.RCODE.NXRRSET` | 8 | RRset that should exist does not | +| `Packet.RCODE.NOTAUTH` | 9 | Not authoritative / not authorized | +| `Packet.RCODE.NOTZONE` | 10 | Name not contained in zone | +| `Packet.RCODE.DSOTYPENI` | 11 | DSO-TYPE not implemented | +| `Packet.RCODE.BADVERS` | 16 | Bad OPT version | +| `Packet.RCODE.BADSIG` | 16 | TSIG signature failure | +| `Packet.RCODE.BADKEY` | 17 | Key not recognized | +| `Packet.RCODE.BADTIME` | 18 | Signature out of time window | +| `Packet.RCODE.BADMODE` | 19 | Bad TKEY mode | +| `Packet.RCODE.BADNAME` | 20 | Duplicate key name | +| `Packet.RCODE.BADALG` | 21 | Algorithm not supported | +| `Packet.RCODE.BADTRUNC` | 22 | Bad truncation | +| `Packet.RCODE.BADCOOKIE` | 23 | Bad or missing server cookie | + +Codes above 15 do not fit the header's 4-bit RCODE field — their high byte +travels in an OPT record's TTL (RFC 6891 §6.1.3), so a response using one must +carry an OPT. `Packet.createErrorResponseFromRequest` attaches one for you. +Note that 16 has two names in the IANA registry: they share the code point on +the wire, and only context distinguishes them. ```js const dns2 = require('dns2'); diff --git a/client/doh.js b/client/doh.js index d338876..5480b34 100644 --- a/client/doh.js +++ b/client/doh.js @@ -67,7 +67,9 @@ const readStream = res => .on('end', () => { const data = Buffer.concat(chunks); if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode}: ${data.toString()}`)); + return reject( + new Error(`HTTP ${res.statusCode}: ${data.toString()}`), + ); } resolve(data); }); diff --git a/client/tcp.js b/client/tcp.js index 132d57f..5b73c1c 100644 --- a/client/tcp.js +++ b/client/tcp.js @@ -1,6 +1,9 @@ const tls = require('node:tls'); const tcp = require('node:net'); const Packet = require('../packet'); +const { debuglog } = require('node:util'); + +const debug = debuglog('dns2'); const makeQuery = ({ name, @@ -46,6 +49,11 @@ const TCPClient = ({ const message = makeQuery({ name, type, cls, ...options }); const [host] = dns.split(':'); const client = protocols[protocol](host, port); + // The socket outlives the single-message read below — we still end() it — + // and Packet.readStream releases its own listeners once it has the message. + // An 'error' with no listener is fatal to the process, so hold one here for + // the socket's whole life; readStream's rejection reports the failure. + client.on('error', err => debug('tcp: socket error: %s', err.message)); sendQuery(client, message); const data = await Packet.readStream(client); diff --git a/client/udp.js b/client/udp.js index 323f4d7..d818763 100644 --- a/client/udp.js +++ b/client/udp.js @@ -1,6 +1,5 @@ const udp = require('node:dgram'); const net = require('node:net'); -const crypto = require('node:crypto'); const Packet = require('../packet'); const { debuglog } = require('node:util'); @@ -16,7 +15,7 @@ module.exports = ({ return (name, type = 'A', cls = Packet.CLASS.IN, options = {}) => { const { clientIp, recursive = true } = options; const query = new Packet(); - query.header.id = crypto.randomInt(0x10000); + query.header.id = Packet.uuid(); // see https://github.com/song940/node-dns/issues/29 if (recursive) { query.header.rd = 1; @@ -38,6 +37,10 @@ module.exports = ({ return new Promise((resolve, reject) => { let settled = false; let timer; + // Packets that cannot be decoded are dropped, a forged or corrupt datagram + // must not pre-empt the real reply. Keep the reason so a subsequent timeout + // can explain itself + let lastDropped; const cleanup = () => { if (settled) return; settled = true; @@ -56,6 +59,9 @@ module.exports = ({ rinfo.port !== port || (expectedAddress && rinfo.address !== expectedAddress) ) { + lastDropped = + `packet came from ${rinfo.address}:${rinfo.port}, not the ` + + `configured resolver ${dns}:${port}`; debug( 'udp: dropping packet from unexpected sender %s:%d', rinfo.address, @@ -67,11 +73,23 @@ module.exports = ({ try { response = Packet.parse(message); } catch (e) { + lastDropped = `response could not be decoded: ${e.message}`; debug('udp: dropping unparseable packet: %s', e.message); return; } + if (response.errors.length) { + debug( + 'udp: response %d decoded with %d error(s): %s', + response.header.id, + response.errors.length, + response.errors.map(e => e.message).join('; '), + ); + } // Stray / late reply from a reused ephemeral port — keep listening. if (response.header.id !== query.header.id) { + lastDropped = + `response id ${response.header.id} did not match the query ` + + `id ${query.header.id}`; debug( 'udp: dropping response with mismatched id %d (expected %d)', response.header.id, @@ -102,7 +120,10 @@ module.exports = ({ if (timeout > 0) { timer = setTimeout(() => { cleanup(); - const err = new Error(`DNS query timed out after ${timeout}ms`); + const err = new Error( + `DNS query timed out after ${timeout}ms` + + (lastDropped ? ` (last ${lastDropped})` : ''), + ); err.code = 'ETIMEDOUT'; reject(err); }, timeout); diff --git a/index.js b/index.js index 5d0dc80..8f02b78 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ class DNS extends EventEmitter { { port: 53, retries: 3, - timeout: 3, + timeout: 3000, // milliseconds recursive: true, retryOverTCP: true, resolverProtocol: 'UDP', @@ -60,19 +60,46 @@ class DNS extends EventEmitter { * @param {*} type * @param {*} cls */ - resolve(domain, type = 'ANY', cls = DNS.Packet.CLASS.IN, options = {}) { - const { port, nameServers, resolverProtocol = 'UDP', retryOverTCP } = this; + async resolve(domain, type = 'ANY', cls = DNS.Packet.CLASS.IN, options = {}) { + const { + port, + nameServers, + resolverProtocol = 'UDP', + retryOverTCP, + timeout, + } = this; const createResolver = DNS[resolverProtocol + 'Client']; - return Promise.race( - nameServers.map(address => { - const resolve = createResolver({ dns: address, port, retryOverTCP }); - return resolve(domain, type, cls, options); - }), - ); + try { + // Promise.any, so one unreachable or misbehaving name server doesn't + // fail the lookup while another is able to answer. + return await Promise.any( + nameServers.map(address => { + const resolve = createResolver({ + dns: address, + port, + retryOverTCP, + timeout, + }); + return resolve(domain, type, cls, options); + }), + ); + } catch (e) { + // AggregateError's own message ("All promises were rejected") hides which + // server failed and why; name each one. + if (!(e instanceof AggregateError)) throw e; + const reasons = nameServers + .map((address, i) => `${address}: ${e.errors[i]?.message}`) + .join('; '); + throw new Error( + `${type} lookup of ${domain} failed on all ${nameServers.length} ` + + `name server(s) — ${reasons}`, + { cause: e }, + ); + } } resolveA(domain, clientIp) { - return this.resolve(domain, 'A', undefined, clientIp); + return this.resolve(domain, 'A', undefined, clientIp ? { clientIp } : {}); } resolveAAAA(domain) { diff --git a/lib/reader.js b/lib/reader.js index 8da0ecb..6573e16 100644 --- a/lib/reader.js +++ b/lib/reader.js @@ -15,32 +15,22 @@ function BufferReader(buffer, offset) { * @param {[type]} offset [description] * @param {[type]} length [description] * @return {[type]} [description] + * @throws {RangeError} when the range extends past the end of the buffer */ BufferReader.read = function (buffer, offset, length) { - let a = []; - let l = Math.floor(offset / 8); - const m = offset % 8; - // Need enough bytes to cover the bit range [offset, offset+length); when the - // offset isn't byte-aligned, the read can straddle one more byte than - // ceil(length/8) alone accounts for. - let c = Math.ceil((length + m) / 8); - function t(n) { - const r = [0, 0, 0, 0, 0, 0, 0, 0]; - for (let i = 7; i >= 0; i--) { - r[7 - i] = n & Math.pow(2, i) ? 1 : 0; - } - a = a.concat(r); + const end = offset + length; + if (offset < 0 || length < 0 || end > buffer.length * 8) { + throw new RangeError( + `read past end of message: wanted ${length} bits at bit offset ${offset}, ` + + `message is ${buffer.length} octets (${buffer.length * 8} bits)`, + ); } - function p(a) { - let n = 0; - const f = a.length - 1; - for (let i = f; i >= 0; i--) { - if (a[f - i]) n += Math.pow(2, i); - } - return n; + // Accumulate with multiplication: a 32-bit TTL read would otherwise overflow + let n = 0; + for (let bit = offset; bit < end; bit++) { + n = n * 2 + ((buffer[bit >>> 3] >>> (7 - (bit & 7))) & 1); } - while (c--) t(buffer.readUInt8(l++)); - return p(a.slice(m, m + length)); + return n; }; /** @@ -54,4 +44,12 @@ BufferReader.prototype.read = function (size) { return val; }; +/** + * Bits left between the cursor and the end of the message. + * @return {number} + */ +BufferReader.prototype.remaining = function () { + return Math.max(0, this.buffer.length * 8 - this.offset); +}; + module.exports = BufferReader; diff --git a/lib/writer.js b/lib/writer.js index 60840a9..c82cff0 100644 --- a/lib/writer.js +++ b/lib/writer.js @@ -12,8 +12,8 @@ function BufferWriter() { * @return {[type]} [description] */ BufferWriter.prototype.write = function (d, size) { - for (let i = 0; i < size; i++) { - this.buffer.push(d & Math.pow(2, size - i - 1) ? 1 : 0); + for (let i = size - 1; i >= 0; i--) { + this.buffer.push((d >>> i) & 1); } }; @@ -40,7 +40,7 @@ BufferWriter.prototype.byteLength = function () { // placeholders (e.g. RDLENGTH) once the field's contents have been written. BufferWriter.prototype.patch = function (bitOffset, value, size) { for (let i = 0; i < size; i++) { - this.buffer[bitOffset + i] = value & Math.pow(2, size - i - 1) ? 1 : 0; + this.buffer[bitOffset + i] = (value >>> (size - i - 1)) & 1; } }; @@ -49,12 +49,11 @@ BufferWriter.prototype.patch = function (bitOffset, value, size) { * @return {[type]} [description] */ BufferWriter.prototype.toBuffer = function () { - const arr = []; - for (let i = 0; i < this.buffer.length; i += 8) { - const chunk = this.buffer.slice(i, i + 8); - arr.push(parseInt(chunk.join(''), 2)); + const bytes = Buffer.alloc(Math.ceil(this.buffer.length / 8)); + for (let i = 0; i < this.buffer.length; i++) { + if (this.buffer[i]) bytes[i >>> 3] |= 0x80 >>> (i & 7); } - return Buffer.from(arr); + return bytes; }; module.exports = BufferWriter; diff --git a/package-lock.json b/package-lock.json index 4bb41ea..120cbb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { "name": "dns2", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dns2", - "version": "3.0.0", + "version": "3.0.1", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", - "eslint": "^10.4.0", - "globals": "^17.6.0", - "prettier": "^3.8.3" + "eslint": "^10.8.0", + "globals": "^17.8.0", + "prettier": "^3.9.6" + }, + "engines": { + "node": ">=22" } }, "node_modules/@eslint-community/eslint-utils": { @@ -99,11 +102,10 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^1.2.1" }, @@ -116,7 +118,6 @@ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -156,11 +157,10 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" @@ -253,8 +253,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/acorn": { "version": "8.16.0", @@ -337,18 +336,17 @@ "license": "MIT" }, "node_modules/eslint": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", - "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -370,7 +368,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -637,11 +635,10 @@ } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -852,11 +849,10 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/package.json b/package.json index 98ea5cb..fd3b0b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dns2", - "version": "3.0.0", + "version": "3.1.0", "description": "A DNS Server and Client Implementation in Pure JavaScript with no dependencies.", "main": "index.js", "types": "ts/index.d.ts", @@ -65,11 +65,14 @@ "url": "https://github.com/lsongdev/node-dns/issues" }, "homepage": "https://github.com/lsongdev/node-dns#readme", + "engines": { + "node": ">=22" + }, "devDependencies": { "@eslint/js": "^10.0.1", - "eslint": "^10.4.0", - "globals": "^17.6.0", - "prettier": "^3.8.3" + "eslint": "^10.8.0", + "globals": "^17.8.0", + "prettier": "^3.9.6" }, "prettier": { "printWidth": 80, diff --git a/packet.js b/packet.js index 9b77f55..e1ef1e1 100644 --- a/packet.js +++ b/packet.js @@ -1,4 +1,5 @@ -const { debuglog } = require('node:util'); +const net = require('node:net'); +const { debuglog, inspect } = require('node:util'); const { randomInt } = require('node:crypto'); const BufferReader = require('./lib/reader'); const BufferWriter = require('./lib/writer'); @@ -36,30 +37,20 @@ const toIPv6 = buffer => { const fromIPv6 = address => { const digits = address.split(':'); - // CAVEAT edge case for :: and IPs starting - // or ending by :: + // Leading/trailing "::" produces an empty leading/trailing element that is + // not a zero group of its own; drop it so only the interior "" marks the run. if (digits[0] === '') { digits.shift(); } if (digits[digits.length - 1] === '') { digits.pop(); } - // node js 10 does not support Array.prototype.flatMap - if (!Array.prototype.flatMap) { - Array.prototype.flatMap = function (f, ctx) { - return this.reduce((r, x, i, a) => r.concat(f.call(ctx, x, i, a)), []); - }; - } - - // CAVEAT we have to take into account - // the extra space used by the empty string + // The interior empty string occupies a slot of its own, so it stands in for + // one more group than the shortfall in `digits`. const missingFields = 8 - digits.length + 1; - return digits.flatMap(digit => { - if (digit === '') { - return Array(missingFields).fill('0'); - } - return digit.padStart(4, '0'); - }); + return digits.flatMap(digit => + digit === '' ? Array(missingFields).fill('0') : digit.padStart(4, '0'), + ); }; /** @@ -80,6 +71,9 @@ function Packet(data) { this.answers = []; this.authorities = []; this.additionals = []; + // Populated by Packet.parse with one Packet.DecodeError per record it could + // not decode; empty for messages built in memory or parsed cleanly. + this.errors = []; if (data instanceof Packet) { return data; } else if (data instanceof Packet.Header) { @@ -102,6 +96,9 @@ function Packet(data) { return this; } +// Octets in a DNS message header (RFC 1035 §4.1.1). +Packet.HEADER_SIZE = 12; + /** * [QUERY_TYPE description] * @type {Object} @@ -127,6 +124,7 @@ Packet.TYPE = { AAAA: 0x1c, SRV: 0x21, EDNS: 0x29, + RRSIG: 0x2e, SPF: 0x63, AXFR: 0xfc, MAILB: 0xfd, @@ -135,6 +133,23 @@ Packet.TYPE = { CAA: 0x101, DNSKEY: 0x30, }; +/** + * Reverse of Packet.TYPE, used to dispatch rdata codecs and to name types in + * diagnostics. + * @type {Object} + */ +Packet.TYPE_NAME = Object.fromEntries( + Object.entries(Packet.TYPE).map(([name, code]) => [code, name]), +); + +/** + * Name of a type code, falling back to the RFC 3597 §5 "TYPE" presentation + * for types this library has no codec for. + * @param {number} code + * @return {string} + */ +Packet.typeName = code => Packet.TYPE_NAME[code] || `TYPE${code}`; + /** * [QUERY_CLASS description] * @type {Object} @@ -159,6 +174,28 @@ Packet.RCODE = { NXDOMAIN: 3, NOTIMP: 4, REFUSED: 5, + YXDOMAIN: 6, + YXRRSET: 7, + NXRRSET: 8, + NOTAUTH: 9, + NOTZONE: 10, + DSOTYPENI: 11, + // Codes above 15 do not fit the header's 4-bit RCODE field: the high byte + // travels in an OPT record's TTL (RFC 6891 §6.1.3), so a response using one + // MUST carry an OPT. Packet.toBuffer performs that split. + // + // 16 has two assignments in the IANA registry — BADVERS for an unsupported + // EDNS version (RFC 6891) and BADSIG for a TSIG failure (RFC 8945). They + // share the code point on the wire; only context tells them apart. + BADVERS: 16, + BADSIG: 16, + BADKEY: 17, + BADTIME: 18, + BADMODE: 19, + BADNAME: 20, + BADALG: 21, + BADTRUNC: 22, + BADCOOKIE: 23, }; /** * [EDNS_OPTION_CODE description] @@ -167,7 +204,67 @@ Packet.RCODE = { */ Packet.EDNS_OPTION_CODE = { ECS: 0x08, + EDE: 0x0f, +}; +/** + * Extended DNS Error INFO-CODEs. These explain a response; they do not replace + * its RCODE. Codes past 24 are later registry additions, some originating from + * drafts or vendor implementations rather than a published RFC. + * @type {Object} + * @docs https://tools.ietf.org/html/rfc8914#section-4 + * @docs https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#extended-dns-error-codes + */ +Packet.EDE = { + OTHER: 0, + UNSUPPORTED_DNSKEY_ALGORITHM: 1, + UNSUPPORTED_DS_DIGEST_TYPE: 2, + STALE_ANSWER: 3, + FORGED_ANSWER: 4, + DNSSEC_INDETERMINATE: 5, + DNSSEC_BOGUS: 6, + SIGNATURE_EXPIRED: 7, + SIGNATURE_NOT_YET_VALID: 8, + DNSKEY_MISSING: 9, + RRSIGS_MISSING: 10, + NO_ZONE_KEY_BIT_SET: 11, + NSEC_MISSING: 12, + CACHED_ERROR: 13, + NOT_READY: 14, + BLOCKED: 15, + CENSORED: 16, + FILTERED: 17, + PROHIBITED: 18, + STALE_NXDOMAIN_ANSWER: 19, + NOT_AUTHORITATIVE: 20, + NOT_SUPPORTED: 21, + NO_REACHABLE_AUTHORITY: 22, + NETWORK_ERROR: 23, + INVALID_DATA: 24, + SIGNATURE_EXPIRED_BEFORE_VALID: 25, + TOO_EARLY: 26, + UNSUPPORTED_NSEC3_ITERATIONS: 27, + UNABLE_TO_CONFORM_TO_POLICY: 28, + SYNTHESIZED: 29, + INVALID_QUERY_TYPE: 30, + RATE_LIMITED: 31, + OVER_QUOTA: 32, + NEGATIVE_TRUST_ANCHOR: 33, + NEW_DELEGATION_ONLY: 34, }; +/** + * Reverse of Packet.EDE, for naming a received INFO-CODE in diagnostics. + * @type {Object} + */ +Packet.EDE_NAME = Object.fromEntries( + Object.entries(Packet.EDE).map(([name, code]) => [code, name]), +); +/** + * Reverse of Packet.EDNS_OPTION_CODE. + * @type {Object} + */ +Packet.EDNS_OPTION_NAME = Object.fromEntries( + Object.entries(Packet.EDNS_OPTION_CODE).map(([name, code]) => [code, name]), +); /** * Generate a cryptographically random 16-bit DNS transaction ID. @@ -179,34 +276,84 @@ Packet.uuid = function () { return randomInt(0x10000); }; +/** + * A record, question, or message that could not be decoded. + * + * Records that fail to decode are dropped rather than half-populated, so the + * reason has to travel separately: Packet.parse collects one of these per + * failure on `packet.errors`, and throws one when the message itself is + * unusable. + * + * @property {string} [section] questions / answers / authorities / additionals + * @property {number} [index] position of the record within that section + * @property {number} [offset] octet offset in the message where it started + * @property {boolean} recovered whether decoding resumed after this failure + */ +class DecodeError extends Error { + constructor(message, context = {}) { + const { section, index, offset, cause } = context; + const where = + section === undefined + ? '' + : `${section}[${index}]${offset === undefined ? '' : ` at offset ${offset}`}: `; + super(`${where}${message}`, cause ? { cause } : undefined); + this.name = 'DecodeError'; + Object.assign(this, context); + this.recovered = !!context.recovered; + } +} +Packet.DecodeError = DecodeError; + /** * [parse description] * @param {[type]} buffer [description] * @return {[type]} [description] + * @throws {Packet.DecodeError} when the message has no usable header; per-record + * failures are reported on the returned packet's `errors` array */ Packet.parse = function (buffer) { + if (!Buffer.isBuffer(buffer)) { + throw new DecodeError( + `expected a Buffer, got ${buffer === null ? 'null' : typeof buffer}`, + ); + } + if (buffer.length < Packet.HEADER_SIZE) { + throw new DecodeError( + `message is ${buffer.length} octets, too short for the ` + + `${Packet.HEADER_SIZE}-octet header (RFC 1035 §4.1.1)`, + ); + } const packet = new Packet(); const reader = new Packet.Reader(buffer); packet.header = Packet.Header.parse(reader); - [ - // props parser count + // A failure that left the reader misaligned makes every later record in the + // message garbage, so parsing stops there rather than manufacturing junk + // records. Failures confined to one record's RDATA are recoverable: the + // reader is repositioned by RDLENGTH and the next record still decodes. + sections: for (const [section, decoder, count] of [ ['questions', Packet.Question, packet.header.qdcount], ['answers', Packet.Resource, packet.header.ancount], ['authorities', Packet.Resource, packet.header.nscount], ['additionals', Packet.Resource, packet.header.arcount], - ].forEach(function (def) { - const section = def[0]; - const decoder = def[1]; - let count = def[2]; - while (count--) { + ]) { + for (let index = 0; index < count; index++) { + const offset = reader.offset / 8; try { - packet[section] = packet[section] || []; packet[section].push(decoder.parse(reader)); - } catch (e) { - debug('node-dns > parse %s error:', section, e.message); + } catch (cause) { + const error = new DecodeError(cause.message, { + section, + index, + offset, + recovered: !!cause.recovered, + cause, + }); + packet.errors.push(error); + debug('node-dns > %s', error.message); + if (!error.recovered) break sections; } } - }); + } // RFC 6891 §6.1.3: when an OPT record is present the wire RCODE is 12 bits: // the 4 low bits come from the header, the 8 high bits come from the OPT // record's TTL high byte. Merge them so callers see the full 12-bit value. @@ -412,9 +559,24 @@ Packet.Question.parse = Packet.Question.decode = function (reader) { return question; }; +// A non-numeric TYPE or CLASS would be written as 16 zero bits, turning a typo +// such as Packet.TYPE.AAA (undefined) into a valid-looking type 0 on the wire. +const assertCode = (value, field, context) => { + if (!Number.isInteger(value) || value < 0 || value > 0xffff) { + // inspect, not JSON.stringify: the latter renders NaN and Infinity as + // "null". Nor String(), which renders the string '1' as 1 — the very + // confusion this message exists to resolve. + throw new Error( + `${context}: ${field} must be a 16-bit integer, got ${inspect(value)}`, + ); + } +}; + Packet.Question.encode = function (question, writer) { const ownsWriter = !writer; writer = writer || new Packet.Writer(); + assertCode(question.type, 'type', `Question encode "${question.name}"`); + assertCode(question.class, 'class', `Question encode "${question.name}"`); Packet.Name.encode(question.name, writer); writer.write(question.type, 16); writer.write(question.class, 16); @@ -464,15 +626,15 @@ Packet.Resource.prototype.toBuffer = function (writer) { */ Packet.Resource.encode = function (resource, writer) { writer = writer || new Packet.Writer(); + assertCode(resource.type, 'type', `Resource encode "${resource.name}"`); + assertCode(resource.class, 'class', `Resource encode "${resource.name}"`); Packet.Name.encode(resource.name, writer); writer.write(resource.type, 16); writer.write(resource.class, 16); // RFC 2181 §8: TTL is an unsigned 32-bit value but high-bit values are // historically unsafe; clamp to 2^31 - 1 on the wire. writer.write(Math.min(resource.ttl >>> 0, 0x7fffffff), 32); - const encoder = Object.keys(Packet.TYPE).filter(function (type) { - return resource.type === Packet.TYPE[type]; - })[0]; + const encoder = Packet.TYPE_NAME[resource.type]; // RDLENGTH is owned here, not by each rdata encoder. We write a 16-bit // placeholder, dispatch to the rdata encoder, then back-fill the length. // This is what lets rdata encoders use compression pointers without having @@ -480,8 +642,9 @@ Packet.Resource.encode = function (resource, writer) { const rdlenBitPos = writer.bitLength(); writer.write(0, 16); const rdataBitStart = writer.bitLength(); - if (encoder in Packet.Resource && Packet.Resource[encoder].encode) { - Packet.Resource[encoder].encode(resource, writer); + const codec = encoder && Packet.Resource[encoder]; + if (codec && codec.encode) { + codec.encode(resource, writer); } else { debug('node-dns > unknown encoder %s(%j)', encoder, resource.type); // Fallback for unknown / decoder-only types: round-trip the raw RDATA the @@ -517,17 +680,43 @@ Packet.Resource.parse = Packet.Resource.decode = function (reader) { // treated them as signed. Anything with the high bit set is clamped to // 2^31 - 1 so it cannot be misinterpreted as a negative value. if (resource.ttl > 0x7fffffff) resource.ttl = 0x7fffffff; - let length = reader.read(16); - const parser = Object.keys(Packet.TYPE).filter(function (type) { - return resource.type === Packet.TYPE[type]; - })[0]; - if (parser in Packet.Resource) { - resource = Packet.Resource[parser].decode.call(resource, reader, length); - } else { - debug('node-dns > unknown parser type: %s(%j)', parser, resource.type); - const arr = []; - while (length--) arr.push(reader.read(8)); - resource.data = Buffer.from(arr); + const length = reader.read(16); + const label = `${Packet.typeName(resource.type)} record "${resource.name}"`; + if (length * 8 > reader.remaining()) { + throw new Error( + `${label} declares RDLENGTH ${length} but only ` + + `${reader.remaining() / 8} octet(s) remain in the message`, + ); + } + // RDLENGTH delimits the record on the wire, so it — not the rdata decoder — + // decides where the next record begins. Restoring the cursor to that boundary + // keeps a malformed record from cascading into the ones that follow, and lets + // Packet.parse report the failure as recoverable. + const rdataStart = reader.offset; + const rdataEnd = rdataStart + length * 8; + const parser = Packet.TYPE_NAME[resource.type]; + const codec = parser && Packet.Resource[parser]; + try { + if (codec && codec.decode) { + resource = codec.decode.call(resource, reader, length); + if (reader.offset !== rdataEnd) { + throw new Error( + `${label} rdata consumed ${(reader.offset - rdataStart) / 8} ` + + `octet(s), RDLENGTH declares ${length}`, + ); + } + } else { + debug('node-dns > unknown parser type: %s(%j)', parser, resource.type); + // RFC 3597 §5: retain unknown rdata verbatim so it can be re-emitted. + resource.data = Buffer.from( + reader.buffer.subarray(rdataStart / 8, rdataEnd / 8), + ); + } + } catch (cause) { + cause.recovered = true; + throw cause; + } finally { + reader.offset = rdataEnd; } return resource; }; @@ -660,6 +849,13 @@ Packet.Resource.A = function (address) { Packet.Resource.A.encode = function (record, writer) { writer = writer || new Packet.Writer(); + // Without this check a malformed address writes NaN octets, silently + // encoding as 0.0.0.0 on the wire. + if (!net.isIPv4(record.address)) { + throw new Error( + `A encode: invalid IPv4 address ${JSON.stringify(record.address)}`, + ); + } // RDLENGTH is written by Packet.Resource.encode; only emit the rdata here. // No toBuffer() — the caller owns materialization (avoids O(N) re-walks of // the message bit-array per record). @@ -669,6 +865,10 @@ Packet.Resource.A.encode = function (record, writer) { }; Packet.Resource.A.decode = function (reader, length) { + // RFC 1035 §3.4.1 — ADDRESS is exactly one 32-bit value. + if (length !== 4) { + throw new Error(`A decode: RDLENGTH is ${length}, expected 4`); + } const parts = []; while (length--) parts.push(reader.read(8)); this.address = parts.join('.'); @@ -717,6 +917,11 @@ Packet.Resource.MX.decode = function (reader, length) { */ Packet.Resource.AAAA = { decode: function (reader, length) { + // RFC 3596 §2.2 — a 128-bit address. An odd or short length would step the + // `length -= 2` countdown past zero and read into the following records. + if (length !== 16) { + throw new Error(`AAAA decode: RDLENGTH is ${length}, expected 16`); + } const parts = []; while (length) { length -= 2; @@ -727,6 +932,11 @@ Packet.Resource.AAAA = { }, encode: function (record, writer) { writer = writer || new Packet.Writer(); + if (!net.isIPv6(record.address)) { + throw new Error( + `AAAA encode: invalid IPv6 address ${JSON.stringify(record.address)}`, + ); + } fromIPv6(record.address).forEach(function (part) { writer.write(parseInt(part, 16), 16); }); @@ -778,16 +988,13 @@ Packet.Resource.SPF = Packet.Resource.TXT = { while (bytesRead < length) { const chunkLength = 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. + // A character-string whose length runs past the end of RDATA would make + // us read into the next record; Packet.Resource.parse restores the cursor + // to the RDLENGTH boundary so the following records still decode. 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)`, + `RDATA (${length - bytesRead} octets remaining)`, ); } const bytes = Buffer.alloc(chunkLength); @@ -926,31 +1133,42 @@ Packet.Resource.EDNS.decode = function (reader, length) { this.doFlag = !!(ttl & 0x8000); this.rdata = []; - while (length) { + // RFC 6891 §6.1.2 — RDATA is a sequence of {code, length, data} triples. + while (length > 0) { + if (length < 4) { + throw new Error( + `EDNS decode: ${length} octet(s) left in RDATA, too few for an ` + + 'option header', + ); + } const optionCode = reader.read(16); const optionLength = reader.read(16); // In octet (https://tools.ietf.org/html/rfc6891#page-8) + length -= 4; + if (optionLength > length) { + throw new Error( + `EDNS decode: option ${optionCode} declares ${optionLength} octet(s) ` + + `but only ${length} remain in RDATA`, + ); + } - const decoder = Object.keys(Packet.EDNS_OPTION_CODE).filter( - function (type) { - return optionCode === Packet.EDNS_OPTION_CODE[type]; - }, - )[0]; - if ( - decoder in Packet.Resource.EDNS && - Packet.Resource.EDNS[decoder].decode - ) { - const rdata = Packet.Resource.EDNS[decoder].decode(reader, optionLength); - this.rdata.push(rdata); + const decoder = Packet.EDNS_OPTION_NAME[optionCode]; + const codec = decoder && Packet.Resource.EDNS[decoder]; + if (codec && codec.decode) { + const optionEnd = reader.offset + optionLength * 8; + this.rdata.push(codec.decode(reader, optionLength)); + // An option decoder that mis-counts would shift every option after it. + reader.offset = optionEnd; } else { - reader.read(optionLength); // Ignore data that doesn't understand + // Skip the option body; `read` counts bits, the option length is octets. + reader.offset += optionLength * 8; debug( - 'node-dns > unknown EDNS rdata decoder %s(%j)', - decoder, + 'node-dns > skipping EDNS option code %d (%d octets): no decoder', optionCode, + optionLength, ); } - length = length - 4 - optionLength; + length -= optionLength; } return this; }; @@ -960,25 +1178,33 @@ Packet.Resource.EDNS.encode = function (record, writer) { // RDLENGTH is owned by Packet.Resource.encode; emit option records back to // back into the main writer. for (const rdata of record.rdata) { - const encoder = Object.keys(Packet.EDNS_OPTION_CODE).filter( - function (type) { - return rdata.ednsCode === Packet.EDNS_OPTION_CODE[type]; - }, - )[0]; - if ( - encoder in Packet.Resource.EDNS && - Packet.Resource.EDNS[encoder].encode - ) { + const encoder = Packet.EDNS_OPTION_NAME[rdata.ednsCode]; + const codec = encoder && Packet.Resource.EDNS[encoder]; + if (codec && codec.encode) { const w = new Packet.Writer(); - Packet.Resource.EDNS[encoder].encode(rdata, w); + codec.encode(rdata, w); + // The 16-bit length has to match what the encoder actually wrote. A + // fractional or oversized count is silently truncated by write(), which + // would misalign every option after this one and the records beyond. + if (w.bitLength() % 8 !== 0) { + throw new Error( + `EDNS option ${rdata.ednsCode} encoder wrote ${w.bitLength()} bits, ` + + 'not a whole number of octets', + ); + } + if (w.byteLength() > 0xffff) { + throw new Error( + `EDNS option ${rdata.ednsCode} is ${w.byteLength()} octets, too long ` + + 'for its 16-bit length field', + ); + } writer.write(rdata.ednsCode, 16); - writer.write(w.buffer.length / 8, 16); + writer.write(w.byteLength(), 16); writer.writeBuffer(w); } else { debug( - 'node-dns > unknown EDNS rdata encoder %s(%j)', - encoder, - rdata.ednsCode, + 'node-dns > dropping EDNS option code %s: no encoder', + inspect(rdata.ednsCode), ); } } @@ -997,6 +1223,13 @@ Packet.Resource.EDNS.ECS = function (clientIp) { }; Packet.Resource.EDNS.ECS.decode = function (reader, length) { + // RFC 7871 §6 — family (2), source prefix (1), scope prefix (1), then the + // leftmost ceil(sourcePrefixLength / 8) octets of the address. + if (length < 4) { + throw new Error( + `EDNS.ECS decode: option is ${length} octet(s), expected at least 4`, + ); + } const rdata = {}; rdata.ednsCode = Packet.EDNS_OPTION_CODE.ECS; rdata.family = reader.read(16); @@ -1004,6 +1237,14 @@ Packet.Resource.EDNS.ECS.decode = function (reader, length) { rdata.scopePrefixLength = reader.read(8); length -= 4; + const addressOctets = { 1: 4, 2: 16 }[rdata.family]; + if (addressOctets !== undefined && length > addressOctets) { + throw new Error( + `EDNS.ECS decode: family ${rdata.family} address is ${length} octet(s), ` + + `at most ${addressOctets}`, + ); + } + if (rdata.family === 1) { const ipv4Octets = []; while (length--) { @@ -1018,7 +1259,9 @@ Packet.Resource.EDNS.ECS.decode = function (reader, length) { if (rdata.family === 2) { const ipv6Segments = []; - for (; length; length -= 2) { + // A truncated address can leave an odd octet; `length > 0` keeps the + // countdown from stepping past zero and reading into the next option. + for (; length > 0; length -= 2) { const segment = reader.read(16).toString(16); ipv6Segments.push(segment); } @@ -1076,6 +1319,52 @@ function expandIPv6ToBytes(address) { return out; } +// RFC 8914 §3 — EXTRA-TEXT should stay short. It shares the response with the +// answer, which still has to fit the negotiated UDP payload size. +Packet.EDE_MAX_TEXT = 256; + +/** + * Extended DNS Error — an INFO-CODE naming the category of failure plus + * free-form UTF-8 text explaining it. Additive: it annotates a response + * without changing its RCODE. + * @docs https://tools.ietf.org/html/rfc8914 + */ +Packet.Resource.EDNS.EDE = function (infoCode, extraText = '') { + return { + ednsCode: Packet.EDNS_OPTION_CODE.EDE, + infoCode, + extraText, + }; +}; + +Packet.Resource.EDNS.EDE.decode = function (reader, length) { + // RFC 8914 §2 — 16-bit INFO-CODE, then optional EXTRA-TEXT to the end of + // the option. + if (length < 2) { + throw new Error( + `EDNS.EDE decode: option is ${length} octet(s), expected at least 2`, + ); + } + const infoCode = reader.read(16); + const bytes = Buffer.alloc(length - 2); + for (let i = 0; i < bytes.length; i++) bytes[i] = reader.read(8); + return { + ednsCode: Packet.EDNS_OPTION_CODE.EDE, + infoCode, + // §3 warns that EXTRA-TEXT must not be assumed null-terminated; senders + // that terminate it anyway would otherwise leave NULs in the string. + extraText: bytes.toString('utf8').replace(/\0+$/, ''), + }; +}; + +Packet.Resource.EDNS.EDE.encode = function (record, writer) { + assertCode(record.infoCode, 'infoCode', 'EDNS.EDE encode'); + writer.write(record.infoCode, 16); + for (const byte of Buffer.from(record.extraText || '', 'utf8')) { + writer.write(byte, 8); + } +}; + Packet.Resource.CAA = { encode: function (record, writer) { writer = writer || new Packet.Writer(); @@ -1089,10 +1378,20 @@ Packet.Resource.CAA = { }); }, decode: function (reader, length) { + // RFC 8659 §4.1 — flags octet, tag length octet, then tag and value. + if (length < 2) { + throw new Error(`CAA decode: RDLENGTH is ${length}, expected at least 2`); + } this.flags = reader.read(8); const tagLength = reader.read(8); - const bytes = []; let remaining = length - 2; + if (tagLength > remaining) { + throw new Error( + `CAA decode: tag length ${tagLength} overruns RDATA ` + + `(${remaining} octets remaining)`, + ); + } + const bytes = []; while (remaining--) bytes.push(reader.read(8)); const buffer = Buffer.from(bytes); this.tag = buffer.slice(0, tagLength).toString('utf8'); @@ -1108,6 +1407,12 @@ Packet.Resource.CAA = { */ Packet.Resource.DNSKEY = { decode: function (reader, length) { + // RFC 4034 §2.1 — flags (2), protocol (1), algorithm (1), then the key. + if (length < 4) { + throw new Error( + `DNSKEY decode: RDLENGTH is ${length}, expected at least 4`, + ); + } const RData = []; while (RData.length < length) { RData.push(reader.read(8)); @@ -1156,39 +1461,30 @@ Packet.Resource.DNSKEY = { */ Packet.Resource.RRSIG = { decode: function (reader, length) { - function dateForSig(date) { - // javascript date is from millisecond - date = new Date(date * 1000); - const definitions = { - month: date.getUTCMonth() + 1, - date: date.getUTCDate(), - hour: date.getUTCHours(), - minutes: date.getUTCMinutes(), - seconds: date.getUTCSeconds(), - }; - let i; - for (i in definitions) { - // if less than 10 > single - if (definitions[i] < 10) { - definitions[i] = '0' + '' + definitions[i]; - } - } - return ( - date.getFullYear() + - '' + - definitions.month + - '' + - definitions.date + - '' + - definitions.hour + - '' + - definitions.minutes + - '' + - definitions.seconds - ); + // RFC 4034 §3.2 — inception/expiration are presented as YYYYMMDDHHmmSS in + // UTC. Every field has to come from the UTC accessors: mixing in the local + // year puts the wrong year on a signature near a year boundary. + function dateForSig(seconds) { + const date = new Date(seconds * 1000); + const pad = n => String(n).padStart(2, '0'); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + ].join(''); } - // calculate max-offset uint8 + // RFC 4034 §3.1 — 18 octets of fixed fields, then the signer name and the + // signature. Anything shorter cannot hold a signature. + if (length < 18) { + throw new Error( + `RRSIG decode: RDLENGTH is ${length}, expected at least 18`, + ); + } + const rdataStart = reader.offset; const maxOffset = reader.offset + length * 8; /* * Stuff sign contains 18 octets @@ -1207,6 +1503,13 @@ Packet.Resource.RRSIG = { signature.push(reader.read(8)); } this.signature = Buffer.from(signature).toString('base64'); + // There is no RRSIG encoder — the decoded form is lossy (timestamps become + // display strings). Retaining the raw rdata lets Packet.Resource.encode's + // unknown-type fallback re-emit the record byte for byte, so a proxy that + // parses and re-serializes a signed response does not strip the signature. + this.data = Buffer.from( + reader.buffer.subarray(rdataStart / 8, maxOffset / 8), + ); return this; }, }; @@ -1232,37 +1535,140 @@ Packet.createResourceFromQuestion = function (base, record) { return resource; }; +/** + * Build an error response for a request, optionally explaining why with an + * RFC 8914 Extended DNS Error. + * + * For a request that only partly decoded, the reason is already to hand: + * + * Packet.createErrorResponseFromRequest(request, Packet.RCODE.FORMERR, { + * infoCode: Packet.EDE.INVALID_DATA, + * extraText: request.errors.map(e => e.message).join('; '), + * }); + * + * @param {Packet} request + * @param {number} rcode + * @param {{infoCode: number, extraText?: string}} [ede] + * @return {Packet} + */ +Packet.createErrorResponseFromRequest = function (request, rcode, ede) { + const response = Packet.createResponseFromRequest(request); + response.header.rcode = rcode; + const requestOpt = (request.additionals || []).find( + r => r && r.type === Packet.TYPE.EDNS, + ); + // An OPT belongs in the response when the request signalled EDNS + // (RFC 6891 §6.1.1), and is *required* for an RCODE above 15, whose high + // byte rides in the OPT TTL — without one only the low nibble survives, and + // BADVERS would go out as NOERROR. + if (!requestOpt && rcode <= 0xf) return response; + const rdata = []; + // RFC 8914 §3: extended errors belong only in responses to EDNS requests. + if (ede && requestOpt) { + rdata.push( + Packet.Resource.EDNS.EDE( + ede.infoCode, + String(ede.extraText ?? '').slice(0, Packet.EDE_MAX_TEXT), + ), + ); + } + response.additionals.push(Packet.Resource.EDNS(rdata)); + return response; +}; + +/** + * Read one length-prefixed DNS message from a stream (RFC 1035 §4.2.2). + * @param {stream.Readable} socket + * @return {Promise} the message, without its 2-octet length prefix + */ Packet.readStream = socket => { let chunks = []; let chunklen = 0; - let received = false; - let expected = false; + let settled = false; + let ended = false; + let expected = null; return new Promise((resolve, reject) => { + // This call borrows the socket for exactly one message, so it releases its + // listeners once settled. Left attached, `onReadable` would keep pulling + // bytes out of the stream into `chunks` — never returned to anyone, and no + // longer visible to whoever reads the next pipelined message. + const cleanup = () => { + socket.removeListener('readable', onReadable); + socket.removeListener('end', onEnd); + socket.removeListener('error', fail); + }; + // 'end' may have been emitted before these listeners attached, in which + // case onEnd will never run and `ended` stays false. readableEnded is the + // reliable signal; the flag covers streams that don't implement it. + const streamEnded = () => ended || socket.readableEnded === true; + const fail = error => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; const processMessage = () => { - if (received) return; - received = true; + if (settled) return; + settled = true; + cleanup(); const buffer = Buffer.concat(chunks, chunklen); - resolve(buffer.slice(2)); + // Bound by the declared length: anything past it belongs to the next + // pipelined message, not this one. Hand those octets back to the stream + // so the next reader still sees them. Once the peer has half-closed + // there is no next message, and unshift after 'end' throws. + const end = 2 + expected; + if (!streamEnded() && chunklen > end) socket.unshift(buffer.slice(end)); + resolve(buffer.slice(2, end)); }; - socket.on('end', processMessage); - socket.on('error', reject); - socket.on('readable', () => { + const onEnd = () => { + ended = true; + // A message cut short is reported as such. Resolving with the partial + // bytes instead would surface as a puzzling decode failure downstream. + if (expected === null) { + return fail( + new DecodeError( + `connection closed after ${chunklen} octet(s), before the ` + + '2-octet message length prefix (RFC 1035 §4.2.2)', + ), + ); + } + if (chunklen < 2 + expected) { + return fail( + new DecodeError( + `connection closed after ${chunklen - 2} of ${expected} ` + + 'declared message octet(s)', + ), + ); + } + processMessage(); + }; + const onReadable = () => { let chunk; while ((chunk = socket.read()) !== null) { chunks.push(chunk); chunklen += chunk.length; } - if (!expected && chunklen >= 2) { + if (expected === null && chunklen >= 2) { if (chunks.length > 1) { chunks = [Buffer.concat(chunks, chunklen)]; } expected = chunks[0].readUInt16BE(0); } - if (chunklen >= 2 + expected) { + if (expected !== null && chunklen >= 2 + expected) { processMessage(); } - }); + }; + socket.on('end', onEnd); + socket.on('error', fail); + socket.on('readable', onReadable); + // Drain anything buffered before our listener attached — in particular the + // remainder unshifted by a previous readStream call on this socket. + onReadable(); + // A stream that has already emitted 'end' will never emit it again, so + // nothing above can settle this promise. Reach the same verdict now instead + // of waiting for an event that cannot arrive. + if (!settled && socket.readableEnded === true) onEnd(); }); }; diff --git a/server/tcp.js b/server/tcp.js index 4faf29f..42b7d47 100644 --- a/server/tcp.js +++ b/server/tcp.js @@ -35,7 +35,6 @@ class Server extends tcp.Server { // flight: we must not close our own write side while a handler may still // call send(). const state = { inFlight: 0, peerEnded: false }; - client._dnsPipeline = state; try { if (this.proxyProtocol) { const header = await consumeProxyHeader(client); @@ -68,7 +67,7 @@ class Server extends tcp.Server { this.emit( 'request', message, - this.response.bind(this, client), + this.response.bind(this, client, state), client, ); }, @@ -83,7 +82,7 @@ class Server extends tcp.Server { } } - response(client, message) { + response(client, state, message) { if (message instanceof Packet) { message = message.toBuffer(); } @@ -100,12 +99,9 @@ class Server extends tcp.Server { // was the last outstanding response, half-close our side too. Without // this guard, a client that sent its query via socket.end(frame) would // see us close before its handler runs. - const state = client._dnsPipeline; - if (state) { - if (state.inFlight > 0) state.inFlight--; - if (state.peerEnded && state.inFlight === 0 && !client.destroyed) { - client.end(); - } + if (state.inFlight > 0) state.inFlight--; + if (state.peerEnded && state.inFlight === 0 && !client.destroyed) { + client.end(); } } } diff --git a/test/client.js b/test/client.js index 135f15b..c96bc3b 100644 --- a/test/client.js +++ b/test/client.js @@ -459,3 +459,123 @@ test('client/udp respects retryOverTCP:false and returns truncated packet', asyn ); await new Promise(resolve => udpServer.close(resolve)); }); + +test('client/udp timeout names the last dropped response', async () => { + // A resolver that answers with something undecodable. The client is right to + // keep waiting for a real reply, but the eventual timeout should carry the + // reason rather than looking like an unresponsive server. + const server = udp.createSocket('udp4'); + await new Promise(resolve => server.bind(0, '127.0.0.1', resolve)); + const { port: serverPort } = server.address(); + server.on('message', (msg, rinfo) => + server.send(Buffer.alloc(5), rinfo.port, rinfo.address), + ); + + const query = UDPClient({ dns: '127.0.0.1', port: serverPort, timeout: 300 }); + await assert.rejects(query('garbage.test'), err => { + assert.equal(err.code, 'ETIMEDOUT'); + assert.match(err.message, /timed out after 300ms/); + assert.match( + err.message, + /last response could not be decoded: message is 5 octets, too short/, + ); + return true; + }); + await new Promise(resolve => server.close(resolve)); +}); + +test('client/udp timeout names an id mismatch', async () => { + const server = udp.createSocket('udp4'); + await new Promise(resolve => server.bind(0, '127.0.0.1', resolve)); + const { port: serverPort } = server.address(); + server.on('message', (msg, rinfo) => { + const stray = new Packet(); + stray.header.id = (Packet.parse(msg).header.id + 1) & 0xffff; + stray.header.qr = 1; + server.send(stray.toBuffer(), rinfo.port, rinfo.address); + }); + + const query = UDPClient({ dns: '127.0.0.1', port: serverPort, timeout: 300 }); + await assert.rejects( + query('mismatch.test'), + /last response id \d+ did not match the query id \d+/, + ); + await new Promise(resolve => server.close(resolve)); +}); + +test('dns#resolveA forwards clientIp as an ECS option', async () => { + // The second argument is documented as a client subnet; it has to reach the + // query as an EDNS Client Subnet option, not as the options bag itself. + let request; + const server = createUDPServer((req, send) => { + request = req; + const response = Packet.createResponseFromRequest(req); + response.answers.push({ + name: 'subnet.test', + type: Packet.TYPE.A, + class: Packet.CLASS.IN, + ttl: 60, + address: '1.2.3.4', + }); + send(response); + }); + await server.listen(0, '127.0.0.1'); + const { port } = server.address(); + + const dns = new DNS({ dns: '127.0.0.1', port }); + await dns.resolveA('subnet.test', '178.67.222.0/24'); + + const opt = request.additionals.find(r => r.type === Packet.TYPE.EDNS); + assert.ok(opt, 'query carries an OPT record'); + const [ecs] = opt.rdata; + assert.equal(ecs.ednsCode, Packet.EDNS_OPTION_CODE.ECS); + assert.equal(ecs.sourcePrefixLength, 24); + assert.equal(ecs.ip, '178.67.222.0'); + + await new Promise(resolve => server.close(resolve)); +}); + +test('dns#resolve succeeds when one of several name servers fails', async () => { + const server = createUDPServer((req, send) => { + const response = Packet.createResponseFromRequest(req); + response.answers.push({ + name: 'survivor.test', + type: Packet.TYPE.A, + class: Packet.CLASS.IN, + ttl: 60, + address: '5.6.7.8', + }); + send(response); + }); + await server.listen(0, '127.0.0.1'); + const { port } = server.address(); + + // 192.0.2.1 is TEST-NET-1 and will not answer; the working server must still + // satisfy the lookup rather than losing a race to the other's timeout. + const dns = new DNS({ + nameServers: ['192.0.2.1', '127.0.0.1'], + port, + timeout: 500, + }); + const result = await dns.resolve('survivor.test', 'A'); + assert.equal(result.answers[0].address, '5.6.7.8'); + + await new Promise(resolve => server.close(resolve)); +}); + +test('dns#resolve reports every name server when all of them fail', async () => { + const dns = new DNS({ + nameServers: ['192.0.2.1', '192.0.2.2'], + timeout: 300, + }); + await assert.rejects(dns.resolve('nowhere.test', 'A'), err => { + assert.match( + err.message, + /A lookup of nowhere.test failed on all 2 name server\(s\)/, + ); + assert.match(err.message, /192\.0\.2\.1: DNS query timed out/); + assert.match(err.message, /192\.0\.2\.2: DNS query timed out/); + assert.ok(err.cause instanceof AggregateError); + return true; + }); +}); diff --git a/test/packet.js b/test/packet.js index 28e0738..00b072a 100644 --- a/test/packet.js +++ b/test/packet.js @@ -1,4 +1,5 @@ const assert = require('node:assert'); +const { PassThrough } = require('node:stream'); const test = require('./test'); const { Packet } = require('..'); @@ -887,6 +888,22 @@ test('Packet.RCODE contains all standard error codes', function () { assert.equal(Packet.RCODE.NXDOMAIN, 3); assert.equal(Packet.RCODE.NOTIMP, 4); assert.equal(Packet.RCODE.REFUSED, 5); + assert.equal(Packet.RCODE.YXDOMAIN, 6); + assert.equal(Packet.RCODE.YXRRSET, 7); + assert.equal(Packet.RCODE.NXRRSET, 8); + assert.equal(Packet.RCODE.NOTAUTH, 9); + assert.equal(Packet.RCODE.NOTZONE, 10); + assert.equal(Packet.RCODE.DSOTYPENI, 11); + // 16 is assigned twice by IANA; both names share the code point. + assert.equal(Packet.RCODE.BADVERS, 16); + assert.equal(Packet.RCODE.BADSIG, 16); + assert.equal(Packet.RCODE.BADKEY, 17); + assert.equal(Packet.RCODE.BADTIME, 18); + assert.equal(Packet.RCODE.BADMODE, 19); + assert.equal(Packet.RCODE.BADNAME, 20); + assert.equal(Packet.RCODE.BADALG, 21); + assert.equal(Packet.RCODE.BADTRUNC, 22); + assert.equal(Packet.RCODE.BADCOOKIE, 23); }); test('Packet.RCODE is preserved through encode/parse round-trip', function () { @@ -895,6 +912,9 @@ test('Packet.RCODE is preserved through encode/parse round-trip', function () { pkt.header.id = 0x1234; pkt.header.qr = 1; pkt.header.rcode = code; + // RFC 6891 §6.1.3: the header holds only 4 bits of RCODE. Anything above + // 15 needs an OPT record to carry its high byte. + if (code > 0xf) pkt.additionals.push(Packet.Resource.EDNS([])); const parsed = Packet.parse(pkt.toBuffer()); assert.equal( parsed.header.rcode, @@ -904,6 +924,16 @@ test('Packet.RCODE is preserved through encode/parse round-trip', function () { } }); +test('extended RCODE without an OPT loses its high byte (RFC 6891 §6.1.3)', function () { + // There is nowhere for the high byte to go, so BADVERS (16) ships as the low + // nibble alone — 0, i.e. NOERROR. This is why + // Packet.createErrorResponseFromRequest attaches an OPT for any rcode > 15. + const pkt = new Packet(); + pkt.header.qr = 1; + pkt.header.rcode = Packet.RCODE.BADVERS; + assert.equal(Packet.parse(pkt.toBuffer()).header.rcode, 0); +}); + test('Resource encode round-trips unknown type via raw data fallback', function () { // the encoder must write RDLENGTH+RDATA for types it doesn't know how // to serialize, else the wire format is truncated. @@ -1365,3 +1395,762 @@ test('Packet.parse tolerates multiple questions', function () { assert.equal(parsed.questions[1].name, 'two.test'); assert.equal(parsed.questions[1].type, Packet.TYPE.AAAA); }); + +// ── Decode failure reporting ──────────────────────────────────────────────── + +test('Packet.parse rejects a message too short to hold a header', function () { + assert.throws( + () => Packet.parse(Buffer.from('INVALID')), + err => + err instanceof Packet.DecodeError && + /7 octets, too short for the 12-octet header/.test(err.message), + ); +}); + +test('Packet.parse rejects a non-Buffer argument', function () { + assert.throws( + () => Packet.parse('not a buffer'), + /expected a Buffer, got string/, + ); + assert.throws(() => Packet.parse(null), /expected a Buffer, got null/); +}); + +test('Packet.parse reports no errors for a well-formed message', function () { + const parsed = Packet.parse(response); + assert.deepEqual(parsed.errors, []); +}); + +test('Packet.parse records why a record was dropped', function () { + // answer 1 is a TXT whose character-string overruns its RDLENGTH; answer 2 + // is a well-formed A record that must still decode. + const pkt = Buffer.from([ + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x74, 0x00, 0x00, 0x10, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00, + 0x05, 0x0a, 0x61, 0x62, 0x63, 0x64, 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); + assert.equal(parsed.errors.length, 1); + const [error] = parsed.errors; + assert.ok(error instanceof Packet.DecodeError); + assert.equal(error.section, 'answers'); + assert.equal(error.index, 0); + assert.equal(error.offset, 12, 'the record began 12 octets in'); + assert.equal(error.recovered, true, 'RDLENGTH let decoding resume'); + assert.match(error.message, /answers\[0\] at offset 12: TXT decode/); + assert.match(error.message, /overruns RDATA/); + // header counts still describe the wire, so a caller can see 1 of 2 survived + assert.equal(parsed.header.ancount, 2); +}); + +test('Packet.parse stops and reports when the reader is left misaligned', function () { + // ancount claims 2 answers but the message ends mid-way through the first + // record's name: nothing after it can be located, so decoding must stop. + const pkt = Buffer.from([ + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x05, + 0x70, + 0x61, + 0x72, // a 5-octet label with only 3 octets present + ]); + const parsed = Packet.parse(pkt); + assert.equal(parsed.answers.length, 0); + assert.equal( + parsed.errors.length, + 1, + 'one error, not one per phantom record', + ); + assert.equal(parsed.errors[0].recovered, false); + assert.match(parsed.errors[0].message, /read past end of message/); +}); + +test('Packet.parse reports an RDLENGTH that overruns the message', function () { + const pkt = Buffer.from([ + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x61, + 0x00, + 0x00, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x3c, + 0xff, + 0xff, + 0xc0, + 0x00, + 0x02, + 0x07, // RDLENGTH 65535, 4 octets present + ]); + const parsed = Packet.parse(pkt); + assert.equal(parsed.answers.length, 0); + assert.match( + parsed.errors[0].message, + /A record "a" declares RDLENGTH 65535 but only 4 octet\(s\) remain/, + ); +}); + +test('Packet.parse reports rdata that does not fill its RDLENGTH', function () { + // An MX record whose exchange name ends 3 octets short of RDLENGTH. The name + // decoder cannot notice, so Resource.parse compares against RDLENGTH. + const pkt = Buffer.from([ + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x61, + 0x00, + 0x00, + 0x0f, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x3c, + 0x00, + 0x09, // RDLENGTH 9 + 0x00, + 0x0a, + 0x01, + 0x62, + 0x00, // priority + name "b" = 5 octets + 0x00, + 0x00, + 0x00, + 0x00, // 4 stray octets + ]); + const parsed = Packet.parse(pkt); + assert.equal(parsed.answers.length, 0); + assert.match( + parsed.errors[0].message, + /MX record "a" rdata consumed 5 octet\(s\), RDLENGTH declares 9/, + ); + assert.equal(parsed.errors[0].recovered, true); +}); + +test('Resource#A decode rejects an RDLENGTH other than 4', function () { + assert.throws( + () => + Packet.Resource.A.decode.call({}, new Packet.Reader(Buffer.alloc(6)), 6), + /A decode: RDLENGTH is 6, expected 4/, + ); +}); + +test('Resource#AAAA decode rejects a short or odd RDLENGTH', function () { + // A `length -= 2` countdown from an odd value would step past zero and read + // into whatever follows. + assert.throws( + () => + Packet.Resource.AAAA.decode.call( + {}, + new Packet.Reader(Buffer.alloc(9)), + 9, + ), + /AAAA decode: RDLENGTH is 9, expected 16/, + ); +}); + +test('Resource#CAA decode rejects a tag length past the rdata', function () { + const reader = new Packet.Reader(Buffer.from([0x00, 0x40, 0x61, 0x62])); + assert.throws( + () => Packet.Resource.CAA.decode.call({}, reader, 4), + /tag length 64 overruns RDATA \(2 octets remaining\)/, + ); +}); + +test('Resource#A encode rejects an address that is not IPv4', function () { + assert.throws( + () => + Packet.Resource.A.encode({ address: 'not-an-ip' }, new Packet.Writer()), + /A encode: invalid IPv4 address "not-an-ip"/, + ); +}); + +test('Resource#AAAA encode rejects an address that is not IPv6', function () { + assert.throws( + () => + Packet.Resource.AAAA.encode( + { address: '192.0.2.1' }, + new Packet.Writer(), + ), + /AAAA encode: invalid IPv6 address "192.0.2.1"/, + ); +}); + +test('EDNS#decode skips an unknown option by octets, not bits', function () { + // Unknown option 0x1234 (4 octets) followed by an ECS option. If the skip + // advanced by bits, the ECS option that follows would be misread. + const rdata = Buffer.from([ + 0x12, + 0x34, + 0x00, + 0x04, + 0xde, + 0xad, + 0xbe, + 0xef, // unknown option + 0x00, + 0x08, + 0x00, + 0x07, + 0x00, + 0x01, + 0x18, + 0x00, + 0x0a, + 0x0b, + 0x0c, // ECS + ]); + const record = Packet.Resource.EDNS.decode.call( + { ttl: 0 }, + new Packet.Reader(rdata), + rdata.length, + ); + assert.equal(record.rdata.length, 1, 'only the ECS option is understood'); + assert.equal(record.rdata[0].ednsCode, Packet.EDNS_OPTION_CODE.ECS); + assert.equal(record.rdata[0].sourcePrefixLength, 24); + assert.equal(record.rdata[0].ip, '10.11.12.0'); +}); + +test('EDNS#decode rejects an option length past the end of rdata', function () { + const rdata = Buffer.from([0x00, 0x08, 0x00, 0x20, 0x00, 0x01]); + assert.throws( + () => + Packet.Resource.EDNS.decode.call( + { ttl: 0 }, + new Packet.Reader(rdata), + rdata.length, + ), + /option 8 declares 32 octet\(s\) but only 2 remain/, + ); +}); + +test('Packet.typeName names known and unknown types (RFC 3597 §5)', function () { + assert.equal(Packet.typeName(Packet.TYPE.AAAA), 'AAAA'); + assert.equal(Packet.typeName(43), 'TYPE43'); +}); + +test('Reader#read past the end of the message says so', function () { + const reader = new Packet.Reader(Buffer.from([0x01, 0x02])); + assert.throws( + () => reader.read(32), + err => + err instanceof RangeError && + /wanted 32 bits at bit offset 0, message is 2 octets/.test(err.message), + ); +}); + +test('Resource#RRSIG decodes UTC timestamps and re-encodes verbatim', function () { + // inception 2024-01-02T03:04:05Z, expiration one day later. The date fields + // must all come from UTC accessors — a local-time year would be wrong for + // signatures near a year boundary. + const inception = Math.floor(Date.UTC(2024, 0, 2, 3, 4, 5) / 1000); + const expiration = inception + 86400; + const writer = new Packet.Writer(); + writer.write(Packet.TYPE.A, 16); + writer.write(8, 8); // algorithm + writer.write(2, 8); // labels + writer.write(3600, 32); // original TTL + writer.write(expiration, 32); + writer.write(inception, 32); + writer.write(0x4d2, 16); // key tag + Packet.Name.encode('example.com', writer); + for (const byte of Buffer.from('signature-bytes')) writer.write(byte, 8); + const rdata = writer.toBuffer(); + + const packet = new Packet(); + packet.header.qr = 1; + packet.answers.push({ + name: 'example.com', + type: Packet.TYPE.RRSIG, + class: Packet.CLASS.IN, + ttl: 3600, + data: rdata, + }); + const parsed = Packet.parse(packet.toBuffer()); + assert.deepEqual(parsed.errors, []); + const [rrsig] = parsed.answers; + assert.equal(rrsig.sigType, Packet.TYPE.A); + assert.equal(rrsig.algorithm, 8); + assert.equal(rrsig.keyTag, 0x4d2); + assert.equal(rrsig.signer, 'example.com'); + assert.equal(rrsig.inception, '20240102030405'); + assert.equal(rrsig.expiration, '20240103030405'); + assert.equal( + Buffer.from(rrsig.signature, 'base64').toString(), + 'signature-bytes', + ); + // Re-serializing must not strip the signature: there is no RRSIG encoder, so + // the raw rdata retained by the decoder is what makes the round trip work. + const reparsed = Packet.parse(parsed.toBuffer()); + assert.deepEqual(reparsed.errors, []); + assert.deepEqual(reparsed.answers[0].data, rdata); + assert.equal(reparsed.answers[0].signature, rrsig.signature); +}); + +test('Packet.readStream returns only the declared message', async function () { + const stream = new PassThrough(); + const message = Buffer.from([0x00, 0x01, 0x02, 0x03]); + const framed = Buffer.concat([Buffer.from([0x00, message.length]), message]); + // A second pipelined message follows; it must not leak into the first read. + stream.end(Buffer.concat([framed, framed])); + assert.deepEqual(await Packet.readStream(stream), message); +}); + +test('Packet.readStream reports a stream that ends before the length prefix', async function () { + const stream = new PassThrough(); + stream.end(Buffer.from([0x00])); + await assert.rejects( + Packet.readStream(stream), + /closed after 1 octet\(s\), before the 2-octet message length prefix/, + ); +}); + +test('Packet.readStream reports a message cut short', async function () { + const stream = new PassThrough(); + stream.end(Buffer.from([0x00, 0x10, 0xaa, 0xbb])); + await assert.rejects( + Packet.readStream(stream), + /closed after 2 of 16 declared message octet\(s\)/, + ); +}); + +test('Question#encode rejects a non-numeric type', function () { + // Packet.TYPE.AAA is a typo for AAAA and evaluates to undefined; writing it + // would silently produce a valid-looking type 0 question. + const packet = new Packet(); + packet.questions.push({ + name: 'typo.test', + type: Packet.TYPE.AAA, + class: Packet.CLASS.IN, + }); + assert.throws( + () => packet.toBuffer(), + /Question encode "typo.test": type must be a 16-bit integer, got undefined/, + ); +}); + +test('Resource#encode rejects a missing class', function () { + const packet = new Packet(); + packet.header.qr = 1; + packet.answers.push({ + name: 'noclass.test', + type: Packet.TYPE.A, + ttl: 60, + address: '192.0.2.1', + }); + assert.throws( + () => packet.toBuffer(), + /Resource encode "noclass.test": class must be a 16-bit integer/, + ); +}); + +test('Question#encode reports a NaN type accurately', function () { + // JSON.stringify renders NaN and Infinity as "null"; the message must name + // the value that was actually passed. + const packet = new Packet(); + packet.questions.push({ + name: 'nan.test', + type: parseInt('nonsense', 10), + class: Packet.CLASS.IN, + }); + assert.throws( + () => packet.toBuffer(), + /type must be a 16-bit integer, got NaN/, + ); +}); + +test('Question#encode distinguishes a string code from a number', function () { + const packet = new Packet(); + packet.questions.push({ + name: 'str.test', + type: '1', + class: Packet.CLASS.IN, + }); + assert.throws( + () => packet.toBuffer(), + /type must be a 16-bit integer, got '1'/, + ); +}); + +test( + 'Packet.readStream leaves pipelined bytes for the next reader', + async function () { + // Two length-prefixed messages arrive together. Reading the first must not + // swallow the second: the earlier implementation drained everything into a + // closure that had already resolved, so the second read saw nothing. + const stream = new PassThrough(); + const frame = body => + Buffer.concat([Buffer.from([0x00, body.length]), body]); + const first = Buffer.from([0x11, 0x22, 0x33]); + const second = Buffer.from([0xaa, 0xbb]); + stream.write(Buffer.concat([frame(first), frame(second)])); + + assert.deepEqual(await Packet.readStream(stream), first); + // The pre-fix failure is a hang, not a wrong value: the second message's + // octets had been drained into the first (already settled) call, so nothing + // would ever satisfy this read. The short per-test timeout reports it. + assert.deepEqual(await Packet.readStream(stream), second); + }, + { timeout: 1000 }, +); + +test('Packet.readStream releases its listeners once settled', async function () { + const stream = new PassThrough(); + const message = Buffer.from([0x01, 0x02]); + stream.write(Buffer.concat([Buffer.from([0x00, message.length]), message])); + await Packet.readStream(stream); + + for (const event of ['readable', 'end', 'error']) { + assert.equal( + stream.listenerCount(event), + 0, + `no ${event} listener should remain on the socket`, + ); + } +}); + +test('Packet.readStream does not accumulate bytes after settling', async function () { + const stream = new PassThrough(); + const message = Buffer.from([0x07]); + stream.write(Buffer.concat([Buffer.from([0x00, 0x01]), message])); + assert.deepEqual(await Packet.readStream(stream), message); + + // Traffic arriving after the promise settled stays in the stream rather than + // being pulled into the settled call's buffer. + stream.write(Buffer.from([0x00, 0x01, 0x63])); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(stream.read(), Buffer.from([0x00, 0x01, 0x63])); +}); + +// ── Extended DNS Errors (RFC 8914) ────────────────────────────────────────── + +test('EDNS.EDE round-trips through a full message', function () { + const packet = new Packet(); + packet.header.id = 0x5150; + packet.header.qr = 1; + packet.header.rcode = Packet.RCODE.SERVFAIL; + packet.additionals.push( + Packet.Resource.EDNS([ + Packet.Resource.EDNS.EDE(Packet.EDE.INVALID_DATA, 'rdata was nonsense'), + ]), + ); + const parsed = Packet.parse(packet.toBuffer()); + assert.deepEqual(parsed.errors, []); + const opt = parsed.additionals.find(r => r.type === Packet.TYPE.EDNS); + const [ede] = opt.rdata; + assert.equal(ede.ednsCode, Packet.EDNS_OPTION_CODE.EDE); + assert.equal(ede.infoCode, 24); + assert.equal(ede.extraText, 'rdata was nonsense'); + assert.equal(parsed.header.rcode, Packet.RCODE.SERVFAIL); +}); + +test('EDNS.EDE carries an empty EXTRA-TEXT (RFC 8914 §2)', function () { + const packet = new Packet(); + packet.header.qr = 1; + packet.additionals.push( + Packet.Resource.EDNS([Packet.Resource.EDNS.EDE(Packet.EDE.BLOCKED)]), + ); + const parsed = Packet.parse(packet.toBuffer()); + const [ede] = parsed.additionals[0].rdata; + assert.equal(ede.infoCode, Packet.EDE.BLOCKED); + assert.equal(ede.extraText, ''); +}); + +test('EDNS.EDE alongside ECS in one OPT record', function () { + const packet = new Packet(); + packet.header.qr = 1; + packet.additionals.push( + Packet.Resource.EDNS([ + Packet.Resource.EDNS.ECS('10.20.0.0/16'), + Packet.Resource.EDNS.EDE(Packet.EDE.CENSORED, 'nope'), + ]), + ); + const parsed = Packet.parse(packet.toBuffer()); + const { rdata } = parsed.additionals[0]; + assert.equal(rdata.length, 2); + assert.equal(rdata[0].ip, '10.20.0.0'); + assert.equal(rdata[1].infoCode, Packet.EDE.CENSORED); + assert.equal(rdata[1].extraText, 'nope'); +}); + +test('EDNS.EDE decode preserves multi-byte UTF-8 text', function () { + const text = 'signature expiré — 서명'; + const packet = new Packet(); + packet.header.qr = 1; + packet.additionals.push( + Packet.Resource.EDNS([ + Packet.Resource.EDNS.EDE(Packet.EDE.SIGNATURE_EXPIRED, text), + ]), + ); + const parsed = Packet.parse(packet.toBuffer()); + assert.equal(parsed.additionals[0].rdata[0].extraText, text); +}); + +test('EDNS.EDE decode rejects an option shorter than its INFO-CODE', function () { + assert.throws( + () => + Packet.Resource.EDNS.EDE.decode( + new Packet.Reader(Buffer.from([0x00])), + 1, + ), + /EDNS.EDE decode: option is 1 octet\(s\), expected at least 2/, + ); +}); + +test('EDNS.EDE decode strips a trailing NUL some senders add', function () { + // RFC 8914 §3 — EXTRA-TEXT must not be assumed null-terminated. + const reader = new Packet.Reader(Buffer.from([0x00, 0x18, 0x68, 0x69, 0x00])); + const ede = Packet.Resource.EDNS.EDE.decode(reader, 5); + assert.equal(ede.infoCode, 24); + assert.equal(ede.extraText, 'hi'); +}); + +test('EDNS.EDE decodes an INFO-CODE this library has no name for', function () { + const reader = new Packet.Reader(Buffer.from([0xc0, 0x00])); + const ede = Packet.Resource.EDNS.EDE.decode(reader, 2); + assert.equal(ede.infoCode, 49152, 'private-use range decodes as a number'); + assert.equal(Packet.EDE_NAME[ede.infoCode], undefined); +}); + +test('Packet.EDE_NAME names a received INFO-CODE', function () { + assert.equal(Packet.EDE_NAME[24], 'INVALID_DATA'); + assert.equal(Packet.EDE_NAME[0], 'OTHER'); +}); + +// ── createErrorResponseFromRequest ────────────────────────────────────────── + +// A request that decoded except for one malformed additional record. +function requestWithDecodeError({ edns = true } = {}) { + const query = new Packet(); + query.header.id = 0x3131; + query.questions.push({ + name: 'broken.test', + type: Packet.TYPE.A, + class: Packet.CLASS.IN, + }); + if (edns) query.additionals.push(Packet.Resource.EDNS([])); + const buffer = query.toBuffer(); + // Append an AAAA record claiming 4 octets of rdata but supplying none. + const truncated = Buffer.from([ + 0x00, 0x00, 0x1c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, + ]); + const malformed = Buffer.concat([buffer, truncated]); + malformed.writeUInt16BE(query.additionals.length + 1, 10); // arcount + const parsed = Packet.parse(malformed); + assert.ok(parsed.errors.length, 'fixture should produce a decode error'); + return parsed; +} + +test('createErrorResponseFromRequest reports the decode reason as EDE text', function () { + const request = requestWithDecodeError(); + const response = Packet.createErrorResponseFromRequest( + request, + Packet.RCODE.FORMERR, + { + infoCode: Packet.EDE.INVALID_DATA, + extraText: request.errors.map(e => e.message).join('; '), + }, + ); + const parsed = Packet.parse(response.toBuffer()); + assert.equal(parsed.header.id, 0x3131, 'answers the right transaction'); + assert.equal(parsed.header.qr, 1); + assert.equal(parsed.header.rcode, Packet.RCODE.FORMERR); + assert.deepEqual(parsed.questions[0].name, 'broken.test'); + const opt = parsed.additionals.find(r => r.type === Packet.TYPE.EDNS); + const [ede] = opt.rdata; + assert.equal(ede.infoCode, Packet.EDE.INVALID_DATA); + assert.match(ede.extraText, /additionals\[1\]/); + assert.match(ede.extraText, /RDLENGTH 4 but only 0 octet\(s\)/); +}); + +test('createErrorResponseFromRequest omits EDE for a non-EDNS request', function () { + // RFC 8914 §3 — extended errors belong only in responses to EDNS requests. + const request = requestWithDecodeError({ edns: false }); + const response = Packet.createErrorResponseFromRequest( + request, + Packet.RCODE.FORMERR, + { infoCode: Packet.EDE.INVALID_DATA, extraText: 'ignored' }, + ); + const parsed = Packet.parse(response.toBuffer()); + assert.equal(parsed.header.rcode, Packet.RCODE.FORMERR); + assert.equal(parsed.additionals.length, 0, 'no OPT is invented'); +}); + +test('createErrorResponseFromRequest echoes an OPT even with no EDE', function () { + const request = requestWithDecodeError(); + const response = Packet.createErrorResponseFromRequest( + request, + Packet.RCODE.REFUSED, + ); + const parsed = Packet.parse(response.toBuffer()); + assert.equal(parsed.header.rcode, Packet.RCODE.REFUSED); + const opt = parsed.additionals.find(r => r.type === Packet.TYPE.EDNS); + assert.ok(opt, 'an EDNS request gets an OPT back (RFC 6891 §6.1.1)'); + assert.deepEqual(opt.rdata, []); +}); + +test('createErrorResponseFromRequest attaches an OPT so BADVERS survives', function () { + // Even for a request with no OPT: rcode 16 has nowhere else to put its high + // byte, and would otherwise go out as NOERROR. + const request = requestWithDecodeError({ edns: false }); + const response = Packet.createErrorResponseFromRequest( + request, + Packet.RCODE.BADVERS, + ); + const parsed = Packet.parse(response.toBuffer()); + assert.equal(parsed.header.rcode, Packet.RCODE.BADVERS); +}); + +test('createErrorResponseFromRequest caps EXTRA-TEXT length', function () { + const request = requestWithDecodeError(); + const response = Packet.createErrorResponseFromRequest( + request, + Packet.RCODE.SERVFAIL, + { infoCode: Packet.EDE.OTHER, extraText: 'x'.repeat(5000) }, + ); + const parsed = Packet.parse(response.toBuffer()); + const [ede] = parsed.additionals[0].rdata; + assert.equal(ede.extraText.length, Packet.EDE_MAX_TEXT); +}); + +test( + 'Packet.readStream reports an already-ended stream instead of hanging', + async function () { + // 'end' fires before readStream attaches, so its own listener can never run. + // Without the readableEnded check nothing settles the promise. + const stream = new PassThrough(); + stream.end(Buffer.from([0x00, 0x01, 0x07])); + stream.resume(); + await new Promise(resolve => stream.on('end', resolve)); + assert.equal(stream.readableEnded, true, 'fixture precondition'); + + await assert.rejects( + Packet.readStream(stream), + /closed after 0 octet\(s\), before the 2-octet message length prefix/, + ); + }, + { timeout: 1000 }, +); + +test( + 'Packet.readStream does not unshift into an ended stream', + async function () { + // A real socket empties its read buffer before emitting 'end', so it cannot + // present leftover bytes and readableEnded at once. This pairs a live + // stream with readableEnded forced on — the shape the guard exists for, + // where unshift would throw ERR_STREAM_UNSHIFT_AFTER_END_EVENT. + const stream = new PassThrough(); + const frame = body => + Buffer.concat([Buffer.from([0x00, body.length]), body]); + const first = Buffer.from([0x11, 0x22]); + stream.write(Buffer.concat([frame(first), frame(Buffer.from([0xaa]))])); + Object.defineProperty(stream, 'readableEnded', { value: true }); + let unshifted = false; + const realUnshift = stream.unshift.bind(stream); + stream.unshift = chunk => { + unshifted = true; + return realUnshift(chunk); + }; + + assert.deepEqual(await Packet.readStream(stream), first); + assert.equal(unshifted, false, 'must not unshift into an ended stream'); + }, + { timeout: 1000 }, +); + +test('EDNS encode rejects an option that is not octet-aligned', function () { + // The option length is 16 bits of octets. An encoder that stopped mid-octet + // would floor it — declaring 0 octets while shipping one — misaligning every + // option after it. Registered temporarily, since this needs a custom codec. + const CODE = 0x99; + Packet.EDNS_OPTION_NAME[CODE] = 'ODDBITS'; + Packet.Resource.EDNS.ODDBITS = { encode: (record, w) => w.write(0b101, 3) }; + try { + const packet = new Packet(); + packet.header.qr = 1; + packet.additionals.push(Packet.Resource.EDNS([{ ednsCode: CODE }])); + assert.throws( + () => packet.toBuffer(), + /EDNS option 153 encoder wrote 3 bits, not a whole number of octets/, + ); + } finally { + delete Packet.EDNS_OPTION_NAME[CODE]; + delete Packet.Resource.EDNS.ODDBITS; + } +}); + +test('EDNS encode rejects an option too long for its length field', function () { + const CODE = 0x9a; + Packet.EDNS_OPTION_NAME[CODE] = 'HUGE'; + Packet.Resource.EDNS.HUGE = { + encode: (record, w) => { + for (let i = 0; i <= 0xffff; i++) w.write(0, 8); + }, + }; + try { + const packet = new Packet(); + packet.header.qr = 1; + packet.additionals.push(Packet.Resource.EDNS([{ ednsCode: CODE }])); + assert.throws( + () => packet.toBuffer(), + /EDNS option 154 is 65536 octets, too long for its 16-bit length field/, + ); + } finally { + delete Packet.EDNS_OPTION_NAME[CODE]; + delete Packet.Resource.EDNS.HUGE; + } +}); + +test('EDNS option length matches the octets actually written', function () { + // Guards the arithmetic itself: a 7-octet ECS option must declare 7. + const packet = new Packet(); + packet.header.qr = 1; + packet.additionals.push( + Packet.Resource.EDNS([Packet.Resource.EDNS.ECS('10.11.12.0/24')]), + ); + const buf = packet.toBuffer(); + // OPT rdata begins after header(12) + name(1) + type(2) + class(2) + ttl(4) + // + rdlength(2); the option header is code(2) then length(2). + const optionLengthAt = 12 + 1 + 2 + 2 + 4 + 2 + 2; + const declared = buf.readUInt16BE(optionLengthAt); + const rdlength = buf.readUInt16BE(12 + 1 + 2 + 2 + 4); + assert.equal(declared, 7, 'family(2) + prefixes(2) + 3 address octets'); + assert.equal(rdlength, declared + 4, 'rdata is the option header plus body'); +}); diff --git a/test/server.js b/test/server.js index 1a8257d..8b1ead3 100644 --- a/test/server.js +++ b/test/server.js @@ -1086,10 +1086,9 @@ test('server/udp#proxyProtocol exposes real client address (v2 IPv4)', async () test('server/udp#proxyProtocol with missing header emits requestError', async () => { const server = createUDPServer({ proxyProtocol: true }); - let captured; - server.on('requestError', e => { - captured = e; - }); + const requestError = new Promise(resolve => + server.once('requestError', resolve), + ); await server.listen(0, '127.0.0.1'); const { port: serverPort } = server.address(); @@ -1105,11 +1104,9 @@ test('server/udp#proxyProtocol with missing header emits requestError', async () await new Promise(resolve => sender.send(query.toBuffer(), serverPort, '127.0.0.1', resolve), ); - // Give the server a moment to handle the datagram. - await new Promise(resolve => setTimeout(resolve, 20)); + const captured = await requestError; await new Promise(resolve => sender.close(resolve)); - assert.ok(captured, 'expected requestError to fire'); assert.match(captured.message, /PROXY/); await new Promise(resolve => server.close(resolve)); }); @@ -1220,10 +1217,9 @@ test('server/tcp#proxyProtocol v2 exposes real client address', async () => { test('server/tcp#proxyProtocol with garbage prefix emits requestError', async () => { const server = createTCPServer({ proxyProtocol: true }); - let captured; - server.on('requestError', e => { - captured = e; - }); + const requestError = new Promise(resolve => + server.once('requestError', resolve), + ); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const { port: serverPort } = server.address(); @@ -1234,10 +1230,8 @@ test('server/tcp#proxyProtocol with garbage prefix emits requestError', async () sock.on('close', resolve); sock.on('error', reject); }); - // Give the server an event-loop tick to surface the error. - await new Promise(resolve => setTimeout(resolve, 20)); + const captured = await requestError; - assert.ok(captured, 'expected requestError to fire'); assert.match(captured.message, /PROXY/); await new Promise(resolve => server.close(resolve)); }); @@ -1286,3 +1280,152 @@ test('server/udp/tcp without proxyProtocol still work normally', async () => { assert.equal(tcpReply.answers[0].address, '10.0.0.2'); await new Promise(resolve => tcpServer.close(resolve)); }); + +test('server#requestError explains why a query could not be decoded', async () => { + const server = createServer({ udp: true, tcp: true, handle: () => {} }); + const servers = await server.listen(); + const errors = []; + server.on('requestError', e => errors.push(e)); + // Await the event rather than sleeping a fixed interval, which a loaded + // machine can outrun. If it never arrives the per-test timeout says so. + const firstError = new Promise(resolve => + server.once('requestError', resolve), + ); + + // 8 octets: enough to look like traffic, too few to hold a DNS header. + const socket = udp.createSocket('udp4'); + await new Promise(resolve => + socket.send(Buffer.alloc(8), servers.udp.port, '127.0.0.1', () => + socket.close(resolve), + ), + ); + + const error = await firstError; + assert.ok(error instanceof Packet.DecodeError); + assert.match(error.message, /8 octets, too short for the 12-octet header/); + assert.equal(errors.length, 1, 'one datagram produced one error'); + + await server.close(); +}); + +test('server#handler sees a partially decoded request and its errors', async () => { + // A query whose question is fine but whose additional record is malformed: + // the handler still gets the question, plus the reason the OPT was dropped. + const requests = []; + const server = createUDPServer((request, send) => { + requests.push(request); + send(Packet.createResponseFromRequest(request)); + }); + await server.listen(0); + const { port } = server.address(); + + const query = new Packet(); + query.header.id = 0x4242; + query.questions.push({ + name: 'partial.test', + type: Packet.TYPE.A, + class: Packet.CLASS.IN, + }); + const buffer = query.toBuffer(); + // Append an additional record with a 4-octet RDLENGTH but no rdata, and bump + // arcount to 1. + const truncatedOpt = Buffer.from([ + 0x00, 0x00, 0x1c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, + ]); + const malformed = Buffer.concat([buffer, truncatedOpt]); + malformed.writeUInt16BE(1, 10); // arcount + + const socket = udp.createSocket('udp4'); + const reply = new Promise(resolve => + socket.on('message', msg => resolve(Packet.parse(msg))), + ); + socket.send(malformed, port, '127.0.0.1'); + await reply; + socket.close(); + + assert.equal(requests.length, 1); + const [request] = requests; + assert.equal(request.questions[0].name, 'partial.test'); + assert.equal( + request.additionals.length, + 0, + 'the bad record is not delivered', + ); + assert.equal(request.errors.length, 1); + assert.equal(request.errors[0].section, 'additionals'); + assert.match(request.errors[0].message, /RDLENGTH 4 but only 0 octet\(s\)/); + + await new Promise(resolve => server.close(resolve)); +}); + +test('client/tcp#reports a reply that is cut short', async () => { + // A server that frames a 40-octet reply but sends only 4 of them, then + // closes. The client must say the message was truncated rather than fail + // somewhere inside the decoder. + const rude = tcp.createServer(socket => { + socket.on('data', () => socket.end(Buffer.from([0x00, 0x28, 0x01, 0x02]))); + }); + await new Promise(resolve => rude.listen(0, '127.0.0.1', resolve)); + const { port } = rude.address(); + + const resolve4 = TCPClient({ dns: '127.0.0.1', port }); + await assert.rejects( + resolve4('example.com'), + /closed after 2 of 40 declared message octet\(s\)/, + ); + await new Promise(done => rude.close(done)); +}); + +test('server#answers a malformed query with FORMERR and an EDE reason', async () => { + // End-to-end: a query whose additional record is malformed comes back as + // FORMERR carrying an RFC 8914 Extended DNS Error naming the reason. + const server = createUDPServer((request, send) => { + if (request.errors.length) { + return send( + Packet.createErrorResponseFromRequest(request, Packet.RCODE.FORMERR, { + infoCode: Packet.EDE.INVALID_DATA, + extraText: request.errors.map(e => e.message).join('; '), + }), + ); + } + send(Packet.createResponseFromRequest(request)); + }); + await server.listen(0, '127.0.0.1'); + const { port } = server.address(); + + const query = new Packet(); + query.header.id = 0x7f7f; + query.questions.push({ + name: 'formerr.test', + type: Packet.TYPE.A, + class: Packet.CLASS.IN, + }); + query.additionals.push(Packet.Resource.EDNS([])); + // Append an AAAA record declaring 4 octets of rdata but carrying none. + const malformed = Buffer.concat([ + query.toBuffer(), + Buffer.from([ + 0x00, 0x00, 0x1c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, + ]), + ]); + malformed.writeUInt16BE(2, 10); // arcount: the OPT plus the broken record + + const socket = udp.createSocket('udp4'); + const reply = new Promise(resolve => + socket.on('message', msg => resolve(Packet.parse(msg))), + ); + socket.send(malformed, port, '127.0.0.1'); + const response = await reply; + socket.close(); + + assert.equal(response.header.id, 0x7f7f); + assert.equal(response.header.rcode, Packet.RCODE.FORMERR); + const opt = response.additionals.find(r => r.type === Packet.TYPE.EDNS); + assert.ok(opt, 'response carries an OPT'); + const [ede] = opt.rdata; + assert.equal(ede.ednsCode, Packet.EDNS_OPTION_CODE.EDE); + assert.equal(Packet.EDE_NAME[ede.infoCode], 'INVALID_DATA'); + assert.match(ede.extraText, /RDLENGTH 4 but only 0 octet\(s\) remain/); + + await new Promise(resolve => server.close(resolve)); +}); diff --git a/test/test.js b/test/test.js index eb7ad7f..aa6d907 100644 --- a/test/test.js +++ b/test/test.js @@ -1,26 +1,60 @@ const { inspect } = require('util'); +// A test that never settles used to stall the chain silently: with nothing left +// in the event loop the process exits 0, `node --test` scores the file as a +// pass, and every test after the stalled one is never run. Two guards: each +// test races a timer, and exiting with tests still pending fails the file. +const DEFAULT_TIMEOUT = Number(process.env.TEST_TIMEOUT) || 10000; + let previous = Promise.resolve(); +let pending = 0; + +const withTimeout = (fn, title, ms) => { + let timer; + return Promise.race([ + // .then(fn) so a synchronous throw is captured as a rejection too. + Promise.resolve().then(fn), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out after ${ms}ms — did not settle`)), + ms, + ); + }), + // A cleared timer must not keep the loop alive after a test passes. + ]).finally(() => clearTimeout(timer)); +}; /** * super tiny testing framework * * @author Liu song - * @github https://github.com/song940 + * @github https://github.com/song940/node-dns */ -const test = (title, fn) => { +const test = (title, fn, { timeout = DEFAULT_TIMEOUT } = {}) => { + pending++; previous = previous.then(async () => { try { - await fn(); + await withTimeout(fn, title, timeout); + pending--; console.log(color(` ✔ ${title}`, 32)); - } catch (err) { + } catch (thrown) { + // A test may throw a primitive. Normalize first: `in` on a non-object + // throws a TypeError of its own, masking the failure being reported. + const err = + thrown instanceof Error + ? thrown + : Object.assign(new Error(inspect(thrown)), { name: 'ThrownValue' }); console.error(color(` ✘ ${title}`, 31)); console.log(); console.log(color(` ${err.name}: ${err.message}`, 31)); - console.error(color(` expected: ${inspect(err.expected)}`, 32)); - console.error(color(` actual: ${inspect(err.actual)}`, 31)); + if ('expected' in err || 'actual' in err) { + console.error(color(` expected: ${inspect(err.expected)}`, 32)); + console.error(color(` actual: ${inspect(err.actual)}`, 31)); + } console.log(err.stack); console.log(); + // The failure is already reported; don't also claim tests went missing. + pending = 0; process.exit(1); } }); @@ -38,4 +72,14 @@ test.skip = (title, _fn) => { return previous; }; +// Backstop for anything the per-test timer cannot catch — a stray process.exit, +// or the event loop draining between tests. +process.on('exit', () => { + if (pending === 0) return; + console.error( + color(` ✘ ${pending} test(s) did not run to completion`, 31), + ); + if (!process.exitCode) process.exitCode = 1; +}); + module.exports = test; diff --git a/ts/index.d.mts b/ts/index.d.mts index 3380a87..fd7bd26 100644 --- a/ts/index.d.mts +++ b/ts/index.d.mts @@ -51,3 +51,7 @@ export type Question = DNS.Packet.Question; export type Resource = DNS.Packet.Resource; export type Reader = DNS.Packet.Reader; export type Writer = DNS.Packet.Writer; +export type DecodeError = DNS.Packet.DecodeError; +export type EdnsOption = DNS.Packet.EdnsOption; +export type EcsOption = DNS.Packet.EcsOption; +export type EdeOption = DNS.Packet.EdeOption; diff --git a/ts/index.d.ts b/ts/index.d.ts index 323c97f..7c20cdd 100644 --- a/ts/index.d.ts +++ b/ts/index.d.ts @@ -50,6 +50,11 @@ declare namespace DNS { authorities: Packet.Resource[]; additionals: Packet.Resource[]; recursive: boolean; + /** + * One entry per record Packet.parse could not decode; empty for messages + * built in memory or decoded cleanly. + */ + errors: Packet.DecodeError[]; constructor( data?: Packet | Packet.Header | Packet.Question | Packet.Resource | string | any[], @@ -80,6 +85,7 @@ declare namespace DNS { AAAA : 0x1c; SRV : 0x21; EDNS : 0x29; + RRSIG : 0x2e; SPF : 0x63; AXFR : 0xfc; MAILB : 0xfd; @@ -97,14 +103,125 @@ declare namespace DNS { ANY : 0xff; }; + /** + * DNS response codes. Codes above 15 are only transmissible in a message + * carrying an OPT record — their high byte rides in the OPT TTL + * (RFC 6891 §6.1.3). + */ + static RCODE: { + NOERROR : 0; + FORMERR : 1; + SERVFAIL : 2; + NXDOMAIN : 3; + NOTIMP : 4; + REFUSED : 5; + YXDOMAIN : 6; + YXRRSET : 7; + NXRRSET : 8; + NOTAUTH : 9; + NOTZONE : 10; + DSOTYPENI : 11; + /** Unsupported EDNS version (RFC 6891); shares code 16 with BADSIG. */ + BADVERS : 16; + /** TSIG signature failure (RFC 8945); shares code 16 with BADVERS. */ + BADSIG : 16; + BADKEY : 17; + BADTIME : 18; + BADMODE : 19; + BADNAME : 20; + BADALG : 21; + BADTRUNC : 22; + BADCOOKIE : 23; + }; + static EDNS_OPTION_CODE: { ECS: 0x08; + /** Extended DNS Error (RFC 8914). */ + EDE: 0x0f; }; + /** + * Extended DNS Error INFO-CODEs (RFC 8914 §4). These explain a response; + * they do not replace its RCODE. + */ + static EDE: { + OTHER : 0; + UNSUPPORTED_DNSKEY_ALGORITHM : 1; + UNSUPPORTED_DS_DIGEST_TYPE : 2; + STALE_ANSWER : 3; + FORGED_ANSWER : 4; + DNSSEC_INDETERMINATE : 5; + DNSSEC_BOGUS : 6; + SIGNATURE_EXPIRED : 7; + SIGNATURE_NOT_YET_VALID : 8; + DNSKEY_MISSING : 9; + RRSIGS_MISSING : 10; + NO_ZONE_KEY_BIT_SET : 11; + NSEC_MISSING : 12; + CACHED_ERROR : 13; + NOT_READY : 14; + BLOCKED : 15; + CENSORED : 16; + FILTERED : 17; + PROHIBITED : 18; + STALE_NXDOMAIN_ANSWER : 19; + NOT_AUTHORITATIVE : 20; + NOT_SUPPORTED : 21; + NO_REACHABLE_AUTHORITY : 22; + NETWORK_ERROR : 23; + INVALID_DATA : 24; + SIGNATURE_EXPIRED_BEFORE_VALID : 25; + TOO_EARLY : 26; + UNSUPPORTED_NSEC3_ITERATIONS : 27; + UNABLE_TO_CONFORM_TO_POLICY : 28; + SYNTHESIZED : 29; + INVALID_QUERY_TYPE : 30; + RATE_LIMITED : 31; + OVER_QUOTA : 32; + NEGATIVE_TRUST_ANCHOR : 33; + NEW_DELEGATION_ONLY : 34; + }; + + /** Octets in a DNS message header. */ + static HEADER_SIZE: 12; + + /** Longest EXTRA-TEXT createErrorResponseFromRequest will emit. */ + static EDE_MAX_TEXT: number; + + /** Reverse of Packet.TYPE, keyed by type code. */ + static TYPE_NAME: Record; + + /** Reverse of Packet.EDNS_OPTION_CODE, keyed by option code. */ + static EDNS_OPTION_NAME: Record; + + /** Reverse of Packet.EDE, keyed by INFO-CODE. */ + static EDE_NAME: Record; + // ── Static helpers ────────────────────────────────────────────────────── + /** + * Decode a DNS message. Throws Packet.DecodeError when the message has no + * usable header; per-record failures are reported on the returned packet's + * `errors` array. + */ static parse(buffer: Buffer): Packet; + static typeName(code: number): string; + static DecodeError: { + new(message: string, context?: Partial): Packet.DecodeError; + prototype: Packet.DecodeError; + }; static createResponseFromRequest(request: Packet): Packet; + /** + * Build an error response, optionally explaining why with an RFC 8914 + * Extended DNS Error. The EDE option is attached only when the request + * carried an OPT record; an OPT is added regardless when `rcode` exceeds 15 + * so its high byte survives serialization. + */ + static createErrorResponseFromRequest( + request: Packet, + rcode: number, + ede?: { infoCode: number; extraText?: string }, + ): Packet; static createResourceFromQuestion( base: Packet.Question, record: Partial, @@ -142,7 +259,29 @@ declare namespace DNS { parse(reader: Buffer | Packet.Reader): Packet.Resource; decode(reader: Buffer | Packet.Reader): Packet.Resource; encode(resource: Packet.Resource, writer?: Packet.Writer): Buffer; - EDNS(rdata: object[]): Packet.Resource; + EDNS: { + ( + rdata: object[], + opts?: { + extendedRcode?: number; + version?: number; + doFlag?: boolean; + udpPayloadSize?: number; + }, + ): Packet.Resource; + /** EDNS Client Subnet, in CIDR notation, e.g. "1.2.3.4/24" (RFC 7871). */ + ECS: { + (clientIp: string): Packet.EcsOption; + decode(reader: Packet.Reader, length: number): Packet.EcsOption; + encode(record: Packet.EcsOption, writer: Packet.Writer): void; + }; + /** Extended DNS Error (RFC 8914). */ + EDE: { + (infoCode: number, extraText?: string): Packet.EdeOption; + decode(reader: Packet.Reader, length: number): Packet.EdeOption; + encode(record: Packet.EdeOption, writer: Packet.Writer): void; + }; + }; }; static Name: { @@ -194,15 +333,21 @@ declare namespace DNS { // CNAME / PTR / NS domain?: string; ns?: string; - // TXT / SPF - data?: string | string[]; + // TXT / SPF; also the preserved raw RDATA of a type with no encoder + data?: string | string[] | Buffer; + // EDNS / OPT — `class` doubles as the requestor's UDP payload size + rdata?: EdnsOption[]; + extendedRcode?: number; + version?: number; + doFlag?: boolean; // SOA primary?: string; admin?: string; serial?: number; refresh?: number; retry?: number; - expiration?: number; + /** Seconds for SOA; a YYYYMMDDHHmmSS display string for RRSIG. */ + expiration?: number | string; minimum?: number; // SRV weight?: number; @@ -216,18 +361,67 @@ declare namespace DNS { algorithm?: number; keyTag?: number; publicKey?: string; + protocol?: number; + zoneKey?: boolean; + zoneSep?: boolean; + key?: string; + // RRSIG (decode only) + sigType?: number; + labels?: number; + originalTtl?: number; + inception?: string; + signer?: string; + signature?: string; toBuffer(writer?: Writer): Buffer; } + /** A record, question, or message that could not be decoded. */ + interface DecodeError extends Error { + /** questions / answers / authorities / additionals */ + section?: string; + /** Position of the record within its section. */ + index?: number; + /** Octet offset in the message where the record started. */ + offset?: number; + /** Whether decoding resumed after this failure. */ + recovered: boolean; + } + + /** An EDNS option as carried in Packet.Resource['rdata']. */ + interface EdnsOption { + ednsCode: number; + } + + /** EDNS Client Subnet option (RFC 7871). */ + interface EcsOption extends EdnsOption { + family: number; + sourcePrefixLength: number; + scopePrefixLength: number; + ip?: string; + } + + /** Extended DNS Error option (RFC 8914). */ + interface EdeOption extends EdnsOption { + /** See Packet.EDE for the registered INFO-CODEs. */ + infoCode: number; + extraText: string; + } + interface Reader { + buffer: Buffer; offset: number; read(bits: number): number; + /** Bits left between the cursor and the end of the message. */ + remaining(): number; } interface Writer { buffer: number[]; write(value: number, bits: number): void; writeBuffer(writer: Writer): void; + bitLength(): number; + byteLength(): number; + patch(bitOffset: number, value: number, bits: number): void; toBuffer(): Buffer; } } @@ -297,7 +491,9 @@ declare namespace DNS { interface ClientOptions { port: number; + /** Reserved; not yet honoured by `resolve()`. */ retries: number; + /** Per-name-server query timeout in milliseconds. Default: `3000`. */ timeout: number; recursive: boolean; /** When using UDP and the TC (truncated) bit is set, automatically retry over TCP. Default: `true`. */ diff --git a/ts/tsconfig.json b/ts/tsconfig.json index ea9a023..e46eafb 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -1,12 +1,14 @@ { "compilerOptions": { "target": "ES2019", - "module": "commonjs", - "moduleResolution": "node", + // node16 resolves through the "require" condition of package.json + // "exports", which is how CommonJS consumers actually reach index.d.ts. + // The former node10 mode was removed in TypeScript 7. + "module": "node16", + "moduleResolution": "node16", "strict": true, "noEmit": true, "esModuleInterop": true, - "ignoreDeprecations": "6.0", "typeRoots": ["../node_modules/@types"] }, "include": ["typings-check.ts"] diff --git a/ts/typings-check.mts b/ts/typings-check.mts index b6cf3dd..d674533 100644 --- a/ts/typings-check.mts +++ b/ts/typings-check.mts @@ -34,6 +34,8 @@ import type { Header, Question, Resource, + DecodeError, + EdeOption, DnsHandler, DnsResolver, ServerAddresses, @@ -91,6 +93,16 @@ pkt.questions.push(new Packet.Question('esm.test', Packet.TYPE.A, Packet.CLASS.I const buf: Buffer = pkt.toBuffer(); const parsed: Packet = Packet.parse(buf); +const decodeErrors: DecodeError[] = parsed.errors; +const _ede: EdeOption = Packet.Resource.EDNS.EDE(Packet.EDE.INVALID_DATA, 'why'); +const _formErr: Packet = Packet.createErrorResponseFromRequest( + parsed, + Packet.RCODE.FORMERR, + { infoCode: _ede.infoCode, extraText: _ede.extraText }, +); +void _formErr.additionals.length; +void decodeErrors.map(e => `${e.section}: ${e.message} (recovered=${e.recovered})`); + const hdr: Header = parsed.header; const q: Question = parsed.questions[0]; const ans: Resource | undefined = parsed.answers[0]; diff --git a/ts/typings-check.ts b/ts/typings-check.ts index 79cd2cc..27d09fc 100644 --- a/ts/typings-check.ts +++ b/ts/typings-check.ts @@ -53,6 +53,60 @@ const parsed: DNS.Packet = Packet.parse(buf); const response: DNS.Packet = Packet.createResponseFromRequest(parsed); response.header.rcode = 3; // NXDOMAIN +// Decode failures are reported per record, not thrown, once the header parses. +const decodeErrors: DNS.Packet.DecodeError[] = parsed.errors; +for (const err of decodeErrors) { + const _section: string | undefined = err.section; + const _index: number | undefined = err.index; + const _offset: number | undefined = err.offset; + const _recovered: boolean = err.recovered; + const _reason: string = err.message; +} +const _isDecodeError: boolean = decodeErrors[0] instanceof Packet.DecodeError; +const _typeName: string = Packet.typeName(Packet.TYPE.RRSIG); +const _rcode: number = Packet.RCODE.FORMERR; +const _badvers: number = Packet.RCODE.BADVERS; +const _edeInvalidData: number = Packet.EDE.INVALID_DATA; +const _edeName: string | undefined = Packet.EDE_NAME[24]; +const _edeMaxText: number = Packet.EDE_MAX_TEXT; + +// Extended DNS Errors: build one, and read one back off a response. +const _formErr: DNS.Packet = Packet.createErrorResponseFromRequest( + parsed, + Packet.RCODE.FORMERR, + { + infoCode: Packet.EDE.INVALID_DATA, + extraText: parsed.errors.map(e => e.message).join('; '), + }, +); +const _refused: DNS.Packet = Packet.createErrorResponseFromRequest( + parsed, + Packet.RCODE.REFUSED, +); +const _optRecord: DNS.Packet.Resource | undefined = _formErr.additionals.find( + r => r.type === Packet.TYPE.EDNS, +); +for (const option of _optRecord?.rdata ?? []) { + const _code: number = option.ednsCode; + if (option.ednsCode === Packet.EDNS_OPTION_CODE.EDE) { + const ede = option as DNS.Packet.EdeOption; + const _info: number = ede.infoCode; + const _text: string = ede.extraText; + } +} +const _ede: DNS.Packet.EdeOption = Packet.Resource.EDNS.EDE( + Packet.EDE.BLOCKED, + 'why', +); +const _ecs: DNS.Packet.EcsOption = Packet.Resource.EDNS.ECS('1.2.3.4/24'); +const _opt: DNS.Packet.Resource = Packet.Resource.EDNS([_ede, _ecs], { + udpPayloadSize: 1232, + doFlag: true, +}); +const _headerSize: number = Packet.HEADER_SIZE; +const _typeOfCode: string | undefined = Packet.TYPE_NAME[1]; +const _ednsOptionName: string | undefined = Packet.EDNS_OPTION_NAME[8]; + const q: DNS.Packet.Question = parsed.questions[0]; if (q) { Packet.createResourceFromQuestion(q, { address: '1.2.3.4', ttl: 60 });