@blamejs/core 0.18.39 → 0.18.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mail.js CHANGED
@@ -248,10 +248,9 @@ async function reverseDns(ip) {
248
248
  // the original input. RFC 8601 §3 says the forward query must use
249
249
  // the same family as the source; mismatched families don't count
250
250
  // as confirmation.
251
- var net = require("node:net");
252
251
  var forwardAddrs = [];
253
252
  try {
254
- if (net.isIPv6(ip)) {
253
+ if (net().isIPv6(ip)) {
255
254
  forwardAddrs = await dns.resolveAaaa(ptrName);
256
255
  } else {
257
256
  forwardAddrs = await dns.resolve4(ptrName);
@@ -69,6 +69,7 @@
69
69
  * }
70
70
  */
71
71
  var C = require("../constants");
72
+ var cookies = require("../cookies");
72
73
  var lazyRequire = require("../lazy-require");
73
74
  var pick = require("../pick");
74
75
  var forms = require("../forms");
@@ -130,33 +131,6 @@ function _parseCookieHeader(header) {
130
131
  return Object.assign(Object.create(null), Object.fromEntries(pairs));
131
132
  }
132
133
 
133
- function _formatSetCookie(name, value, opts) {
134
- var parts = [name + "=" + value];
135
- parts.push("Path=" + (opts.path || "/"));
136
- parts.push("SameSite=" + (opts.sameSite || "Lax"));
137
- if (opts.httpOnly) parts.push("HttpOnly");
138
- if (opts.secure) parts.push("Secure");
139
- if (opts.maxAge != null) parts.push("Max-Age=" + opts.maxAge);
140
- return parts.join("; ");
141
- }
142
-
143
- function _appendSetCookie(res, value) {
144
- // Don't clobber other Set-Cookie headers the route may have already
145
- // queued (login session cookie, etc.). Use res.appendHeader when
146
- // available; else array-merge manually.
147
- if (typeof res.appendHeader === "function") {
148
- res.appendHeader("Set-Cookie", value);
149
- return;
150
- }
151
- var existing = typeof res.getHeader === "function" ? res.getHeader("Set-Cookie") : undefined;
152
- if (existing == null) {
153
- res.setHeader("Set-Cookie", value);
154
- } else if (Array.isArray(existing)) {
155
- res.setHeader("Set-Cookie", existing.concat(value));
156
- } else {
157
- res.setHeader("Set-Cookie", [existing, value]);
158
- }
159
- }
160
134
 
161
135
  // csrf-protect does NOT buffer or parse the request body itself.
162
136
  // Operators who use form-urlencoded POSTs MUST register
@@ -431,30 +405,44 @@ function create(opts) {
431
405
  if (["Lax", "Strict", "None"].indexOf(cookieCfg.sameSite) === -1) {
432
406
  throw new Error("middleware.csrfProtect: opts.cookie.sameSite must be Lax|Strict|None");
433
407
  }
434
- // Cookie name-prefix safety (RFC 6265bis §4.1.3). csrf-protect builds its
435
- // own Set-Cookie header rather than routing through b.cookies.serialize, so
436
- // this boot check is the only enforcement point. §5.4 requires user agents
437
- // to apply the prefix test case-INSENSITIVELY (the server-side §4.1.3
438
- // description reads "case-sensitive", but the UA is what drops the cookie),
439
- // so `__host-`/`__SECURE-` get the same browser enforcement as
440
- // `__Host-`/`__Secure-` -- case-sensitive matching was itself CVE-2024-5699.
441
- // Compare a lowercased copy so a case-variant name can't dodge the invariant
442
- // here and then be silently rejected by the browser. Catch typos at boot.
408
+ // Cookie name-prefix safety (RFC 6265bis §4.1.3). b.cookies.serialize
409
+ // enforces the same invariants when the header is built, but that is a
410
+ // per-request throw inside the middleware; catching the bad configuration
411
+ // at boot turns it into a startup error the operator can read. §5.4
412
+ // requires user agents to apply the prefix test case-INSENSITIVELY (the
413
+ // server-side §4.1.3 description reads "case-sensitive", but the UA is what
414
+ // drops the cookie), so `__host-`/`__SECURE-` get the same browser
415
+ // enforcement as `__Host-`/`__Secure-` -- case-sensitive matching was itself
416
+ // CVE-2024-5699. Compare a lowercased copy so a case-variant name can't
417
+ // dodge the invariant here and then be silently rejected by the browser.
443
418
  // __Host-* — Path must be "/", no Domain (we never set one), Secure.
444
419
  // __Secure-* — Secure.
445
420
  if (cookieCfg.name) {
446
421
  var lowerCookieName = cookieCfg.name.toLowerCase();
447
- if (lowerCookieName.indexOf("__host-") === 0) {
448
- if (cookieCfg.path !== "/") {
449
- throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
450
- }
451
- if (cookieCfg.secure === false) {
452
- throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
453
- }
454
- } else if (lowerCookieName.indexOf("__secure-") === 0) {
455
- if (cookieCfg.secure === false) {
456
- throw new Error("middleware.csrfProtect: __Secure-* cookie name requires secure (cannot be explicit false)");
457
- }
422
+ var isHostPrefix = lowerCookieName.indexOf("__host-") === 0;
423
+ var isSecurePrefix = lowerCookieName.indexOf("__secure-") === 0;
424
+ if (isHostPrefix && cookieCfg.path !== "/") {
425
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
426
+ }
427
+ if (isHostPrefix && cookieCfg.secure === false) {
428
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
429
+ }
430
+ if (isSecurePrefix && cookieCfg.secure === false) {
431
+ throw new Error("middleware.csrfProtect: __Secure-* cookie name requires secure (cannot be explicit false)");
432
+ }
433
+ // A prefixed name is a promise to the browser that the cookie is always
434
+ // Secure. Leaving `secure` to per-request auto-detection breaks that
435
+ // promise on any cleartext request: the cookie goes out prefixed and
436
+ // WITHOUT Secure, the browser drops it, and the double-submit token
437
+ // silently never persists. The name and the auto-detect cannot both
438
+ // stand — the operator picking the prefix is the one asserting HTTPS,
439
+ // so make them say it.
440
+ if ((isHostPrefix || isSecurePrefix) && cookieCfg.secure == null) {
441
+ throw new Error("middleware.csrfProtect: " +
442
+ (isHostPrefix ? "__Host-*" : "__Secure-*") +
443
+ " cookie name requires an explicit cookie.secure: true — " +
444
+ "auto-detected secure emits the prefix without Secure on a plain-HTTP " +
445
+ "request, which browsers reject");
458
446
  }
459
447
  }
460
448
  }
@@ -485,8 +473,8 @@ function create(opts) {
485
473
  function _issueIfNeeded(req, res) {
486
474
  if (!cookieCfg) return null;
487
475
  var cookieName = _resolveCookieName(req);
488
- var cookies = _parseCookieHeader(req.headers && req.headers.cookie);
489
- var existing = cookies[cookieName];
476
+ var requestCookies = _parseCookieHeader(req.headers && req.headers.cookie);
477
+ var existing = requestCookies[cookieName];
490
478
  // Strict 64-hex-char check matches the byte-length of every token
491
479
  // forms.generateCsrfToken() produces (CSRF_TOKEN_BYTES = 32 bytes
492
480
  // → 64 hex chars). The previous {2,} floor accepted any 2-char
@@ -522,14 +510,20 @@ function create(opts) {
522
510
  } catch (_e) { /* drop-silent */ }
523
511
  }
524
512
  var fresh = forms.generateCsrfToken();
525
- var setCookie = _formatSetCookie(cookieName, fresh, {
513
+ // b.cookies owns Set-Cookie: it validates the name as an RFC 6265 token,
514
+ // refuses CRLF / NUL in the value, scrubs the Path and Domain attributes
515
+ // before they reach a response header, and enforces the RFC 6265bis
516
+ // prefix invariants. The token is 64 hex characters, so the percent-
517
+ // encoding serialize applies to the value is a no-op on it and the cookie
518
+ // the browser echoes back still matches the double-submit compare.
519
+ var setCookie = cookies.serialize(cookieName, fresh, {
526
520
  path: cookieCfg.path,
527
521
  sameSite: cookieCfg.sameSite,
528
522
  secure: cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure,
529
523
  httpOnly: cookieCfg.httpOnly,
530
524
  maxAge: cookieCfg.maxAge,
531
525
  });
532
- _appendSetCookie(res, setCookie);
526
+ cookies.appendSetCookie(res, setCookie);
533
527
  req._csrfIssuedCookies[cookieName] = fresh;
534
528
  req.csrfToken = fresh;
535
529
  return fresh;
package/lib/migrations.js CHANGED
@@ -297,12 +297,12 @@ function create(opts) {
297
297
  var dir = opts.dir;
298
298
 
299
299
  function _appliedRows() {
300
- var db = _resolveDb(opts);
301
- _ensureTable(db);
302
- var q = sql.select(_migrationsTable(), _sqlOpts(db))
300
+ var conn = _resolveDb(opts);
301
+ _ensureTable(conn);
302
+ var q = sql.select(_migrationsTable(), _sqlOpts(conn))
303
303
  .columns(["name", "description", "appliedAt"])
304
304
  .orderBy("appliedAt", "asc").orderBy("name", "asc").toSql();
305
- var stmt = db.prepare(q.sql);
305
+ var stmt = conn.prepare(q.sql);
306
306
  return stmt.all.apply(stmt, q.params);
307
307
  }
308
308
 
@@ -319,11 +319,11 @@ function create(opts) {
319
319
  }
320
320
 
321
321
  function up() {
322
- var db = _resolveDb(opts);
323
- _ensureTable(db);
324
- return _withLock(db, opts, function () {
325
- var namesQ = sql.select(_migrationsTable(), _sqlOpts(db)).columns(["name"]).toSql();
326
- var namesStmt = db.prepare(namesQ.sql);
322
+ var conn = _resolveDb(opts);
323
+ _ensureTable(conn);
324
+ return _withLock(conn, opts, function () {
325
+ var namesQ = sql.select(_migrationsTable(), _sqlOpts(conn)).columns(["name"]).toSql();
326
+ var namesStmt = conn.prepare(namesQ.sql);
327
327
  var appliedSet = new Set(
328
328
  namesStmt.all.apply(namesStmt, namesQ.params)
329
329
  .map(function (r) { return r.name; })
@@ -336,12 +336,12 @@ function create(opts) {
336
336
  if (appliedSet.has(file)) { skipped.push(file); continue; }
337
337
  var mod = _loadMigration(file, dir);
338
338
  try {
339
- _txn(db, function () {
340
- mod.up(db);
341
- var insQ = sql.insert(_migrationsTable(), _sqlOpts(db))
339
+ _txn(conn, function () {
340
+ mod.up(conn);
341
+ var insQ = sql.insert(_migrationsTable(), _sqlOpts(conn))
342
342
  .values({ name: file, description: mod.description || "",
343
343
  appliedAt: new Date().toISOString() }).toSql();
344
- var insStmt = db.prepare(insQ.sql);
344
+ var insStmt = conn.prepare(insQ.sql);
345
345
  insStmt.run.apply(insStmt, insQ.params);
346
346
  });
347
347
  } catch (e) {
@@ -363,16 +363,16 @@ function create(opts) {
363
363
  "down: steps must be a positive integer (got " + opts2.steps + ")",
364
364
  true);
365
365
  }
366
- var db = _resolveDb(opts);
367
- _ensureTable(db);
368
- return _withLock(db, opts, function () {
366
+ var conn = _resolveDb(opts);
367
+ _ensureTable(conn);
368
+ return _withLock(conn, opts, function () {
369
369
  // Most-recent applied first (reverse chronological by appliedAt
370
370
  // then by name as a stable tiebreaker for fixtures with identical
371
371
  // timestamps). steps is a validated positive integer, so b.sql
372
372
  // inlines the LIMIT.
373
- var downQ = sql.select(_migrationsTable(), _sqlOpts(db)).columns(["name"])
373
+ var downQ = sql.select(_migrationsTable(), _sqlOpts(conn)).columns(["name"])
374
374
  .orderBy("appliedAt", "desc").orderBy("name", "desc").limit(steps).toSql();
375
- var downStmt = db.prepare(downQ.sql);
375
+ var downStmt = conn.prepare(downQ.sql);
376
376
  var rows = downStmt.all.apply(downStmt, downQ.params);
377
377
 
378
378
  var reverted = [];
@@ -386,10 +386,10 @@ function create(opts) {
386
386
  true);
387
387
  }
388
388
  try {
389
- _txn(db, function () {
390
- mod.down(db);
391
- var delQ = sql.delete(_migrationsTable(), _sqlOpts(db)).where("name", file).toSql();
392
- var delStmt = db.prepare(delQ.sql);
389
+ _txn(conn, function () {
390
+ mod.down(conn);
391
+ var delQ = sql.delete(_migrationsTable(), _sqlOpts(conn)).where("name", file).toSql();
392
+ var delStmt = conn.prepare(delQ.sql);
393
393
  delStmt.run.apply(delStmt, delQ.params);
394
394
  });
395
395
  } catch (e) {
@@ -278,6 +278,22 @@ function create(opts) {
278
278
  throw new ResolverError("resolver/bad-input",
279
279
  "query: unknown qtype '" + type + "'");
280
280
  }
281
+ // Canonicalize BEFORE keying. A U-label and its A-label are one DNS name,
282
+ // so keying on the raw string splits it across two cache entries and sends
283
+ // a U-label upstream that no zone answers for; the same pass refuses a name
284
+ // carrying an empty label rather than letting the wire encoder decide. The
285
+ // check lives in b.network.dns so this layer and the transport cannot
286
+ // disagree about what counts as a name.
287
+ // Translated, not propagated: b.network.dns raises DnsError/`dns/bad-host`,
288
+ // and every other input refusal on this API is a ResolverError with a
289
+ // `resolver/*` code. Letting the transport's error class out through the
290
+ // resolver's surface would leave a caller that classifies by code or class
291
+ // unable to recognize a bad name as bad input.
292
+ try { name = networkDns._validateHostShape(name, "resolver.query"); }
293
+ catch (e) {
294
+ throw new ResolverError("resolver/bad-input",
295
+ "query: " + ((e && e.message) || "name is not a valid DNS name"));
296
+ }
281
297
  var validate = qopts.validate === true;
282
298
  var key = _key(name, qtype);
283
299
 
@@ -588,30 +604,14 @@ async function _wireLookup(name, qtype, timeoutMs) {
588
604
  }
589
605
 
590
606
  // _encodeWireQuery — assemble a wire-format DNS query for (name, qtype).
591
- // Mirrors the encoder in network-dns.js but accepts an explicit qtype
592
- // (the existing function hardcodes A/AAAA based on family).
607
+ //
608
+ // The encoder itself lives in network-dns.js. This was a byte-for-byte copy of
609
+ // it, which meant the label validation added there — the 1..63 cap that stops a
610
+ // hostname putting a forged compression pointer in the question section, and
611
+ // the refusal to drop an empty label and ask for a neighbouring domain — would
612
+ // have had to be added twice to hold.
593
613
  function _encodeWireQuery(name, qtype) {
594
- var parts = name.split(".").filter(Boolean);
595
- var nameLen = 1;
596
- for (var i = 0; i < parts.length; i += 1) nameLen += 1 + Buffer.byteLength(parts[i], "ascii");
597
- var buf = Buffer.alloc(12 + nameLen + 4); // RFC 1035 §4.1.1 header (12) + question tail (4) + name
598
- var id = bCrypto.randomInt(0, 0x10000); // RFC 1035 §4.1.1 16-bit query ID space
599
- buf.writeUInt16BE(id, 0);
600
- buf.writeUInt16BE(0x0100, 2); // RFC 1035 §4.1.1 RD=1 flags
601
- buf.writeUInt16BE(1, 4); // RFC 1035 §4.1.1 qdcount
602
- var off = 12; // RFC 1035 §4.1.1 header end / question start
603
- for (var p = 0; p < parts.length; p += 1) {
604
- var s = parts[p];
605
- buf.writeUInt8(Buffer.byteLength(s, "ascii"), off);
606
- off += 1;
607
- off += buf.write(s, off, "ascii");
608
- }
609
- buf.writeUInt8(0, off);
610
- off += 1;
611
- buf.writeUInt16BE(qtype, off);
612
- off += 2; // RFC 1035 §4.1.2 QTYPE width
613
- buf.writeUInt16BE(1, off); // RFC 1035 §4.1.2 QCLASS=IN
614
- return buf;
614
+ return networkDns._encodeDnsQuery(name, qtype).buf;
615
615
  }
616
616
 
617
617
  function _minTtl(rrs) {
@@ -13,6 +13,7 @@ var C = require("./constants");
13
13
  var bCrypto = require("./crypto");
14
14
  var lazyRequire = require("./lazy-require");
15
15
  var safeBuffer = require("./safe-buffer");
16
+ var publicSuffix = require("./public-suffix");
16
17
  var safeUrl = require("./safe-url");
17
18
  var validateOpts = require("./validate-opts");
18
19
  var { defineClass } = require("./framework-error");
@@ -129,25 +130,82 @@ function _labelsOf(host) {
129
130
  // is one longer and encodes to identical wire bytes, so measuring the raw
130
131
  // string would refuse a maximum-length name written absolutely while accepting
131
132
  // it written relatively.
133
+ // Which character marks the root is not fixed: UTS #46 maps U+3002, U+FF0E and
134
+ // U+FF61 to ".", so `example。` is as absolute as `example.` and canonicalization
135
+ // turns one into the other. Every place that asks "does this name end in the
136
+ // root?" has to agree, or a spelling is accepted by one rule and refused by the
137
+ // next — the length cap measured a mapped marker as an extra character and
138
+ // refused a maximum-length name that its ASCII spelling passes.
139
+ function _endsWithRootMarker(host) {
140
+ if (typeof host !== "string" || host.length < 2) return false;
141
+ // Ask the primitive that owns the set. A second copy of these four characters
142
+ // drifts from it, and this module already learned that lesson once — the
143
+ // whole point of routing name questions through b.publicSuffix.
144
+ return publicSuffix._isRootMarker(host.charAt(host.length - 1));
145
+ }
146
+
132
147
  function _hostLengthWithoutRoot(host) {
133
148
  if (typeof host !== "string") return 0;
134
- return (host.length > 1 && host.charAt(host.length - 1) === ".")
135
- ? host.length - 1
136
- : host.length;
149
+ return _endsWithRootMarker(host) ? host.length - 1 : host.length;
137
150
  }
138
151
 
139
152
  function _validateHostShape(host, primitive) {
140
153
  if (typeof host !== "string" || host.length === 0) return host;
141
154
  if (net.isIP(host)) return host;
142
- var labels = _labelsOf(host);
143
- for (var i = 0; i < labels.length; i += 1) {
144
- if (labels[i].length === 0) {
145
- throw new DnsError("dns/bad-host",
146
- primitive + ": host " + JSON.stringify(host) + " has an empty label — a " +
147
- "name may carry one trailing root dot and no other empty label");
148
- }
149
- }
150
- return host;
155
+ // The root zone. `. NS` is how the root servers are asked for, and the name
156
+ // encodes as the empty label list, which the wire encoder already handles.
157
+ // It is the one name that is nothing BUT a root marker, so it must not reach
158
+ // `canonicalDomain` — that refuses a bare root, correctly, since for every
159
+ // other name a lone marker means the name went missing.
160
+ //
161
+ // Any of the four spellings, asked of the primitive that owns the set rather
162
+ // than compared against a copy of it: this module already treats U+3002 /
163
+ // U+FF0E / U+FF61 as equivalent to "." at the END of a name, so a root zone
164
+ // that depended on which one the caller typed would be the same name
165
+ // resolving through one spelling and not another. All four normalize to the
166
+ // ASCII form, as every other name here does.
167
+ if (host.length === 1 && publicSuffix._isRootMarker(host)) return ".";
168
+ // The same rules the wire encoder enforces, applied at the entry point so the
169
+ // caller is told which primitive refused the name. Checking only here would
170
+ // leave the encoder reachable from paths that do not pass through a public
171
+ // entry; checking only there would name no primitive.
172
+ _dnsQueryLabels(host, primitive);
173
+ // No short-circuit for ASCII. Returning such a name unchanged kept whatever
174
+ // case it was typed in, so `Example.COM` and `example.com` took separate
175
+ // resolver-cache entries and made separate upstream queries while putting
176
+ // byte-identical questions on the wire — the encoder lowercases either way.
177
+ // One canonical form for every alphabet is what makes the cache key the NAME
178
+ // rather than a spelling of it.
179
+ //
180
+ // An internationalized name is returned in its A-label form so that every
181
+ // reader downstream sees the same name the wire will carry. Converting only
182
+ // at the encoder would let `resolve4` reach the domain while `resolveSecure`
183
+ // and `querySvcb` refused it at their LDH pass, which has no reading of a
184
+ // U-label — the same domain resolving through one entry point and not
185
+ // another.
186
+ //
187
+ // The root marker is carried across rather than dropped: a resolver reads it
188
+ // as "already fully qualified, do not apply the search list", and under an
189
+ // elevated ndots losing it can resolve — and cache — a different name
190
+ // entirely.
191
+ //
192
+ // The marker is NOT removed here. canonicalDomain strips exactly one and
193
+ // refuses a doubled one, and a strip on this side is invisible to it — it
194
+ // would take the first marker, canonicalDomain would take the second, and a
195
+ // name with an empty final label would be quietly rewritten into a real,
196
+ // separately-owned one and then cached under it. `example.com。。` resolved as
197
+ // `example.com` that way, while the plain `example.com..` spelling of the
198
+ // same mistake was still refused.
199
+ //
200
+ // Absoluteness is settled by ASKING canonicalDomain rather than by reading
201
+ // the last character: UTS #46 DELETES 294 code points outright (U+00AD,
202
+ // U+200B, U+FEFF, U+2060, the variation selectors), so `example.com.` with
203
+ // one of them appended still ends in a root the raw character does not show.
204
+ // Appending one more marker makes a doubled one, which canonicalDomain
205
+ // refuses — so a refusal here means the name already carried its root.
206
+ var absolute = publicSuffix.canonicalDomain(host + ".") === "";
207
+ var ascii = _dnsToALabel(host, primitive);
208
+ return absolute ? ascii + "." : ascii;
151
209
  }
152
210
 
153
211
  // RFC 6761 special-form classification works on the label LIST, so the
@@ -427,8 +485,99 @@ function _armRequestTimeout(req, ms, host, reject) {
427
485
  });
428
486
  }
429
487
 
488
+ // RFC 1035 §2.3.4 — a label is 1..63 octets and an encoded name is at most 255.
489
+ var DNS_MAX_LABEL_OCTETS = 63;
490
+ var DNS_MAX_NAME_OCTETS = 255;
491
+
492
+ // Split a host into the labels the wire encoder will write, refusing anything
493
+ // the wire format cannot express. Every rejection here is a name that WOULD
494
+ // have been encoded as some other question:
495
+ //
496
+ // - An empty label. Dropping it turns `evil..example.com` into the real,
497
+ // separately-owned `evil.example.com`, so the query asks for a name the
498
+ // caller never passed and the answer is cached under the one they did.
499
+ // - A label over 63 octets. Its length goes into a single octet whose top two
500
+ // bits RFC 1035 §4.1.4 reserves: `11` marks a compression POINTER and `01`
501
+ // an unassigned label type (RFC 6891 §3). Writing the real length lets a
502
+ // 192-octet label put a forged pointer in the question section, aiming the
503
+ // upstream resolver's name parser at an offset the hostname chose.
504
+ // - A name over 255 octets encoded.
505
+ // One trailing root dot is legal and is the only thing removed; the bare root
506
+ // encodes as the empty label list it already was.
507
+ //
508
+ // An internationalized name is CONVERTED rather than refused. The wire carries
509
+ // A-labels, and `Buffer.write(s, "ascii")` keeps only a character's low byte,
510
+ // so writing a U-label directly would query a different name — but refusing one
511
+ // would fail a name that resolves perfectly well in its `xn--` form, which is
512
+ // how its owner published it.
513
+ // `publicSuffix.canonicalDomain` owns this conversion: raw `domainToASCII`
514
+ // TRUNCATES at a URL delimiter ("a.com/evil" -> "a.com"), which would turn a
515
+ // string that is not a bare host into a name the caller never asked for, and
516
+ // canonicalDomain refuses those instead. It also refuses a name carrying an
517
+ // empty label, which is the same answer this function gives for an ASCII one.
518
+ function _dnsToALabel(host, primitive) {
519
+ var ascii = publicSuffix.canonicalDomain(host);
520
+ if (!ascii) {
521
+ throw new DnsError("dns/bad-host",
522
+ primitive + ": internationalized host has no A-label (xn--) form");
523
+ }
524
+ return ascii;
525
+ }
526
+
527
+ function _dnsQueryLabels(host, primitive) {
528
+ var h = String(host);
529
+ if (h.length === 0 || h === ".") return [];
530
+ // EVERY name goes through the domain primitive, ASCII or not. This side used
531
+ // to keep its own, shorter idea of a valid name — empty label, non-ASCII,
532
+ // label 1..63, 255 total — and each character it did not think of was one it
533
+ // encoded into a query label while b.publicSuffix refused the same string
534
+ // outright: a NUL byte, every URL delimiter, and `%`, `^`, `|`, `<` besides.
535
+ // A delimiter is the one that bites, because `domainToASCII` TRUNCATES at
536
+ // one, so `example.com/evil` can masquerade as a trusted prefix of itself.
537
+ //
538
+ // Mirroring the rule was tried first, and the list of near-misses above is
539
+ // what that produced. Asking the owner is the version that cannot drift.
540
+ var canonical = publicSuffix.canonicalDomain(h);
541
+ if (!canonical) {
542
+ throw new DnsError("dns/bad-host",
543
+ primitive + ": host is not a valid domain name (empty label, control " +
544
+ "byte, URL delimiter, or over the RFC 1035 length ceiling)");
545
+ }
546
+ h = canonical;
547
+ // The label caps below still belong here: canonicalDomain bounds the whole
548
+ // NAME at 253 octets but says nothing about a single label, and 1..63 is what
549
+ // the wire format can express — a longer one writes a length octet the
550
+ // receiving parser reads as a compression pointer.
551
+ var labels = h.split(".");
552
+ var total = 1;
553
+ for (var i = 0; i < labels.length; i += 1) {
554
+ var len = Buffer.byteLength(labels[i], "utf8");
555
+ if (labels[i].length === 0) {
556
+ throw new DnsError("dns/bad-host",
557
+ primitive + ": host has an empty label — a name may carry one trailing " +
558
+ "root dot and no other empty label");
559
+ }
560
+ if (len !== labels[i].length) {
561
+ throw new DnsError("dns/bad-host",
562
+ primitive + ": host label is not ASCII and has no A-label (xn--) form");
563
+ }
564
+ if (len > DNS_MAX_LABEL_OCTETS) {
565
+ throw new DnsError("dns/bad-host",
566
+ primitive + ": host label is " + len + " octets (RFC 1035 allows 1.." +
567
+ DNS_MAX_LABEL_OCTETS + ")");
568
+ }
569
+ total += 1 + len;
570
+ }
571
+ if (total > DNS_MAX_NAME_OCTETS) {
572
+ throw new DnsError("dns/bad-host",
573
+ primitive + ": host encodes to " + total + " octets (RFC 1035 allows at most " +
574
+ DNS_MAX_NAME_OCTETS + ")");
575
+ }
576
+ return labels;
577
+ }
578
+
430
579
  function _encodeDnsQuery(host, qtype) {
431
- var parts = host.split(".").filter(Boolean);
580
+ var parts = _dnsQueryLabels(host, "dns");
432
581
  var nameLen = 1;
433
582
  for (var i = 0; i < parts.length; i++) nameLen += 1 + Buffer.byteLength(parts[i], "ascii");
434
583
  var buf = Buffer.alloc(12 + nameLen + 4);
@@ -676,29 +825,9 @@ async function resolveSecure(host, type) {
676
825
  "resolveSecure requires DoH transport (call useDnsOverHttps " +
677
826
  "or rely on the default-on DoH posture)");
678
827
  }
679
- if (typeof host !== "string" || host.length === 0 ||
680
- _hostLengthWithoutRoot(host) > 253) { // RFC 1035 hostname octet ceiling
681
- throw new DnsError("dns/bad-host",
682
- "resolveSecure host is malformed");
683
- }
684
- // RFC 1035 §2.3.4 LDH validation — labels are letters / digits /
685
- // hyphen, hyphens not at edges, label length 1..63, total length
686
- // 253. Pre-v0.8.32 the framework only checked total length;
687
- // operator-supplied hosts containing `_` / `:` / spaces flowed
688
- // through to the DoH endpoint and surfaced as opaque server
689
- // errors.
690
- var labels = _labelsOf(host);
691
- for (var li = 0; li < labels.length; li += 1) {
692
- var label = labels[li];
693
- if (label.length === 0 || label.length > 63) { // RFC 1035 max label length
694
- throw new DnsError("dns/bad-host",
695
- "resolveSecure host has invalid label (length 1..63 required, got " + label.length + ")");
696
- }
697
- if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label)) {
698
- throw new DnsError("dns/bad-host",
699
- "resolveSecure host label '" + label + "' violates RFC 1035 LDH rule (letters/digits/hyphen, no leading/trailing hyphen)");
700
- }
701
- }
828
+ // An operator-supplied host containing `_` / `:` / a space would otherwise
829
+ // flow through to the DoH endpoint and surface as an opaque server error.
830
+ _validateLdh(host, "resolveSecure", false);
702
831
  var family;
703
832
  if (type === "A") family = 4;
704
833
  else if (type === "AAAA") family = 6;
@@ -1316,14 +1445,27 @@ function _parseSvcbRdata(msg, rdataOff, rdlen) {
1316
1445
  return { priority: priority, target: target, params: params };
1317
1446
  }
1318
1447
 
1319
- function _validateLdh(host, primitive) {
1448
+ // RFC 1035 §2.3.4 LDH validation — labels are letters / digits / hyphen, with
1449
+ // no hyphen at either edge. `allowUnderscore` additionally admits the leading
1450
+ // underscore that SVCB / HTTPS query targets carry ("_dns.resolver.arpa");
1451
+ // resolveSecure resolves ordinary hostnames and does not want it.
1452
+ //
1453
+ // This lives in one place because it did not: resolveSecure carried its own
1454
+ // copy of the loop, so the two drifted on which characters they accepted and a
1455
+ // rule added to either held for only half the primitives that need it.
1456
+ function _validateLdh(host, primitive, allowUnderscore) {
1320
1457
  if (typeof host !== "string" || host.length === 0 ||
1321
1458
  _hostLengthWithoutRoot(host) > 253) { // RFC 1035 hostname octet ceiling
1322
1459
  throw new DnsError("dns/bad-host",
1323
1460
  primitive + ": host must be a non-empty RFC 1035 LDH name (length 1..253)");
1324
1461
  }
1325
- // Allow leading underscore on labels (SVCB / HTTPS query targets like
1326
- // "_dns.resolver.arpa" require it).
1462
+ // The root zone has no labels at all `_labelsOf(".")` yields one empty
1463
+ // string, which the 1..63 rule below refuses. Without this, a name the shape
1464
+ // check accepts is turned away here, so `. NS` works through the wire
1465
+ // resolver and not through `querySvcb` / `queryHttps` / `resolveSecure`: the
1466
+ // same name resolving through one entry point and not another, which is the
1467
+ // defect this module already fixed once for internationalized names.
1468
+ if (host === ".") return;
1327
1469
  var labels = _labelsOf(host);
1328
1470
  for (var li = 0; li < labels.length; li += 1) {
1329
1471
  var label = labels[li];
@@ -1331,9 +1473,13 @@ function _validateLdh(host, primitive) {
1331
1473
  throw new DnsError("dns/bad-host",
1332
1474
  primitive + ": host label length must be 1..63");
1333
1475
  }
1334
- if (!/^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$/.test(label)) {
1476
+ var shaped = allowUnderscore
1477
+ ? /^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$/.test(label)
1478
+ : /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label);
1479
+ if (!shaped) {
1335
1480
  throw new DnsError("dns/bad-host",
1336
- primitive + ": host label '" + label + "' violates LDH (allowed: letters/digits/underscore/hyphen, no leading/trailing hyphen)");
1481
+ primitive + ": host label '" + label + "' violates LDH (allowed: letters/digits" +
1482
+ (allowUnderscore ? "/underscore" : "") + "/hyphen, no leading/trailing hyphen)");
1337
1483
  }
1338
1484
  }
1339
1485
  }
@@ -1342,7 +1488,7 @@ async function _querySvcbLike(host, qtype, opts) {
1342
1488
  host = _validateHostShape(host, "dns.querySvcb");
1343
1489
  opts = opts || {};
1344
1490
  validateOpts(opts, ["transport"], "dns.querySvcb");
1345
- _validateLdh(host, "dns.querySvcb");
1491
+ _validateLdh(host, "dns.querySvcb", true); // SVCB targets carry a leading underscore
1346
1492
  if (opts.transport !== undefined && opts.transport !== "doh" &&
1347
1493
  opts.transport !== "dot" && opts.transport !== "system") {
1348
1494
  throw new DnsError("dns/bad-transport",
@@ -1488,7 +1634,16 @@ async function discoverEncrypted(opts) {
1488
1634
  }
1489
1635
  var insecureOnly = opts.insecureSystemResolverOnly !== false;
1490
1636
  var transport = insecureOnly ? "system" : undefined;
1491
- _validateLdh(name, "dns.discoverEncrypted");
1637
+ // Canonicalize BEFORE the LDH pass. An LDH rule has no reading of a U-label,
1638
+ // so running it on the raw name refuses an internationalized one here while
1639
+ // the SVCB query underneath accepts it — the same name resolving through one
1640
+ // entry point and not its own caller.
1641
+ // Canonicalize BEFORE the LDH pass. An LDH rule has no reading of a U-label,
1642
+ // so running it on the raw name refuses an internationalized one here while
1643
+ // the SVCB query underneath accepts it — the same name resolving through one
1644
+ // entry point and not its own caller.
1645
+ name = _validateHostShape(name, "dns.discoverEncrypted");
1646
+ _validateLdh(name, "dns.discoverEncrypted", true); // "_dns.resolver.arpa" per RFC 9462
1492
1647
  var startMs = _now();
1493
1648
  var records;
1494
1649
  try {
@@ -2107,6 +2262,13 @@ module.exports = {
2107
2262
  nodeLookup: nodeLookup,
2108
2263
  clearCache: _clearCache,
2109
2264
  DnsError: DnsError,
2265
+ _encodeDnsQuery: _encodeDnsQuery,
2266
+ _validateHostShape: _validateHostShape,
2267
+ // Exported for the same reason as the shape check: it is the SECOND gate a
2268
+ // public query passes, and a name that clears one and not the other resolves
2269
+ // through some entry points and not others. Asserting it directly proves that
2270
+ // without a test having to reach the network.
2271
+ _validateLdh: _validateLdh,
2110
2272
  _parseSvcbRdata: _parseSvcbRdata,
2111
2273
  _decodeDnsAnswerRaw: _decodeDnsAnswerRaw,
2112
2274
  _readDnsName: _readDnsName,