diff --git a/src/net-util/export/HostInfo.js b/src/net-util/export/HostInfo.js index c0098b9c4..94534123c 100644 --- a/src/net-util/export/HostInfo.js +++ b/src/net-util/export/HostInfo.js @@ -73,10 +73,7 @@ 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]+$/); + this.#nameString = HostUtil.mustBeHostname(nameString); this.#portNumber = AskIf.string(portNumber, /^0*[0-9]{1,5}$/) ? Number(portNumber) 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 456cf26d7..c060629be 100644 --- a/src/net-util/tests/HostInfo.test.js +++ b/src/net-util/tests/HostInfo.test.js @@ -16,7 +16,14 @@ describe('constructor', () => { ${1} ${['x']} ${''} - ${'[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 +89,18 @@ 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. + ${'[a::b]'} // Bracketed IPv6 (brackets stripped on canonicalization). `('accepts valid host name $arg', ({ arg }) => { expect(() => new HostInfo(arg, 1)).not.toThrow(); }); @@ -120,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', () => {