From 1b58d0f92bdfb458c998b72ff18d438e144c9ad9 Mon Sep 17 00:00:00 2001 From: Jeremy Bornstein Date: Tue, 13 Jan 2026 07:52:58 +1100 Subject: [PATCH 1/2] Tighten hostname validation in HostInfo constructor. Replace lenient regex with stricter validation that properly handles: - IPv6 addresses (hex digits and colons) - IPv4 addresses (digits with dot separators) - DNS names (labels must start/end with alphanumeric, hyphens mid-label only) Rejects invalid patterns like leading/trailing dots, consecutive dots, and labels starting or ending with hyphens. --- src/net-util/export/HostInfo.js | 21 +++++++++++++++++---- src/net-util/tests/HostInfo.test.js | 25 ++++++++++++++++++++----- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/net-util/export/HostInfo.js b/src/net-util/export/HostInfo.js index c0098b9c4..560cd05da 100644 --- a/src/net-util/export/HostInfo.js +++ b/src/net-util/export/HostInfo.js @@ -73,10 +73,12 @@ export class HostInfo extends IntfDeconstructable { constructor(nameString, portNumber) { super(); - // Note: The regex is a bit lenient, though notably it _does_ at least - // guarantee that there are no uppercase letters. TODO: Maybe it should be - // more restrictive? - this.#nameString = MustBe.string(nameString, /^[-_.:a-z0-9]+$/); + // Validates canonicalized hostname formats: + // - IPv6: hex digits and colons (must contain at least one colon) + // - IPv4: digits with dot separators + // - DNS: labels separated by dots, each starting/ending with alphanumeric, + // hyphens allowed mid-label only + this.#nameString = MustBe.string(nameString, HostInfo.#NAME_REGEX); this.#portNumber = AskIf.string(portNumber, /^0*[0-9]{1,5}$/) ? Number(portNumber) @@ -223,6 +225,17 @@ export class HostInfo extends IntfDeconstructable { // Static members // + /** + * Regex for validating canonicalized hostname strings. Accepts: + * - IPv6 addresses (lowercase hex with colons) + * - IPv4 addresses (digits with dot separators) + * - DNS names (labels of alphanumerics/hyphens, starting and ending with + * alphanumeric, separated by dots) + * + * @type {RegExp} + */ + static #NAME_REGEX = /^(?:[a-f0-9]*:[a-f0-9:]*|[0-9]+(?:\.[0-9]+)*|[a-z0-9](?:[-a-z0-9]*[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)*)$/; + /** * Gets an instance of this class from a URL or the string form of same, * returning `null` if the diff --git a/src/net-util/tests/HostInfo.test.js b/src/net-util/tests/HostInfo.test.js index 456cf26d7..e7832c97d 100644 --- a/src/net-util/tests/HostInfo.test.js +++ b/src/net-util/tests/HostInfo.test.js @@ -16,7 +16,15 @@ describe('constructor', () => { ${1} ${['x']} ${''} - ${'[a::b]'} // IPv6 addresses must not use brackets. + ${'[a::b]'} // IPv6 addresses must not use brackets. + ${'--bad'} // Starts with hyphen. + ${'.leading'} // Starts with dot. + ${'trailing.'} // Ends with dot. + ${'double..dot'} // Consecutive dots. + ${'-start'} // Label starts with hyphen. + ${'end-'} // Label ends with hyphen. + ${'mid.-bad'} // Label starts with hyphen after dot. + ${'bad-.mid'} // Label ends with hyphen before dot. `('fails when passing name as $arg', ({ arg }) => { expect(() => new HostInfo(arg, 123)).toThrow(); }); @@ -82,10 +90,17 @@ describe('constructor', () => { arg ${'host'} ${'host.sub'} - ${'1.2.3.4'} - ${'01.02.03.04'} - ${'a:b::c:d'} - ${'0a:0123:0:0::987a'} + ${'a'} // Single character. + ${'localhost'} // Single label. + ${'my-host'} // Hyphen mid-label. + ${'a1.b2.c3'} // Alphanumeric labels. + ${'example.com'} // Typical domain. + ${'sub.example.com'} // Multi-level domain. + ${'1.2.3.4'} // IPv4 address. + ${'01.02.03.04'} // IPv4 with leading zeros. + ${'a:b::c:d'} // IPv6 address. + ${'0a:0123:0:0::987a'} // IPv6 with leading zeros. + ${'::1'} // IPv6 loopback. `('accepts valid host name $arg', ({ arg }) => { expect(() => new HostInfo(arg, 1)).not.toThrow(); }); From d1ec36f662e04077dafd6e3ae9133159cd660514 Mon Sep 17 00:00:00 2001 From: Jeremy Bornstein Date: Tue, 13 Jan 2026 09:30:57 +1100 Subject: [PATCH 2/2] Refactor hostname validation: add mustBeHostname() to HostUtil. Simplify mustBeHostname() to use canonicalizeHostnameElseNull() as base, per code review feedback. Now accepts bracketed IPv6 addresses (which are canonicalized to bracket-free form). --- src/net-util/export/HostInfo.js | 18 +-------- src/net-util/export/HostUtil.js | 58 ++++++++++++++++++++++++++--- src/net-util/tests/HostInfo.test.js | 8 +++- 3 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/net-util/export/HostInfo.js b/src/net-util/export/HostInfo.js index 560cd05da..94534123c 100644 --- a/src/net-util/export/HostInfo.js +++ b/src/net-util/export/HostInfo.js @@ -73,12 +73,7 @@ export class HostInfo extends IntfDeconstructable { constructor(nameString, portNumber) { super(); - // Validates canonicalized hostname formats: - // - IPv6: hex digits and colons (must contain at least one colon) - // - IPv4: digits with dot separators - // - DNS: labels separated by dots, each starting/ending with alphanumeric, - // hyphens allowed mid-label only - this.#nameString = MustBe.string(nameString, HostInfo.#NAME_REGEX); + this.#nameString = HostUtil.mustBeHostname(nameString); this.#portNumber = AskIf.string(portNumber, /^0*[0-9]{1,5}$/) ? Number(portNumber) @@ -225,17 +220,6 @@ export class HostInfo extends IntfDeconstructable { // Static members // - /** - * Regex for validating canonicalized hostname strings. Accepts: - * - IPv6 addresses (lowercase hex with colons) - * - IPv4 addresses (digits with dot separators) - * - DNS names (labels of alphanumerics/hyphens, starting and ending with - * alphanumeric, separated by dots) - * - * @type {RegExp} - */ - static #NAME_REGEX = /^(?:[a-f0-9]*:[a-f0-9:]*|[0-9]+(?:\.[0-9]+)*|[a-z0-9](?:[-a-z0-9]*[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)*)$/; - /** * Gets an instance of this class from a URL or the string form of same, * returning `null` if the diff --git a/src/net-util/export/HostUtil.js b/src/net-util/export/HostUtil.js index 56a593d9e..07f0e5875 100644 --- a/src/net-util/export/HostUtil.js +++ b/src/net-util/export/HostUtil.js @@ -149,27 +149,73 @@ export class HostUtil { static parseHostnameElseNull(name, allowWildcard = false) { MustBe.string(name); + const result = this.#pathArrayFromHostnameElseNull(name, allowWildcard); + + if (!result) { + return null; + } + + return new PathKey(result.pathArray, result.wildcard); + } + + /** + * Validates that the given value is a valid hostname string, returning it + * (canonicalized) if so. This accepts both DNS names and IP addresses + * (including bracketed IPv6), but not wildcards. + * + * @param {*} value Value to check. + * @returns {string} `value` canonicalized if it is a valid hostname. + * @throws {Error} Thrown if `value` is not a valid hostname string. + */ + static mustBeHostname(value) { + if (typeof value !== 'string') { + throw new Error(`Expected hostname string, got: ${typeof value}`); + } + + const result = this.canonicalizeHostnameElseNull(value, false); + + if (result === null) { + throw new Error(`Invalid hostname: ${value}`); + } + + return result; + } + + /** + * Private helper that validates a hostname and returns its path array + * representation, or `null` if invalid. + * + * @param {*} name Hostname to parse. + * @param {boolean} allowWildcard Is a wildcard form allowed? + * @returns {?{pathArray: string[], wildcard: boolean}} The path array and + * wildcard flag, or `null` if invalid. + */ + static #pathArrayFromHostnameElseNull(name, allowWildcard) { + if (typeof name !== 'string') { + return null; + } + // Handle IP address cases. const canonicalIp = EndpointAddress.canonicalizeAddressElseNull(name, false); if (canonicalIp) { - return new PathKey([canonicalIp], false); + return { pathArray: [canonicalIp], wildcard: false }; } if (!AskIf.string(name, this.#HOSTNAME_REGEX)) { return null; } - const path = name.toLowerCase().split('.').reverse(); + const pathArray = name.toLowerCase().split('.').reverse(); - if (path[path.length - 1] === '*') { + if (pathArray[pathArray.length - 1] === '*') { if (allowWildcard) { - path.pop(); - return new PathKey(path, true); + pathArray.pop(); + return { pathArray, wildcard: true }; } else { return null; } } else { - return new PathKey(path, false); + return { pathArray, wildcard: false }; } } } diff --git a/src/net-util/tests/HostInfo.test.js b/src/net-util/tests/HostInfo.test.js index e7832c97d..c060629be 100644 --- a/src/net-util/tests/HostInfo.test.js +++ b/src/net-util/tests/HostInfo.test.js @@ -16,7 +16,6 @@ describe('constructor', () => { ${1} ${['x']} ${''} - ${'[a::b]'} // IPv6 addresses must not use brackets. ${'--bad'} // Starts with hyphen. ${'.leading'} // Starts with dot. ${'trailing.'} // Ends with dot. @@ -101,6 +100,7 @@ describe('constructor', () => { ${'a:b::c:d'} // IPv6 address. ${'0a:0123:0:0::987a'} // IPv6 with leading zeros. ${'::1'} // IPv6 loopback. + ${'[a::b]'} // Bracketed IPv6 (brackets stripped on canonicalization). `('accepts valid host name $arg', ({ arg }) => { expect(() => new HostInfo(arg, 1)).not.toThrow(); }); @@ -135,6 +135,12 @@ describe('.nameString', () => { expect(hi.nameString).toBe(name); }); + + test('canonicalizes bracketed IPv6 to bracket-free form', () => { + const hi = new HostInfo('[a::b]', 123); + + expect(hi.nameString).toBe('a::b'); + }); }); describe('.namePortString', () => {