@blamejs/core 0.18.39 → 0.18.40

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.
@@ -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,
@@ -74,6 +74,38 @@ function _err(code, message) {
74
74
  return new PublicSuffixError(code, message);
75
75
  }
76
76
 
77
+ // The index of the first character that cannot appear in a host name, or -1.
78
+ //
79
+ // Control / NUL / whitespace bytes, DEL, and the URL-structural delimiters
80
+ // domainToASCII silently TRUNCATES at — "/" (0x2F), "?" (0x3F), "#" (0x23),
81
+ // "\" (0x5C) reduce "example.com/evil" to "example.com" rather than failing,
82
+ // which would let a hostile host masquerade as a trusted prefix. ":" / "@" /
83
+ // "[" / "]" already make domainToASCII return "", but they are rejected here
84
+ // too so every non-host character fails closed rather than silently.
85
+ //
86
+ // Exported because it is the framework's definition of "a character that may
87
+ // appear in a host", and the DNS wire encoder needs the same answer. When it
88
+ // had its own — a shorter one — `b.network.dns` encoded `a\u0000.com` and
89
+ // `example.com/evil` into query labels that this module refuses outright.
90
+ function _firstNonHostCharacter(name) {
91
+ for (var i = 0; i < name.length; i += 1) {
92
+ var cp = name.charCodeAt(i);
93
+ if (cp < 0x21 || cp === 0x7f ||
94
+ cp === 0x2f || cp === 0x3f || cp === 0x23 || cp === 0x5c || // / ? # \
95
+ cp === 0x3a || cp === 0x40 || cp === 0x5b || cp === 0x5d) { // : @ [ ]
96
+ return i;
97
+ }
98
+ }
99
+ return -1;
100
+ }
101
+
102
+ // The characters UTS #46 treats as a label separator, and therefore the ones
103
+ // that can mark the root of an absolute name. Scanned against Node's own
104
+ // mapping and found to be exactly this set across the BMP and the SMP.
105
+ function _isRootMarker(ch) {
106
+ return ch === "." || ch === "。" || ch === "." || ch === "。";
107
+ }
108
+
77
109
  // _normalizeInput — lowercase + IDN-normalize a candidate domain.
78
110
  // Returns a plain ASCII (punycode) string with no leading/trailing
79
111
  // dots and no empty labels. Throws PublicSuffixError on bad shape so
@@ -87,36 +119,41 @@ function _normalizeInput(domain) {
87
119
  throw _err("public-suffix/invalid-domain",
88
120
  "publicSuffix: domain must not be empty");
89
121
  }
90
- if (domain.length > 253) {
91
- // RFC 1035 §2.3.4 253 octets max for the wire form (255 minus
92
- // length-byte + null). Anything longer is structurally invalid.
93
- throw _err("public-suffix/invalid-domain",
94
- "publicSuffix: domain exceeds 253-octet RFC 1035 limit");
95
- }
96
- // Strip a single trailing dot (FQDN form). Multiple trailing dots,
97
- // leading dots, or embedded empty labels remain rejected below.
122
+ // Strip a single trailing root marker (FQDN form) BEFORE measuring. The
123
+ // absolute and relative spellings of one name encode to identical wire
124
+ // bytes, so measuring the marker would refuse a maximum-length name written
125
+ // absolutely while accepting it written relatively.
126
+ //
127
+ // Which character marks the root is not fixed: UTS #46 maps U+3002, U+FF0E
128
+ // and U+FF61 to "." as well, so `münchen.example。` is absolute too.
129
+ // Recognising all four here rather than only the ASCII dot is what lets the
130
+ // `rootStripped` guard below hold — exactly one marker comes off in total,
131
+ // and a second is an empty final label rather than something to remove.
132
+ //
133
+ // A marker that only APPEARS during conversion — because an IDNA-ignored
134
+ // code point trailed it — is handled by the post-conversion strip below,
135
+ // which the same guard keeps mutually exclusive with this one.
98
136
  var s = domain.toLowerCase();
99
- if (s.charCodeAt(s.length - 1) === 46 /* "." */) {
137
+ var rootStripped = false;
138
+ if (_isRootMarker(s.charAt(s.length - 1))) {
100
139
  s = s.slice(0, -1);
140
+ rootStripped = true;
101
141
  if (s.length === 0) {
102
142
  throw _err("public-suffix/invalid-domain",
103
143
  "publicSuffix: domain must not be a bare dot");
104
144
  }
105
145
  }
106
- // Reject control / null / whitespace bytes AND the URL-structural delimiters
107
- // domainToASCII silently TRUNCATES at "/" (0x2F), "?" (0x3F), "#" (0x23),
108
- // "\" (0x5C) reduce "example.com/evil" to "example.com" rather than failing,
109
- // which would let a hostile host masquerade as a trusted prefix. ":" / "@" /
110
- // "[" / "]" already make domainToASCII return "" (caught below), but reject
111
- // them here too so every non-host character fails closed, not silently.
112
- for (var i = 0; i < s.length; i += 1) {
113
- var cp = s.charCodeAt(i);
114
- if (cp < 0x21 || cp === 0x7f ||
115
- cp === 0x2f || cp === 0x3f || cp === 0x23 || cp === 0x5c || // / ? # \
116
- cp === 0x3a || cp === 0x40 || cp === 0x5b || cp === 0x5d) { // : @ [ ]
117
- throw _err("public-suffix/invalid-domain",
118
- "publicSuffix: domain contains a control byte or URL delimiter");
119
- }
146
+ if (s.length > 253) {
147
+ // A cheap bound on the INPUT, so a pathological string is refused before
148
+ // conversion. It is not the authoritative check: an internationalized name
149
+ // grows when it becomes A-labels, so the real test is on the converted form
150
+ // below. Every ASCII name is settled here, since conversion leaves it as-is.
151
+ throw _err("public-suffix/invalid-domain",
152
+ "publicSuffix: domain exceeds 253-octet RFC 1035 limit");
153
+ }
154
+ if (_firstNonHostCharacter(s) !== -1) {
155
+ throw _err("public-suffix/invalid-domain",
156
+ "publicSuffix: domain contains a control byte or URL delimiter");
120
157
  }
121
158
  // IDN-normalize — non-ASCII labels become xn--… via Node's UTS #46
122
159
  // implementation. Empty string back means the input was malformed
@@ -126,11 +163,55 @@ function _normalizeInput(domain) {
126
163
  throw _err("public-suffix/invalid-domain",
127
164
  "publicSuffix: domain failed IDN normalization");
128
165
  }
129
- // No empty labels (`foo..bar`) and no leading dot.
166
+ // No empty labels (`foo..bar`) and no leading dot. This runs BEFORE the
167
+ // root-marker strip below, so a name carrying an empty final label cannot be
168
+ // turned into a valid one by removing a dot: `münchen.example。。` converts to
169
+ // a trailing `..` and is refused here rather than quietly becoming a
170
+ // different, real domain.
130
171
  if (ascii.indexOf("..") !== -1 || ascii.charCodeAt(0) === 46) {
131
172
  throw _err("public-suffix/invalid-domain",
132
173
  "publicSuffix: domain contains empty label");
133
174
  }
175
+ // RFC 1035 §2.3.4 — 253 octets max for the wire form (255 minus the leading
176
+ // length byte and the root's null). This is the AUTHORITATIVE check, and it
177
+ // has to run on the converted name: an internationalized label grows into its
178
+ // `xn--` form, so five 44-character labels are 224 characters going in and
179
+ // 254 octets coming out, with every individual label a legal 50. Measuring
180
+ // the input handed the caller a name that cannot be put on the wire, and a
181
+ // caller cannot tell — it looks like any other domain, and the DMARC walk
182
+ // stepped over the unqueryable target and applied an ancestor's policy.
183
+ //
184
+ // A trailing root marker is still present at this point and does not count:
185
+ // the wire form carries the root as a zero-length label, not a character.
186
+ var withoutRoot = ascii.charCodeAt(ascii.length - 1) === 46
187
+ ? ascii.length - 1 : ascii.length;
188
+ if (withoutRoot > 253) {
189
+ throw _err("public-suffix/invalid-domain",
190
+ "publicSuffix: domain exceeds 253-octet RFC 1035 limit once converted " +
191
+ "to A-labels (" + withoutRoot + " octets)");
192
+ }
193
+ // The root marker is stripped here when it was not an ASCII dot on the way
194
+ // in. UTS #46 maps U+3002, U+FF0E and U+FF61 to ".", so `münchen.example。`
195
+ // arrives with no trailing dot and leaves the conversion with one. Returning
196
+ // that from a function whose contract is to strip the trailing dot leaves
197
+ // every caller to compensate, and the ones that do not compare two spellings
198
+ // of the same absolute name as different names.
199
+ //
200
+ // At most ONE root marker is removed in total. A dot still here after one was
201
+ // already taken off means the name ended in two of them — an empty final
202
+ // label — and stripping the second would hand the caller a different, valid
203
+ // domain than the one they asked about.
204
+ if (ascii.charCodeAt(ascii.length - 1) === 46 /* "." */) {
205
+ if (rootStripped) {
206
+ throw _err("public-suffix/invalid-domain",
207
+ "publicSuffix: domain contains empty label");
208
+ }
209
+ ascii = ascii.slice(0, -1);
210
+ if (ascii.length === 0) {
211
+ throw _err("public-suffix/invalid-domain",
212
+ "publicSuffix: domain must not be a bare dot");
213
+ }
214
+ }
134
215
  return ascii;
135
216
  }
136
217
 
@@ -462,4 +543,9 @@ module.exports = {
462
543
  canonicalDomain: canonicalDomain,
463
544
  isPublicSuffix: isPublicSuffix,
464
545
  lookupSource: lookupSource,
546
+ _firstNonHostCharacter: _firstNonHostCharacter,
547
+ // Exported for the same reason as the character predicate above: this is the
548
+ // framework's answer to "which characters mark the root of an absolute name",
549
+ // and a second copy of the set drifts from it.
550
+ _isRootMarker: _isRootMarker,
465
551
  };
@@ -21,7 +21,7 @@
21
21
  "server": "sha256:f3325f480cb8eb814fcb0baaa19336cbbf2b993f48624c6aa9600ffd69d0be5e",
22
22
  "browser": "sha256:0ffd91540bcb586a29b56e52ee1c29df69097b50776beb4036a07558f7a4e12e"
23
23
  },
24
- "refreshedAt": "2026-08-19T07:22:41.105Z"
24
+ "refreshedAt": "2026-08-20T04:54:41.606Z"
25
25
  },
26
26
  "@noble/hashes": {
27
27
  "version": "2.3.0",
@@ -48,7 +48,7 @@
48
48
  "hashes": {
49
49
  "browser": "sha256:dfe4b7ae3c9880e388c8da4b68f44742b229b53afacd1e674179527e33da62b0"
50
50
  },
51
- "refreshedAt": "2026-08-19T07:22:41.105Z"
51
+ "refreshedAt": "2026-08-20T04:54:41.606Z"
52
52
  },
53
53
  "@noble/curves": {
54
54
  "version": "2.3.0",
@@ -70,7 +70,7 @@
70
70
  "hashes": {
71
71
  "server": "sha256:b5fe88d1ea780d0581dee6145d666f89d46fc9531b5db35db2e5b16627840890"
72
72
  },
73
- "refreshedAt": "2026-08-19T07:22:41.105Z",
73
+ "refreshedAt": "2026-08-20T04:54:41.606Z",
74
74
  "components": {
75
75
  "@noble/hashes": {
76
76
  "url": "https://github.com/paulmillr/noble-hashes",
@@ -114,7 +114,7 @@
114
114
  "server": "sha256:fab7ebe5737793862c473444f4ee5912f79dd1edec86683acbb4eecbca0f5892",
115
115
  "browser": "sha256:cae1d5bbdc7184b202b6ca68df6e1db7b0d0f668c77809ded189ca7f271accc9"
116
116
  },
117
- "refreshedAt": "2026-08-19T07:22:41.105Z",
117
+ "refreshedAt": "2026-08-20T04:54:41.606Z",
118
118
  "components": {
119
119
  "@noble/hashes": {
120
120
  "url": "https://github.com/paulmillr/noble-hashes",
@@ -148,7 +148,7 @@
148
148
  },
149
149
  "runtime_artifact": "lib/vendor/common-passwords-top-10000.data.js",
150
150
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
151
- "refreshedAt": "2026-08-19T07:22:41.105Z"
151
+ "refreshedAt": "2026-08-20T04:54:41.606Z"
152
152
  },
153
153
  "bimi-trust-anchors": {
154
154
  "version": "operator-managed",
@@ -173,7 +173,7 @@
173
173
  },
174
174
  "runtime_artifact": "lib/vendor/bimi-trust-anchors.data.js",
175
175
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
176
- "refreshedAt": "2026-08-19T07:22:41.105Z"
176
+ "refreshedAt": "2026-08-20T04:54:41.606Z"
177
177
  },
178
178
  "publicsuffix-list": {
179
179
  "version": "master",
@@ -186,17 +186,17 @@
186
186
  "data_js": "lib/vendor/public-suffix-list.data.js"
187
187
  },
188
188
  "bundler": "curl https://publicsuffix.org/list/public_suffix_list.dat",
189
- "bundledAt": "2026-08-17T00:00:00Z",
189
+ "bundledAt": "2026-08-19T00:00:00Z",
190
190
  "hashes": {
191
- "server": "sha256:f7e24ebb8ef6a366c52d2abc66275f68f64255f2650cda0c506035a92cff049a",
192
- "data_js": "sha256:4b54a31c8697ee4509a3e1efbee3d155f276a29e15a135d550874024f4780266"
191
+ "server": "sha256:75142784c0308c8f7cd27f15fca80b9ddc09aeec05ae51c2f54fd0958351d9ad",
192
+ "data_js": "sha256:2435668e6d9964d95b283e5796938aa7c626ea0ca1eed4af8b1abec034f1761b"
193
193
  },
194
194
  "runtime_artifact": "lib/vendor/public-suffix-list.data.js",
195
195
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
196
- "refreshedAt": "2026-08-19T07:22:41.105Z"
196
+ "refreshedAt": "2026-08-20T04:54:41.606Z"
197
197
  },
198
198
  "@blamejs/pki": {
199
- "version": "0.5.11",
199
+ "version": "0.5.16",
200
200
  "license": "Apache-2.0",
201
201
  "author": "blamejs",
202
202
  "source": "https://github.com/blamejs/pki",
@@ -217,11 +217,11 @@
217
217
  },
218
218
  "bundler": "esbuild --format=cjs --platform=node --external:crypto --external:node:crypto",
219
219
  "bundledAt": "2026-08-19T00:00:00Z",
220
- "cpe": "cpe:2.3:a:blamejs:pki:0.5.11:*:*:*:*:node.js:*:*",
220
+ "cpe": "cpe:2.3:a:blamejs:pki:0.5.16:*:*:*:*:node.js:*:*",
221
221
  "hashes": {
222
- "server": "sha256:dfcaa10473eb0980958b1c6a4019114c69260191899edd5dc596605f21543249"
222
+ "server": "sha256:96861fcf18c319d2e984cf48aaa0e496e903e447c491c6efcee677e15e87c24e"
223
223
  },
224
- "refreshedAt": "2026-08-19T07:22:41.105Z"
224
+ "refreshedAt": "2026-08-20T04:54:41.606Z"
225
225
  }
226
226
  }
227
227
  }