diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ac183..026ae10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +- feat(packet): encode name compression pointers (RFC 1035 §4.1.4) +- feat(server/udp): negotiated UDP payload size with TC=1 on oversize +- feat(server/tcp): pipeline support (RFC 7766 §6.2.1.1) +- feat(packet): EDNS extended RCODE supported +- fix(packet): EDNS default UDP payload size raised to 4096 +- fix(packet): clamps TTLs to 2³¹−1 +- fix(packet): Label and name length validation (RFC 1035 §2.3.4) +- fix(server/doh): accepts any (or absent) Accept header (RFC 8484 §4.1) +- fix(server/doh): DoH POST requires Content-Type: application/dns-message +- feat(server/doh): DoH responses include TTL-derived Cache-Control +- fix(packet): Packet.Header.toBuffer writes Z=0 (RFC 1035 §4.1.1) + ### [2.3.0] - 2026-05-25 - fix(packet): IPv6 `::` compression for leading-zero address #123 @@ -57,5 +69,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - fix(packet): ensure compressed IPv6 is valid #70 - doc(README): correct `server.listen` options +[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 diff --git a/lib/writer.js b/lib/writer.js index b7bfb25..66d0f32 100644 --- a/lib/writer.js +++ b/lib/writer.js @@ -25,6 +25,25 @@ BufferWriter.prototype.writeBuffer = function(b) { this.buffer = this.buffer.concat(b.buffer); }; +// Current write position, in bits. +BufferWriter.prototype.bitLength = function() { + return this.buffer.length; +}; + +// Current write position, in bytes. Defined when DNS encoding has stayed on +// byte boundaries (it always does at the points we expose this). +BufferWriter.prototype.byteLength = function() { + return this.buffer.length / 8; +}; + +// Overwrite `size` bits at `bitOffset` with `value`. Used to back-fill +// 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; + } +}; + /** * [toBuffer description] * @return {[type]} [description] diff --git a/packet.js b/packet.js index 3658086..90f7b25 100644 --- a/packet.js +++ b/packet.js @@ -201,6 +201,13 @@ Packet.parse = function(buffer) { } } }); + // 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. + const opt = packet.additionals.find(r => r && r.type === Packet.TYPE.EDNS); + if (opt && opt.extendedRcode) { + packet.header.rcode = (opt.extendedRcode << 4) | (packet.header.rcode & 0xF); + } return packet; }; @@ -224,11 +231,29 @@ Object.defineProperty(Packet.prototype, 'recursive', { */ Packet.prototype.toBuffer = function(writer) { writer = writer || new Packet.Writer(); + // RFC 1035 §4.1.4 — record the byte offset of each name we encode so later + // occurrences can be replaced by a compression pointer. The map is owned by + // the top-level message writer; rdata encoders that recursively encode + // names participate automatically. + if (!writer.names) writer.names = new Map(); this.header.qdcount = this.questions.length; this.header.ancount = this.answers.length; this.header.nscount = this.authorities.length; this.header.arcount = this.additionals.length; if (!(this instanceof Packet.Header)) { this.header = new Packet.Header(this.header); } + // RFC 6891 §6.1.3: if the caller set a header.rcode >= 16 the high byte must + // be carried in the OPT record's TTL. Propagate it before the header is + // serialized so the low nibble alone goes into the header. + if (this.header.rcode > 0xF) { + const opt = this.additionals.find(r => r && r.type === Packet.TYPE.EDNS); + if (opt) { + opt.extendedRcode = (this.header.rcode >>> 4) & 0xFF; + opt.ttl = ednsTtl(opt.extendedRcode, opt.version || 0, opt.doFlag); + } else { + debug('node-dns > rcode %d > 15 but no OPT record; truncating to low nibble', + this.header.rcode); + } + } this.header.toBuffer(writer); ([ // section encoder [ 'questions', Packet.Question ], @@ -238,8 +263,8 @@ Packet.prototype.toBuffer = function(writer) { ]).forEach(function(def) { const section = def[0]; const Encoder = def[1]; - (this[section] || []).map(function(resource) { - return Encoder.encode(resource, writer); + (this[section] || []).forEach(function(resource) { + Encoder.encode(resource, writer); }); }.bind(this)); return writer.toBuffer(); @@ -314,10 +339,12 @@ Packet.Header.prototype.toBuffer = function(writer) { writer.write(this.tc, 1); writer.write(this.rd, 1); writer.write(this.ra, 1); - writer.write(this.z, 1); + // RFC 1035 §4.1.1: the Z bit is reserved and must be zero in outgoing + // messages, regardless of what was preserved from any inbound packet. + writer.write(0, 1); writer.write(this.ad, 1); writer.write(this.cd, 1); - writer.write(this.rcode, 4); + writer.write(this.rcode & 0xF, 4); writer.write(this.qdcount, 16); writer.write(this.ancount, 16); writer.write(this.nscount, 16); @@ -373,11 +400,12 @@ Packet.Question.decode = function(reader) { }; Packet.Question.encode = function(question, writer) { + const ownsWriter = !writer; writer = writer || new Packet.Writer(); Packet.Name.encode(question.name, writer); writer.write(question.type, 16); writer.write(question.class, 16); - return writer.toBuffer(); + return ownsWriter ? writer.toBuffer() : undefined; }; /** @@ -423,23 +451,34 @@ Packet.Resource.encode = function(resource, writer) { Packet.Name.encode(resource.name, writer); writer.write(resource.type, 16); writer.write(resource.class, 16); - writer.write(resource.ttl, 32); + // 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]; + // 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 + // to predict their compressed length up front. + const rdlenBitPos = writer.bitLength(); + writer.write(0, 16); + const rdataBitStart = writer.bitLength(); if (encoder in Packet.Resource && Packet.Resource[encoder].encode) { - return Packet.Resource[encoder].encode(resource, writer); - } - debug('node-dns > unknown encoder %s(%j)', encoder, resource.type); - // Fallback for unknown / decoder-only types: round-trip the raw RDATA the - // decoder preserved as `resource.data`. Without this, RDLENGTH and RDATA - // would be omitted entirely, truncating the wire format and corrupting any - // records that follow. - const data = Buffer.isBuffer(resource.data) ? resource.data : Buffer.alloc(0); - writer.write(data.length, 16); - for (const byte of data) { - writer.write(byte, 8); + Packet.Resource[encoder].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 + // decoder preserved as `resource.data`. Without this, RDATA would be + // omitted entirely, truncating the wire format and corrupting any + // records that follow. + const data = Buffer.isBuffer(resource.data) ? resource.data : Buffer.alloc(0); + for (const byte of data) { + writer.write(byte, 8); + } } + const rdlen = (writer.bitLength() - rdataBitStart) / 8; + writer.patch(rdlenBitPos, rdlen, 16); return writer.toBuffer(); }; /** @@ -457,6 +496,10 @@ Packet.Resource.decode = function(reader) { resource.type = reader.read(16); resource.class = reader.read(16); resource.ttl = reader.read(32); + // RFC 2181 §8: TTLs are an unsigned 32-bit field but legacy implementations + // 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]; @@ -477,9 +520,12 @@ Packet.Resource.decode = function(reader) { * @param {[type]} domain [description] * @return {[type]} [description] */ +// RFC 1035 §2.3.4 — wire-format limits. Packet.Name = { - COPY : 0xc0, - decode : function(reader) { + COPY : 0xc0, + MAX_LABEL : 63, + MAX_NAME : 255, + decode : function(reader) { if (reader instanceof Buffer) { reader = new Packet.Reader(reader); } @@ -487,6 +533,11 @@ Packet.Name = { // Track each pointer target we follow. A crafted packet can chain // pointers in a cycle; without this guard, decode would loop forever. const visited = new Set(); + // Cumulative wire-format octets consumed for this name. RFC 1035 §2.3.4 + // caps the total — including the trailing zero-length root label — at + // 255, so the running tally starts at 1 (the terminator) and adds the + // length byte + label bytes for each non-root label. + let totalOctets = 1; while (len) { if ((len & Packet.Name.COPY) === Packet.Name.COPY) { len -= Packet.Name.COPY; @@ -500,30 +551,71 @@ Packet.Name = { reader.offset = pos * 8; len = reader.read(8); continue; - } else { - let part = ''; - while (len--) part += String.fromCharCode(reader.read(8)); - name.push(part); - len = reader.read(8); } + // RFC 1035: a label length byte has its top two bits clear (00). + // The 01/10 combinations are reserved and indicate a malformed name. + if (len & 0xC0) { + throw new Error(`Name decode: invalid label length byte 0x${len.toString(16)}`); + } + if (len > Packet.Name.MAX_LABEL) { + throw new Error(`Name decode: label exceeds ${Packet.Name.MAX_LABEL} octets`); + } + totalOctets += len + 1; + if (totalOctets > Packet.Name.MAX_NAME) { + throw new Error(`Name decode: name exceeds ${Packet.Name.MAX_NAME} octets`); + } + let part = ''; + while (len--) part += String.fromCharCode(reader.read(8)); + name.push(part); + len = reader.read(8); } if (o) reader.offset = o; return name.join('.'); }, encode: function(domain, writer) { + // Only materialize a Buffer when we created the writer; if the caller + // passed one, they own the final toBuffer() and we avoid an O(buffer) + // materialization per name (a big deal once many records share a suffix). + const ownsWriter = !writer; writer = writer || new Packet.Writer(); - // TODO: domain name compress - (domain || '').split('.').filter(function(part) { - return !!part; - }).forEach(function(part) { - writer.write(part.length, 8); - part.split('').map(function(c) { - writer.write(c.charCodeAt(0), 8); - return c.charCodeAt(0); - }); - }); + const parts = (domain || '').split('.').filter(part => !!part); + let totalOctets = 1; // root terminator + for (const part of parts) { + if (part.length > Packet.Name.MAX_LABEL) { + throw new Error( + `Name encode: label "${part}" is ${part.length} octets ` + + `(max ${Packet.Name.MAX_LABEL})`); + } + totalOctets += part.length + 1; + } + if (totalOctets > Packet.Name.MAX_NAME) { + throw new Error( + `Name encode: name "${domain}" encodes to ${totalOctets} octets ` + + `(max ${Packet.Name.MAX_NAME})`); + } + // RFC 1035 §4.1.4 — if the writer carries a name-offset table, emit a + // compression pointer for any suffix we've already serialized; otherwise + // record this suffix at its current byte offset so later names can point + // here. Compression pointers can address only the first 16 KiB of a + // message (14-bit offset); past that we fall back to literal labels. + const compress = writer.names instanceof Map; + for (let i = 0; i < parts.length; i++) { + const suffix = parts.slice(i).join('.').toLowerCase(); + if (compress && writer.names.has(suffix)) { + writer.write(0xC000 | writer.names.get(suffix), 16); + return ownsWriter ? writer.toBuffer() : undefined; + } + if (compress) { + const byteOffset = writer.byteLength(); + if (byteOffset < 0x4000) writer.names.set(suffix, byteOffset); + } + writer.write(parts[i].length, 8); + for (let j = 0; j < parts[i].length; j++) { + writer.write(parts[i].charCodeAt(j), 8); + } + } writer.write(0, 8); - return writer.toBuffer(); + return ownsWriter ? writer.toBuffer() : undefined; }, }; @@ -541,12 +633,12 @@ Packet.Resource.A = function(address) { Packet.Resource.A.encode = function(record, writer) { writer = writer || new Packet.Writer(); - const parts = record.address.split('.'); - writer.write(parts.length, 16); - parts.forEach(function(part) { + // 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). + record.address.split('.').forEach(function(part) { writer.write(parseInt(part, 10), 8); }); - return writer.toBuffer(); }; Packet.Resource.A.decode = function(reader, length) { @@ -577,11 +669,8 @@ Packet.Resource.MX = function(exchange, priority) { */ Packet.Resource.MX.encode = function(record, writer) { writer = writer || new Packet.Writer(); - const len = Packet.Name.encode(record.exchange).length; - writer.write(len + 2, 16); writer.write(record.priority, 16); Packet.Name.encode(record.exchange, writer); - return writer.toBuffer(); }; /** * [decode description] @@ -611,12 +700,9 @@ Packet.Resource.AAAA = { }, encode: function(record, writer) { writer = writer || new Packet.Writer(); - const parts = fromIPv6(record.address); - writer.write(parts.length * 2, 16); - parts.forEach(function(part) { + fromIPv6(record.address).forEach(function(part) { writer.write(parseInt(part, 16), 16); }); - return writer.toBuffer(); }, }; /** @@ -631,9 +717,7 @@ Packet.Resource.NS = { }, encode: function(record, writer) { writer = writer || new Packet.Writer(); - writer.write(Packet.Name.encode(record.ns).length, 16); Packet.Name.encode(record.ns, writer); - return writer.toBuffer(); }, }; /** @@ -649,9 +733,7 @@ Packet.Resource.CNAME = { }, encode: function(record, writer) { writer = writer || new Packet.Writer(); - writer.write(Packet.Name.encode(record.domain).length, 16); Packet.Name.encode(record.domain, writer); - return writer.toBuffer(); }, }; /** @@ -697,23 +779,13 @@ Packet.Resource.TXT = { return characterString; }); - // calculate byte length of resource strings - const bufferLength = characterStringBuffers.reduce(function(sum, characterStringBuffer) { - return sum + characterStringBuffer.length; - }, 0); - - // write string length to output - writer.write(bufferLength + characterStringBuffers.length, 16); // response length - - // write each string to output + // write each string to output (RDLENGTH is back-filled by Resource.encode) characterStringBuffers.forEach(function(buffer) { writer.write(buffer.length, 8); // text length buffer.forEach(function(c) { writer.write(c, 8); }); }); - - return writer.toBuffer(); }, }; /** @@ -734,19 +806,14 @@ Packet.Resource.SOA = { }, encode: function(record, writer) { writer = writer || new Packet.Writer(); - let len = 0; - len += Packet.Name.encode(record.primary).length; - len += Packet.Name.encode(record.admin).length; - len += (32 * 5) / 8; - writer.write(len, 16); Packet.Name.encode(record.primary, writer); Packet.Name.encode(record.admin, writer); writer.write(record.serial, 32); writer.write(record.refresh, 32); writer.write(record.retry, 32); writer.write(record.expiration, 32); - writer.write(record.minimum, 32); - return writer.toBuffer(); + // RFC 2308 §4: the SOA minimum field is also a TTL; same 31-bit ceiling. + writer.write(Math.min(record.minimum >>> 0, 0x7FFFFFFF), 32); }, }; /** @@ -764,13 +831,10 @@ Packet.Resource.SRV = { }, encode: function(record, writer) { writer = writer || new Packet.Writer(); - const { length } = Packet.Name.encode(record.target); - writer.write(length + 6, 16); writer.write(record.priority, 16); writer.write(record.weight, 16); writer.write(record.port, 16); Packet.Name.encode(record.target, writer); - return writer.toBuffer(); }, }; @@ -784,11 +848,16 @@ const ednsTtl = (extendedRcode, version, doFlag) => | ((version & 0xff) << 16) | (doFlag ? 0x8000 : 0); +// RFC 6891 §6.2.5 — a reasonable default for the requestor's UDP payload size. +// The pre-EDNS 512-byte limit is conservative; modern resolvers advertise +// 4096 so upstreams need not truncate responses that fit in a typical MTU. +Packet.EDNS_DEFAULT_UDP_PAYLOAD_SIZE = 4096; + Packet.Resource.EDNS = function(rdata, opts = {}) { const extendedRcode = opts.extendedRcode || 0; const version = opts.version || 0; const doFlag = !!opts.doFlag; - const udpPayloadSize = opts.udpPayloadSize || 512; + const udpPayloadSize = opts.udpPayloadSize || Packet.EDNS_DEFAULT_UDP_PAYLOAD_SIZE; return { type : Packet.TYPE.EDNS, class : udpPayloadSize, @@ -804,7 +873,7 @@ Packet.Resource.EDNS.decode = function(reader, length) { // When invoked through Resource.parse, this.type/class/ttl are already set // from the wire. Direct callers (e.g. unit tests) hit defaults instead. this.type = this.type ?? Packet.TYPE.EDNS; - this.class = this.class ?? 512; + this.class = this.class ?? Packet.EDNS_DEFAULT_UDP_PAYLOAD_SIZE; const ttl = this.ttl ?? 0; this.ttl = ttl; this.extendedRcode = (ttl >>> 24) & 0xff; @@ -833,7 +902,9 @@ Packet.Resource.EDNS.decode = function(reader, length) { }; Packet.Resource.EDNS.encode = function(record, writer) { - const rdataWriter = new Packet.Writer(); + writer = writer || new Packet.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]; @@ -841,17 +912,13 @@ Packet.Resource.EDNS.encode = function(record, writer) { if (encoder in Packet.Resource.EDNS && Packet.Resource.EDNS[encoder].encode) { const w = new Packet.Writer(); Packet.Resource.EDNS[encoder].encode(rdata, w); - rdataWriter.write(rdata.ednsCode, 16); - rdataWriter.write(w.buffer.length / 8, 16); - rdataWriter.writeBuffer(w); + writer.write(rdata.ednsCode, 16); + writer.write(w.buffer.length / 8, 16); + writer.writeBuffer(w); } else { debug('node-dns > unknown EDNS rdata encoder %s(%j)', encoder, rdata.ednsCode); } } - writer = writer || new Packet.Writer(); - writer.write(rdataWriter.buffer.length / 8, 16); - writer.writeBuffer(rdataWriter); - return writer.toBuffer(); }; Packet.Resource.EDNS.ECS = function(clientIp) { @@ -946,16 +1013,14 @@ function expandIPv6ToBytes(address) { Packet.Resource.CAA = { encode: function(record, writer) { writer = writer || new Packet.Writer(); - + // RDLENGTH is written by Packet.Resource.encode. const buffer = Buffer.from(record.tag + record.value, 'utf8'); - writer.write(2 + buffer.length, 16); writer.write(record.flags, 8); writer.write(record.tag.length, 8); buffer.forEach(function(c) { writer.write(c, 8); }); - return writer.toBuffer(); }, decode: function(reader, length) { this.flags = reader.read(8); @@ -1006,15 +1071,14 @@ Packet.Resource.DNSKEY = { }, encode: function(record, writer) { writer = writer || new Packet.Writer(); + // RDLENGTH is written by Packet.Resource.encode. const buffer = Buffer.from(record.key, 'base64'); - writer.write(4 + buffer.length, 16); writer.write(record.flags, 16); writer.write(record.protocol, 8); writer.write(record.algorithm, 8); buffer.forEach(function(c) { writer.write(c, 8); }); - return writer.toBuffer(); }, }; diff --git a/server/doh.js b/server/doh.js index 264c80e..6f054e5 100644 --- a/server/doh.js +++ b/server/doh.js @@ -27,6 +27,60 @@ const readStream = stream => new Promise((resolve, reject) => { .on('end', () => resolve(Buffer.concat(chunks))); }); +// RFC 8484 §4.1 — accept the request unless the client explicitly asked for +// media types that exclude application/dns-message. +// +// Per RFC 7231 §5.3.1 a q parameter of 0 means "not acceptable", so an entry +// like "application/dns-message;q=0" is an explicit rejection even though +// the media range matches. Parse q and treat q=0 as absent for matching; +// only if no surviving entry matches do we reject the request. +const parseAccept = accept => accept.split(',').map(entry => { + const parts = entry.split(';').map(s => s.trim()); + const type = (parts.shift() || '').toLowerCase(); + let q = 1; + for (const param of parts) { + const [ name, value ] = param.split('=').map(s => s.trim()); + if (name && name.toLowerCase() === 'q') { + const parsed = parseFloat(value); + if (Number.isFinite(parsed)) q = parsed; + } + } + return { type, q }; +}); + +const isAcceptable = accept => { + if (!accept) return true; + return parseAccept(accept).some(({ type, q }) => + q > 0 && ( + type === '*/*' || + type === 'application/*' || + type === 'application/dns-message' + ), + ); +}; + +// RFC 8484 §5.1 — DoH responses SHOULD include Cache-Control: max-age= +// derived from the minimum TTL across all RRs the response carries. A response +// with no RRs (NXDOMAIN, etc.) gets max-age=0. +// +// The OPT pseudo-RR (RFC 6891) reuses the TTL field for flags / extended +// RCODE and is typically 0; including it would force max-age=0 on any +// otherwise-cacheable EDNS response. Skip OPT (and any future pseudo-RR +// whose TTL field is not a real TTL). +const minResponseTtl = packet => { + let min = Infinity; + for (const section of [ packet.answers, packet.authorities, packet.additionals ]) { + if (!section) continue; + for (const rr of section) { + if (!rr || typeof rr.ttl !== 'number') continue; + if (rr.type === Packet.TYPE.EDNS) continue; + if (rr.ttl < min) min = rr.ttl; + } + } + if (!Number.isFinite(min) || min < 0) return 0; + return Math.min(min >>> 0, 0x7FFFFFFF); +}; + class Server extends EventEmitter { constructor(options) { super(); @@ -73,11 +127,15 @@ class Server extends EventEmitter { res.end(); return; } - // Make sure the requestee is requesting the correct content type - const contentType = headers.accept; - if (contentType !== 'application/dns-message') { - res.writeHead(400, { 'Content-Type': 'text/plain' }); - res.write('400 Bad Request: Illegal content type\n'); + // RFC 8484 §4.1: clients SHOULD send Accept: application/dns-message but + // are not required to, and the server is not required to reject other + // values. Only reject when the client sent a specific, incompatible + // Accept that excludes application/dns-message; treat missing, "*/*", + // and "application/*" as acceptable. The server always replies with + // application/dns-message regardless. + if (!isAcceptable(headers.accept)) { + res.writeHead(406, { 'Content-Type': 'text/plain' }); + res.write('406 Not Acceptable: application/dns-message required\n'); res.end(); return; } @@ -102,6 +160,15 @@ class Server extends EventEmitter { // Decode Base64 to buffer queryData = Buffer.from(base64, 'base64'); } else if (method === 'POST') { + // RFC 8484 §4.1: POST request bodies have Content-Type: + // application/dns-message. Anything else is unsupported. + const ct = (headers['content-type'] || '').split(';')[0].trim().toLowerCase(); + if (ct !== 'application/dns-message') { + res.writeHead(415, { 'Content-Type': 'text/plain' }); + res.write('415 Unsupported Media Type: expected application/dns-message\n'); + res.end(); + return; + } queryData = await readStream(client); } // Parse DNS query and Raise event. @@ -121,6 +188,9 @@ class Server extends EventEmitter { response(res, message) { debug('response'); res.setHeader('Content-Type', 'application/dns-message'); + // RFC 8484 §5.1 — Cache-Control derived from the minimum RR TTL so HTTP + // intermediaries expire the cached response when the DNS data does. + res.setHeader('Cache-Control', `max-age=${minResponseTtl(message)}`); res.writeHead(200); res.end(message.toBuffer()); } diff --git a/server/tcp.js b/server/tcp.js index 098ef51..3fec086 100644 --- a/server/tcp.js +++ b/server/tcp.js @@ -2,21 +2,39 @@ const tcp = require('node:net'); const Packet = require('../packet'); const proxyProtocol = require('../lib/proxy-protocol'); +// RFC 7766 §6.2.3 — recommended minimum idle timeout for established TCP +// connections. Clients that pipeline queries on a single connection rely on +// the server holding the connection open between messages. +const DEFAULT_IDLE_TIMEOUT_MS = 10000; + class Server extends tcp.Server { constructor(options) { - super(); + // allowHalfOpen keeps our write side open after the peer half-closes — + // required so that a client which sent its query via socket.end(frame) + // still receives the response. Without it Node auto-ends our writable + // side as soon as 'end' fires, dropping pending responses. + super({ allowHalfOpen: true }); let proxyProtocolEnabled = false; + let idleTimeout = DEFAULT_IDLE_TIMEOUT_MS; if (typeof options === 'object' && options !== null) { proxyProtocolEnabled = options.proxyProtocol ?? false; + if (typeof options.idleTimeout === 'number') idleTimeout = options.idleTimeout; } if (typeof options === 'function') { this.on('request', options); } this.proxyProtocol = proxyProtocolEnabled; + this.idleTimeout = idleTimeout; this.on('connection', this.handle.bind(this)); } async handle(client) { + // Per-connection bookkeeping for the pipelining state machine. Half-close + // from the peer ('end') is independent of how many requests are still in + // 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); @@ -26,9 +44,28 @@ class Server extends tcp.Server { client.proxyPort = header.sourcePort; } } - const data = await Packet.readStream(client); - const message = Packet.parse(data); - this.emit('request', message, this.response.bind(this, client), client); + // RFC 7766 §6.2.1.1 — process pipelined queries on a single connection. + // Each message is length-prefixed (RFC 1035 §4.2.2). The connection + // stays open until the client sends FIN AND every outstanding response + // has been written, an error occurs, or the idle timeout fires. + if (this.idleTimeout > 0) client.setTimeout(this.idleTimeout); + readPipelinedMessages(client, state, data => { + let message; + try { + message = Packet.parse(data); + } catch (e) { + this.emit('requestError', e); + client.destroy(); + return; + } + // Increment before emitting so a synchronous handler that calls + // send() inside the listener sees inFlight === 1 → 0, not 0 → -1. + state.inFlight++; + this.emit('request', message, this.response.bind(this, client), client); + }, err => { + this.emit('requestError', err); + client.destroy(); + }); } catch (e) { this.emit('requestError', e); client.destroy(); @@ -39,15 +76,90 @@ class Server extends tcp.Server { if (message instanceof Packet) { message = message.toBuffer(); } + // Out-of-order replies are permitted (RFC 7766 §6.2.4) and identified by + // the transaction ID in the message header. Each reply is length-prefixed + // and written without closing the connection so pipelined queries can + // continue to use it. const len = Buffer.alloc(2); len.writeUInt16BE(message.length); - client.end(Buffer.concat([ len, message ])); + if (!client.destroyed && client.writable) { + client.write(Buffer.concat([ len, message ])); + } + // Decrement in-flight and, if the peer has already half-closed and this + // 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(); + } + } } } +// Drive a single TCP connection: read each length-prefixed DNS message in +// turn and invoke onMessage(buffer) for each. End cleanly when the client +// half-closes AND there are no outstanding responses; surface protocol +// errors through onError. Connection lifetime is managed by the caller +// (idleTimeout, etc.). +function readPipelinedMessages(socket, state, onMessage, onError) { + let buffered = Buffer.alloc(0); + let expected = null; + + const drain = () => { + while (true) { + if (expected === null) { + if (buffered.length < 2) return; + expected = buffered.readUInt16BE(0); + buffered = buffered.slice(2); + } + if (buffered.length < expected) return; + const message = buffered.slice(0, expected); + buffered = buffered.slice(expected); + expected = null; + onMessage(message); + } + }; + + const onReadable = () => { + let chunk; + while ((chunk = socket.read()) !== null) { + buffered = buffered.length === 0 ? chunk : Buffer.concat([ buffered, chunk ]); + } + try { drain(); } catch (e) { onError(e); } + }; + + socket.on('readable', onReadable); + socket.on('end', () => { + // Half-close from the peer ends the message stream. If there's a partial + // message still in the buffer, that's a framing error. + if (expected !== null || buffered.length > 0) { + onError(new Error('TCP message truncated: connection closed mid-message')); + return; + } + state.peerEnded = true; + // Hold our write side open until every outstanding response has been + // written. Closing now would drop responses for queries the client sent + // via socket.end(frame), where 'end' may fire before the handler runs. + if (state.inFlight === 0 && !socket.destroyed) socket.end(); + }); + socket.on('timeout', () => { + // RFC 7766 §6.2.3 — close the idle connection so resources are not + // pinned indefinitely; clients are expected to reconnect on demand. + if (!socket.destroyed) socket.end(); + }); + socket.on('error', err => onError(err)); + // Drain anything already buffered before our listener attached (in + // particular, bytes unshifted by consumeProxyHeader). + onReadable(); +} + // Read and consume the PROXY header from the front of the socket's stream. // Any bytes that arrive past the header are unshifted back into the socket -// so the next reader (Packet.readStream) sees them. +// so the pipelined message reader (readPipelinedMessages) sees them in its +// initial drain. function consumeProxyHeader(socket) { return new Promise((resolve, reject) => { const chunks = []; diff --git a/server/udp.js b/server/udp.js index 805bcf4..d7a4c5f 100644 --- a/server/udp.js +++ b/server/udp.js @@ -2,6 +2,53 @@ const udp = require('node:dgram'); const Packet = require('../packet'); const proxyProtocol = require('../lib/proxy-protocol'); +// RFC 1035 §2.3.4 / §4.2.1 — UDP messages without EDNS are capped at 512 bytes. +const DEFAULT_UDP_LIMIT = 512; + +// Pick the negotiated UDP payload size: the requestor's EDNS OPT class field +// (RFC 6891 §6.2.3) if present and reasonable, otherwise 512. +const negotiatedPayloadSize = request => { + if (!request || !Array.isArray(request.additionals)) return DEFAULT_UDP_LIMIT; + const opt = request.additionals.find(r => r && r.type === Packet.TYPE.EDNS); + if (!opt) return DEFAULT_UDP_LIMIT; + // §6.2.3: values below 512 are senseless; clamp upward. + return Math.max(opt.class || 0, DEFAULT_UDP_LIMIT); +}; + +// Serialize the response, fitting it into the negotiated UDP budget. If the +// natural serialization exceeds the budget, set TC=1 and drop sections so the +// client knows to retry over TCP rather than receive silently truncated data. +// +// RFC 1035 §4.2.1: oversized responses MUST set the TC bit. RFC 2181 §9 allows +// the server to drop whole RRsets it cannot fit; the simplest correct behavior +// is to keep only the question section. +const serializeForUdp = (message, maxBytes) => { + if (message instanceof Packet) { + let buffer = message.toBuffer(); + if (buffer.length <= maxBytes) return buffer; + // Build a TC=1 response that carries only the header + question section. + const truncated = new Packet(); + truncated.header = new Packet.Header(message.header); + truncated.header.tc = 1; + truncated.questions = message.questions.slice(); + truncated.answers = []; + truncated.authorities = []; + truncated.additionals = []; + buffer = truncated.toBuffer(); + // Pathological: even header + question doesn't fit. Slice the buffer to + // the limit; the TC bit at byte 2 bit 1 is already preserved at the front. + if (buffer.length > maxBytes) buffer = buffer.slice(0, maxBytes); + return buffer; + } + // Raw buffer: caller is responsible for sizing. The most we can do + // safely is set the TC bit and slice. The header is the first 12 bytes; + // the TC bit is bit 1 of byte 2. + if (message.length <= maxBytes) return message; + const out = Buffer.from(message); + if (out.length >= 3) out[2] |= 0x02; // TC bit + return out.slice(0, maxBytes); +}; + /** * [Server description] * @docs https://tools.ietf.org/html/rfc1034 @@ -46,18 +93,21 @@ class Server extends udp.Socket { data = data.slice(parsed.headerLength); } const message = Packet.parse(data); - this.emit('request', message, this.response.bind(this, responder), clientInfo); + const ctx = { rinfo: responder, maxPayload: negotiatedPayloadSize(message) }; + this.emit('request', message, this.response.bind(this, ctx), clientInfo); } catch (e) { this.emit('requestError', e); } } - response(rinfo, message) { - if (message instanceof Packet) { message = message.toBuffer(); } + response(ctx, message) { + const rinfo = ctx && ctx.rinfo ? ctx.rinfo : ctx; + const maxPayload = (ctx && ctx.maxPayload) || DEFAULT_UDP_LIMIT; + const buffer = serializeForUdp(message, maxPayload); return new Promise((resolve, reject) => { - this.send(message, rinfo.port, rinfo.address, err => { + this.send(buffer, rinfo.port, rinfo.address, err => { if (err) return reject(err); - resolve(message); + resolve(buffer); }); }); } diff --git a/test/packet.js b/test/packet.js index b8b8d09..cc67f48 100644 --- a/test/packet.js +++ b/test/packet.js @@ -245,9 +245,10 @@ test('EDNS.ECS#encode', function() { // RFC 7871 §6: ADDRESS field is only ceil(sourcePrefixLength/8) octets, // so /24 writes 3 address bytes (10.11.12), not 4. + // class=0x1000=4096 is the RFC 6891 §6.2.5 default UDP payload size. const b = Packet.Resource.encode(query); assert.deepEqual(b, Buffer.from([ - 0x00, 0x00, 0x29, 0x02, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x29, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x08, 0x00, 0x07, 0x00, 0x01, 0x18, 0x00, 0x0a, 0x0b, 0x0c ])); }); @@ -455,6 +456,8 @@ test('Resource#DNSKEY round-trip preserves keyTag and flags', function() { test('Resource#CAA encode produces correct wire bytes', function() { // CAA only has an encoder in this library; verify the rdata layout directly. + // RDLENGTH is owned by Packet.Resource.encode now (so it can back-fill the + // value after compression), and is not emitted by per-type encoders. const writer = new Packet.Writer(); Packet.Resource.CAA.encode({ flags : 0, @@ -462,13 +465,11 @@ test('Resource#CAA encode produces correct wire bytes', function() { value : 'letsencrypt.org', }, writer); const buffer = writer.toBuffer(); - // Layout: [ rdlength_hi, rdlength_lo, flags, tagLen, tag..., value... ] - const rdlength = buffer.readUInt16BE(0); - assert.equal(rdlength, 2 + 'issue'.length + 'letsencrypt.org'.length); - assert.equal(buffer[2], 0); // flags - assert.equal(buffer[3], 'issue'.length); // tag length - assert.equal(buffer.slice(4, 4 + 5).toString(), 'issue'); - assert.equal(buffer.slice(4 + 5).toString(), 'letsencrypt.org'); + // Layout (rdata only): [ flags, tagLen, tag..., value... ] + assert.equal(buffer[0], 0); // flags + assert.equal(buffer[1], 'issue'.length); // tag length + assert.equal(buffer.slice(2, 2 + 5).toString(), 'issue'); + assert.equal(buffer.slice(2 + 5).toString(), 'letsencrypt.org'); }); test('EDNS.ECS#decode family=2 (IPv6)', function() { @@ -905,6 +906,155 @@ test('EDNS.ECS#encode supports IPv6 family', function() { assert.equal(parsed.rdata[0].ip, '2001:db8:0:0:0:0:0:0'); }); +test('Packet#encode compresses repeated names (RFC 1035 §4.1.4)', function() { + // Same name in the question and answer should be encoded as a 2-byte + // pointer (0xC0 0x0C → offset 12, immediately after the header). Without + // compression the answer name alone would take 1 + 7 + 1 + 7 + 1 + 3 + 1 + // = 21 bytes; with compression it's 2. + const pkt = new Packet(); + pkt.header.id = 1; + pkt.header.qr = 1; + pkt.questions.push({ name: 'example.com', type: Packet.TYPE.A, class: Packet.CLASS.IN }); + pkt.answers.push({ + name : 'example.com', + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 60, + address : '192.0.2.1', + }); + const buf = pkt.toBuffer(); + // Question name 'example.com' starts at byte 12. The answer name should be + // exactly 2 bytes: 0xC0 0x0C. + const headerLen = 12; + const questionNameLen = 1 + 7 + 1 + 3 + 1; // 'example' label + 'com' label + root + const ansNameStart = headerLen + questionNameLen + 4; // + qtype + qclass + assert.equal(buf[ansNameStart], 0xC0, 'answer name should start with pointer high byte'); + assert.equal(buf[ansNameStart + 1], 0x0C, 'pointer should target byte 12'); + // Round-trip back to verify the pointer resolves to the original name. + const parsed = Packet.parse(buf); + assert.equal(parsed.questions[0].name, 'example.com'); + assert.equal(parsed.answers[0].name, 'example.com'); +}); + +test('Packet#encode compresses common suffixes (RFC 1035 §4.1.4)', function() { + // a.example.com and b.example.com share the 'example.com' suffix; the + // second name should be 'b' label + pointer to 'example.com'. + const pkt = new Packet(); + pkt.header.id = 2; + pkt.header.qr = 1; + pkt.questions.push({ name: 'a.example.com', type: Packet.TYPE.A, class: Packet.CLASS.IN }); + pkt.answers.push({ + name : 'b.example.com', + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 60, + address : '192.0.2.2', + }); + const buf = pkt.toBuffer(); + // Without compression the answer name uses 15 bytes; with shared-suffix + // compression it uses 4: [1, 'b', pointer_hi, pointer_lo]. + const parsed = Packet.parse(buf); + assert.equal(parsed.questions[0].name, 'a.example.com'); + assert.equal(parsed.answers[0].name, 'b.example.com'); + // Find the answer name in the wire format and verify its length. + const headerLen = 12; + const questionNameLen = 1 + 1 + 1 + 7 + 1 + 3 + 1; // 'a' . 'example' . 'com' . root + const ansNameStart = headerLen + questionNameLen + 4; + assert.equal(buf[ansNameStart], 1, "first byte is label length for 'b'"); + assert.equal(buf[ansNameStart + 1], 0x62, "second byte is 'b'"); + assert.equal(buf[ansNameStart + 2] & 0xC0, 0xC0, 'third byte starts a compression pointer'); +}); + +test('Packet#encode compresses names inside rdata (CNAME)', function() { + // The CNAME's target is example.com — same as the question name, so the + // rdata should be just a 2-byte pointer. + const pkt = new Packet(); + pkt.header.id = 3; + pkt.header.qr = 1; + pkt.questions.push({ name: 'alias.example.com', type: Packet.TYPE.CNAME, class: Packet.CLASS.IN }); + pkt.answers.push({ + name : 'alias.example.com', + type : Packet.TYPE.CNAME, + class : Packet.CLASS.IN, + ttl : 60, + domain : 'example.com', + }); + const buf = pkt.toBuffer(); + const parsed = Packet.parse(buf); + assert.equal(parsed.answers[0].domain, 'example.com'); + // The CNAME rdata should be exactly 2 bytes (compression pointer). Find it + // by walking past header(12) + question(19+4) + ans_name(2) + type(2) + + // class(2) + ttl(4) + rdlength(2). + const rdlengthAt = 12 + (1 + 5 + 1 + 7 + 1 + 3 + 1) + 4 + 2 + 2 + 2 + 4; + const rdlength = buf.readUInt16BE(rdlengthAt); + assert.equal(rdlength, 2, 'CNAME pointing to existing name should compress to 2 bytes'); +}); + +test('Packet#encode rejects oversized labels (RFC 1035 §2.3.4)', function() { + const pkt = new Packet(); + pkt.questions.push({ + name : 'x'.repeat(64) + '.example.com', + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + }); + assert.throws(() => pkt.toBuffer(), /label/); +}); + +test('Packet#encode rejects oversized names (RFC 1035 §2.3.4)', function() { + // 6 labels of 41 chars + 5 dots + root = 41*6 + 6 length bytes + 1 root = 253? + // Build a name guaranteed to exceed 255 octets: 4 labels of 63 + dots. + const labels = [ 'a'.repeat(63), 'b'.repeat(63), 'c'.repeat(63), 'd'.repeat(63) ]; + const pkt = new Packet(); + pkt.questions.push({ + name : labels.join('.'), + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + }); + assert.throws(() => pkt.toBuffer(), /name/); +}); + +test('Name#decode rejects an oversized label byte', function() { + // Length byte 64 (0x40) has the second-highest bit set and is reserved. + const buf = Buffer.from([ 0x40, 0x61, 0x00 ]); + const reader = new Packet.Reader(buf); + assert.throws(() => Packet.Name.decode(reader), /invalid label length/); +}); + +test('Resource#decode clamps TTL with the sign bit set (RFC 2181 §8)', function() { + // Hand-build a minimal packet: header + 0 questions + 1 answer with TTL = + // 0xFFFFFFFF and 4-byte A rdata. + const pkt = Buffer.from([ + 0x00, 0x01, 0x00, 0x00, // id, flags + 0x00, 0x00, 0x00, 0x01, // qdcount, ancount + 0x00, 0x00, 0x00, 0x00, // nscount, arcount + // answer + 0x03, 0x66, 0x6f, 0x6f, 0x00, // name "foo" + 0x00, 0x01, // type A + 0x00, 0x01, // class IN + 0xFF, 0xFF, 0xFF, 0xFF, // TTL = 2^32 - 1 (high bit set) + 0x00, 0x04, // rdlength + 0xC0, 0x00, 0x02, 0x01, // 192.0.2.1 + ]); + const parsed = Packet.parse(pkt); + assert.equal(parsed.answers[0].ttl, 0x7FFFFFFF, 'high-bit TTL must be clamped to 2^31 - 1'); +}); + +test('Packet#encode merges extended RCODE into OPT (RFC 6891 §6.1.3)', function() { + // header.rcode = 16 (BADVERS) should propagate the high byte into the OPT + // record's TTL and only the low nibble (0) into the header. + const pkt = new Packet(); + pkt.header.id = 0xAAAA; + pkt.header.qr = 1; + pkt.header.rcode = 16; + pkt.additionals.push(Packet.Resource.EDNS([])); + const buf = pkt.toBuffer(); + // Header byte 3 holds Z|AD|CD|RCODE(low4). For rcode=16 → low nibble is 0. + assert.equal(buf[3] & 0x0F, 0); + // Parse it back: the merge should restore rcode=16. + const parsed = Packet.parse(buf); + assert.equal(parsed.header.rcode, 16); +}); + test('Packet.parse tolerates multiple questions', function() { const request = new Packet(); request.header.id = 0x9999; diff --git a/test/server.js b/test/server.js index 8161378..9b89d3a 100644 --- a/test/server.js +++ b/test/server.js @@ -33,6 +33,27 @@ function get(url, options) { }); } +// Open a TCP connection, write a single request payload, read one +// length-prefixed reply, then close. Replaces the older pattern of waiting +// for the server to half-close — RFC 7766 servers hold connections open for +// pipelining, so the client must signal it's done. +function readOneTcpReply(port, payload) { + return new Promise((resolve, reject) => { + const sock = tcp.connect(port, '127.0.0.1', () => sock.write(payload)); + let buffered = Buffer.alloc(0); + sock.on('data', chunk => { + buffered = Buffer.concat([ buffered, chunk ]); + if (buffered.length < 2) return; + const len = buffered.readUInt16BE(0); + if (buffered.length < 2 + len) return; + const message = buffered.slice(2, 2 + len); + sock.end(); + resolve(Packet.parse(message)); + }); + sock.on('error', reject); + }); +} + test('server/doh#cors - default', async function() { const server = createDOHServer(); const { port } = await new Promise(resolve => { @@ -125,6 +146,85 @@ test('server/udp-tcp#simple-request-async-response', async() => { await server.close(); }); +test('server/udp#oversized response sets TC=1 and truncates (RFC 1035 §4.2.1)', async() => { + const server = createUDPServer(); + server.on('request', (request, send) => { + const response = Packet.createResponseFromRequest(request); + // 60 TXT answers with long strings → far past the 512 byte UDP limit. + for (let i = 0; i < 60; i++) { + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.TXT, + class : Packet.CLASS.IN, + ttl : 60, + data : 'x'.repeat(200), + }); + } + send(response); + }); + await server.listen(0, '127.0.0.1'); + const { port } = server.address(); + + // Send a non-EDNS query so the server applies the 512-byte ceiling. + const query = new Packet(); + query.header.id = 0xABCD; + query.header.rd = 1; + query.questions.push({ name: 'big.test', type: Packet.TYPE.TXT, class: Packet.CLASS.IN }); + const client = udp.createSocket('udp4'); + const reply = await new Promise((resolve, reject) => { + client.on('message', msg => resolve(msg)); + client.on('error', reject); + client.send(query.toBuffer(), port, '127.0.0.1'); + }); + await new Promise(resolve => client.close(resolve)); + assert.ok(reply.length <= 512, `reply ${reply.length} bytes must be ≤ 512`); + const parsed = Packet.parse(reply); + assert.equal(parsed.header.tc, 1, 'TC bit must be set on truncated reply'); + assert.equal(parsed.header.id, 0xABCD); + assert.equal(parsed.questions[0].name, 'big.test'); + await new Promise(resolve => server.close(resolve)); +}); + +test('server/udp#EDNS-advertised payload raises UDP ceiling (RFC 6891 §6.2.3)', async() => { + // When the request carries an OPT record with class=4096, the server may + // send a response up to 4096 bytes without truncating. + const server = createUDPServer(); + server.on('request', (request, send) => { + const response = Packet.createResponseFromRequest(request); + for (let i = 0; i < 5; i++) { + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.TXT, + class : Packet.CLASS.IN, + ttl : 60, + data : 'y'.repeat(200), + }); + } + send(response); + }); + await server.listen(0, '127.0.0.1'); + const { port } = server.address(); + + const query = new Packet(); + query.header.id = 0x1234; + query.header.rd = 1; + query.questions.push({ name: 'edns.test', type: Packet.TYPE.TXT, class: Packet.CLASS.IN }); + query.additionals.push(Packet.Resource.EDNS([], { udpPayloadSize: 4096 })); + + const client = udp.createSocket('udp4'); + const reply = await new Promise((resolve, reject) => { + client.on('message', msg => resolve(msg)); + client.on('error', reject); + client.send(query.toBuffer(), port, '127.0.0.1'); + }); + await new Promise(resolve => client.close(resolve)); + assert.ok(reply.length > 512, `EDNS-advertised payload should permit > 512 bytes; got ${reply.length}`); + const parsed = Packet.parse(reply); + assert.equal(parsed.header.tc, 0, 'no truncation expected within EDNS budget'); + assert.equal(parsed.answers.length, 5); + await new Promise(resolve => server.close(resolve)); +}); + test('server/udp#standalone end-to-end query', async() => { const server = createUDPServer(); server.on('request', (request, send) => { @@ -173,6 +273,123 @@ test('server/tcp#standalone end-to-end query', async() => { await new Promise(resolve => server.close(resolve)); }); +test('server/tcp#async handler still responds when client sends socket.end(frame)', async() => { + // Regression: a client that bundles query + FIN in a single socket.end() + // would trigger 'end' on the server before an async handler had a chance + // to call send(). The server must not half-close its write side while any + // response is still in flight. + const server = createTCPServer(); + server.on('request', (request, send) => { + setTimeout(() => { + const response = Packet.createResponseFromRequest(request); + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 60, + address : '203.0.113.99', + }); + send(response); + }, 25); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + const query = new Packet(); + query.header.id = 0x9001; + query.header.rd = 1; + query.questions.push({ name: 'end-with-frame.test', type: Packet.TYPE.A, class: Packet.CLASS.IN }); + const body = query.toBuffer(); + const len = Buffer.alloc(2); + len.writeUInt16BE(body.length); + + const reply = await new Promise((resolve, reject) => { + const sock = tcp.connect(port, '127.0.0.1', () => { + // socket.end(frame) sends both the query data and FIN. The server's + // 'end' event will fire before the async handler calls send(). + sock.end(Buffer.concat([ len, body ])); + }); + let buffered = Buffer.alloc(0); + sock.on('data', chunk => { + buffered = Buffer.concat([ buffered, chunk ]); + if (buffered.length < 2) return; + const replyLen = buffered.readUInt16BE(0); + if (buffered.length < 2 + replyLen) return; + resolve(Packet.parse(buffered.slice(2, 2 + replyLen))); + }); + sock.on('error', reject); + sock.on('close', () => { + if (buffered.length === 0) reject(new Error('connection closed before any response')); + }); + }); + + assert.equal(reply.header.id, 0x9001); + assert.equal(reply.answers[0].address, '203.0.113.99'); + await new Promise(resolve => server.close(resolve)); +}); + +test('server/tcp#pipelined queries share a connection (RFC 7766 §6.2.1.1)', async() => { + const server = createTCPServer(); + server.on('request', (request, send) => { + const response = Packet.createResponseFromRequest(request); + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 60, + address : '192.0.2.42', + }); + send(response); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + // Build two distinct queries and send them back-to-back on one connection + // without waiting for each reply. The server must process both and write + // length-prefixed replies on the same socket. + const queries = [ 'pipe1.test', 'pipe2.test', 'pipe3.test' ].map((name, i) => { + const q = new Packet(); + q.header.id = 0x1000 + i; + q.header.rd = 1; + q.questions.push({ name, type: Packet.TYPE.A, class: Packet.CLASS.IN }); + const body = q.toBuffer(); + const len = Buffer.alloc(2); + len.writeUInt16BE(body.length); + return { id: q.header.id, frame: Buffer.concat([ len, body ]) }; + }); + + const replies = await new Promise((resolve, reject) => { + const sock = tcp.connect(port, '127.0.0.1', () => { + for (const q of queries) sock.write(q.frame); + }); + const out = []; + let buffered = Buffer.alloc(0); + sock.on('data', chunk => { + buffered = Buffer.concat([ buffered, chunk ]); + while (buffered.length >= 2) { + const len = buffered.readUInt16BE(0); + if (buffered.length < 2 + len) break; + out.push(Packet.parse(buffered.slice(2, 2 + len))); + buffered = buffered.slice(2 + len); + if (out.length === queries.length) { + sock.end(); + resolve(out); + } + } + }); + sock.on('error', reject); + }); + + assert.equal(replies.length, queries.length); + const ids = replies.map(r => r.header.id).sort(); + assert.deepEqual(ids, queries.map(q => q.id).sort()); + for (const r of replies) { + assert.equal(r.header.qr, 1); + assert.equal(r.answers[0].address, '192.0.2.42'); + } + await new Promise(resolve => server.close(resolve)); +}); + test('server/doh#GET via DOHClient end-to-end', async() => { const server = createDOHServer(); server.on('request', (request, send) => { @@ -286,17 +503,25 @@ test('server/doh#404 on unknown path', async() => { server.close(); }); -test('server/doh#400 on missing accept header', async() => { +test('server/doh#406 on incompatible Accept header', async() => { + // RFC 8484 §4.1: the client SHOULD send Accept: application/dns-message but + // the server is not required to reject other values. Only reject when the + // client explicitly asked for media types that exclude + // application/dns-message — the server always replies with that type. const server = createDOHServer(); const { port } = await new Promise(resolve => { server.on('listening', resolve); server.listen(); }); const statusCode = await new Promise((resolve, reject) => { - http.get({ host: '127.0.0.1', port, path: '/dns-query?dns=abc' }, res => resolve(res.statusCode)) - .on('error', reject); + http.get({ + host : '127.0.0.1', + port, + path : '/dns-query?dns=abc', + headers : { accept: 'text/html' }, + }, res => resolve(res.statusCode)).on('error', reject); }); - assert.equal(statusCode, 400); + assert.equal(statusCode, 406); server.close(); }); @@ -318,6 +543,170 @@ test('server/doh#400 on missing dns query param', async() => { server.close(); }); +test('server/doh#GET with Accept: */* is accepted (RFC 8484 §4.1)', async() => { + // curl-style clients send */*; the server must not reject them — it always + // replies with application/dns-message anyway. + const server = createDOHServer(); + server.on('request', (request, send) => { + const response = Packet.createResponseFromRequest(request); + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 60, + address : '198.51.100.40', + }); + send(response); + }); + const { port } = await new Promise(resolve => { + server.on('listening', resolve); + server.listen(); + }); + const query = DOHClient({ dns: `http://127.0.0.1:${port}/dns-query` }); + // Routes through DOHClient which sets Accept: application/dns-message; + // for the */* case we hit the server with a raw http.get. + const reply = await query('star-accept.test'); + assert.equal(reply.answers[0].address, '198.51.100.40'); + + const packet = new Packet(); + packet.header.rd = 1; + packet.questions.push({ name: 'star.test', type: Packet.TYPE.A, class: Packet.CLASS.IN }); + const dns = packet.toBase64URL(); + const status = await new Promise((resolve, reject) => { + http.get({ + host : '127.0.0.1', + port, + path : `/dns-query?dns=${dns}`, + headers : { accept: '*/*' }, + }, res => resolve(res.statusCode)).on('error', reject); + }); + assert.equal(status, 200); + server.close(); +}); + +test('server/doh#POST 415 on missing or wrong Content-Type (RFC 8484 §4.1)', async() => { + const server = createDOHServer(); + const requestErrors = []; + server.on('request', (request, send) => send(Packet.createResponseFromRequest(request))); + server.on('requestError', e => requestErrors.push(e)); + const { port } = await new Promise(resolve => { + server.on('listening', resolve); + server.listen(); + }); + const sendPost = (contentType, body = Buffer.alloc(12)) => new Promise((resolve, reject) => { + const headers = { accept: 'application/dns-message' }; + if (contentType) headers['content-type'] = contentType; + const req = http.request({ + host : '127.0.0.1', + port, + path : '/dns-query', + method : 'POST', + headers, + }, res => resolve({ status: res.statusCode })); + req.on('error', err => resolve({ error: err })); + req.end(body); + }); + assert.equal((await sendPost(undefined)).status, 415, 'missing Content-Type'); + assert.equal((await sendPost('application/json')).status, 415, 'wrong Content-Type'); + // With the correct Content-Type the 415 check passes, so a body too short + // for a DNS header surfaces as a server-side parse error (handler destroys + // the connection); the client sees a socket-level error, not a 415. + const malformed = await sendPost('application/dns-message', Buffer.alloc(0)); + assert.ok(malformed.error, 'malformed body should fail at the socket, not return 415'); + assert.ok(requestErrors.length >= 1, 'requestError should have fired for the malformed body'); + server.close(); +}); + +test('server/doh#406 when Accept lists application/dns-message with q=0', async() => { + // Per RFC 7231 §5.3.1 q=0 means "not acceptable". An entry like + // application/dns-message;q=0 is an explicit rejection, even though the + // media range matches. + const server = createDOHServer(); + const { port } = await new Promise(resolve => { + server.on('listening', resolve); + server.listen(); + }); + const status = await new Promise((resolve, reject) => { + http.get({ + host : '127.0.0.1', + port, + path : '/dns-query?dns=abc', + headers : { accept: 'application/dns-message;q=0, text/html' }, + }, res => resolve(res.statusCode)).on('error', reject); + }); + assert.equal(status, 406); + server.close(); +}); + +test('server/doh#Cache-Control ignores OPT pseudo-RR TTL (RFC 6891)', async() => { + // The OPT record's "TTL" field carries flags/extended RCODE; it is almost + // always 0 and must not drive the min TTL down to zero on cacheable + // responses. + const server = createDOHServer(); + server.on('request', (request, send) => { + const response = Packet.createResponseFromRequest(request); + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 120, + address : '203.0.113.20', + }); + // OPT in additionals with the conventional TTL=0. Without the fix, the + // Cache-Control max-age would collapse to 0. + response.additionals.push(Packet.Resource.EDNS([])); + send(response); + }); + const { port } = await new Promise(resolve => { + server.on('listening', resolve); + server.listen(); + }); + const packet = new Packet(); + packet.header.rd = 1; + packet.questions.push({ name: 'opt-ttl.test', type: Packet.TYPE.A, class: Packet.CLASS.IN }); + const dns = packet.toBase64URL(); + const { headers } = await get(`http://127.0.0.1:${port}/dns-query?dns=${dns}`, { + headers: { accept: 'application/dns-message' }, + }); + assert.equal(headers['cache-control'], 'max-age=120'); + server.close(); +}); + +test('server/doh#response carries Cache-Control: max-age= (RFC 8484 §5.1)', async() => { + const server = createDOHServer(); + server.on('request', (request, send) => { + const response = Packet.createResponseFromRequest(request); + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 300, + address : '203.0.113.10', + }); + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 30, // minimum across the two answers + address : '203.0.113.11', + }); + send(response); + }); + const { port } = await new Promise(resolve => { + server.on('listening', resolve); + server.listen(); + }); + const packet = new Packet(); + packet.header.rd = 1; + packet.questions.push({ name: 'cc.test', type: Packet.TYPE.A, class: Packet.CLASS.IN }); + const dns = packet.toBase64URL(); + const { headers } = await get(`http://127.0.0.1:${port}/dns-query?dns=${dns}`, { + headers: { accept: 'application/dns-message' }, + }); + assert.equal(headers['cache-control'], 'max-age=30'); + server.close(); +}); + test('server/all#multi-question request is preserved through handle', async() => { const server = createServer({ udp : true, @@ -619,18 +1008,7 @@ test('server/tcp#proxyProtocol v1 exposes real client address', async() => { destinationPort : serverPort, }); - const reply = await new Promise((resolve, reject) => { - const sock = tcp.connect(serverPort, '127.0.0.1', () => { - sock.write(Buffer.concat([ proxyHeader, length, dnsMessage ])); - }); - const chunks = []; - sock.on('data', c => chunks.push(c)); - sock.on('end', () => { - const buf = Buffer.concat(chunks); - resolve(Packet.parse(buf.slice(2))); - }); - sock.on('error', reject); - }); + const reply = await readOneTcpReply(serverPort, Buffer.concat([ proxyHeader, length, dnsMessage ])); assert.equal(reply.header.id, 0x1111); assert.equal(reply.answers[0].address, '127.0.0.1'); @@ -671,15 +1049,7 @@ test('server/tcp#proxyProtocol v2 exposes real client address', async() => { destinationPort : serverPort, }); - const reply = await new Promise((resolve, reject) => { - const sock = tcp.connect(serverPort, '127.0.0.1', () => { - sock.write(Buffer.concat([ proxyHeader, length, dnsMessage ])); - }); - const chunks = []; - sock.on('data', c => chunks.push(c)); - sock.on('end', () => resolve(Packet.parse(Buffer.concat(chunks).slice(2)))); - sock.on('error', reject); - }); + const reply = await readOneTcpReply(serverPort, Buffer.concat([ proxyHeader, length, dnsMessage ])); assert.equal(reply.header.id, 0x2222); assert.equal(observed.address, '198.51.100.99');