From 8e32cd36aec302fe13829167ecbdc4479ac5910c Mon Sep 17 00:00:00 2001 From: Zihan Dai <99155080+PDGGK@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:30:30 +1000 Subject: [PATCH 1/2] Support IPv6 node URLs and filter wildcard redirect endpoints parseNodeUrls split each node URL on ":" and required exactly two parts, so an IPv6 endpoint such as "[::1]:6667" was rejected up front with "Invalid nodeUrl format", making the client unusable with IPv6 nodes. Parse the bracketed [ipv6]:port form (consistent with apache/iotdb#18162), keeping IPv4 and hostname parsing unchanged; a bare (unbracketed) IPv6 address remains rejected as ambiguous. Also ignore a server redirect recommendation whose address is a wildcard/listen-all address (0.0.0.0 / ::): the endpoint set from the server's redirectNode is consumed by Session.getAndClearLastRedirect() and then cached and connected to by the pool, so the guard is applied there. Add tests for both. Signed-off-by: Zihan Dai <99155080+PDGGK@users.noreply.github.com> --- src/client/Session.ts | 7 +++- src/utils/Config.ts | 49 +++++++++++++++++++------- src/utils/Errors.ts | 11 ++++++ tests/unit/Config.test.ts | 22 ++++++++++-- tests/unit/Errors.test.ts | 34 ++++++++++++++++++ tests/unit/SessionRedirect.test.ts | 55 ++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 tests/unit/Errors.test.ts create mode 100644 tests/unit/SessionRedirect.test.ts diff --git a/src/client/Session.ts b/src/client/Session.ts index 73d180d..ef6424a 100644 --- a/src/client/Session.ts +++ b/src/client/Session.ts @@ -29,7 +29,7 @@ import { registerClosable, unregisterClosable } from "../utils/ProcessCleanup"; import { SessionDataSet } from "./SessionDataSet"; import { RowRecord } from "./RowRecord"; import { BaseColumnDecoder, ColumnEncoding, Column } from "./ColumnDecoder"; -import { RedirectException } from "../utils/Errors"; +import { RedirectException, isWildcardAddress } from "../utils/Errors"; import { serializeTabletValuesFast, serializeTimestamps @@ -243,6 +243,11 @@ export class Session { getAndClearLastRedirect(): EndPoint | null { const redirect = this.lastRedirectEndpoint; this.lastRedirectEndpoint = null; + // Ignore a redirect to a wildcard/listen-all address (0.0.0.0 / ::), + // which is not a connectable remote endpoint (mirrors apache/iotdb#18162). + if (redirect && isWildcardAddress(redirect.host)) { + return null; + } return redirect; } diff --git a/src/utils/Config.ts b/src/utils/Config.ts index 05016f9..1131144 100644 --- a/src/utils/Config.ts +++ b/src/utils/Config.ts @@ -32,23 +32,48 @@ export interface InternalConfig extends Config { sqlDialect?: string; } +/** + * Parse a single "host:port" node URL into an EndPoint. Accepts the bracketed + * IPv6 form "[::1]:6667" (consistent with the [ipv6]:port endpoint format + * standardized in apache/iotdb#18162) as well as IPv4 and hostname URLs. A bare + * (unbracketed) IPv6 address with a port is ambiguous and rejected; the + * "[ipv6]:port" form must be used. + */ +function parseNodeUrl(url: string): EndPoint { + const trimmed = url.trim(); + let host: string; + let portStr: string; + if (trimmed.startsWith('[')) { + // Bracketed IPv6: [host]:port + const close = trimmed.indexOf(']'); + if (close === -1 || trimmed[close + 1] !== ':') { + throw new Error(`Invalid nodeUrl format: ${url}. Expected format: "[ipv6]:port"`); + } + host = trimmed.slice(1, close).trim(); + portStr = trimmed.slice(close + 2).trim(); + } else { + const idx = trimmed.indexOf(':'); + // A non-bracketed URL with more than one colon is a bare IPv6 address, + // which is ambiguous with a trailing port; it must be written as [ipv6]:port. + if (idx === -1 || idx !== trimmed.lastIndexOf(':')) { + throw new Error(`Invalid nodeUrl format: ${url}. Expected format: "host:port" (use "[ipv6]:port" for IPv6 addresses)`); + } + host = trimmed.slice(0, idx).trim(); + portStr = trimmed.slice(idx + 1).trim(); + } + const port = parseInt(portStr, 10); + if (!host || isNaN(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid nodeUrl format: ${url}. Host must be non-empty and port must be a valid number (1-65535)`); + } + return { host, port }; +} + /** * Parse nodeUrls from string array format (e.g., ["host1:6667", "host2:6668"]) * to EndPoint array format */ export function parseNodeUrls(nodeUrls: string[]): EndPoint[] { - return nodeUrls.map((url) => { - const parts = url.split(':'); - if (parts.length !== 2) { - throw new Error(`Invalid nodeUrl format: ${url}. Expected format: "host:port"`); - } - const host = parts[0].trim(); - const port = parseInt(parts[1].trim(), 10); - if (!host || isNaN(port) || port <= 0 || port > 65535) { - throw new Error(`Invalid nodeUrl format: ${url}. Host must be non-empty and port must be a valid number (1-65535)`); - } - return { host, port }; - }); + return nodeUrls.map(parseNodeUrl); } export interface Config { diff --git a/src/utils/Errors.ts b/src/utils/Errors.ts index 0d3581c..ce89307 100644 --- a/src/utils/Errors.ts +++ b/src/utils/Errors.ts @@ -19,6 +19,17 @@ import { EndPoint } from "./Config"; +/** + * A wildcard / listen-all address (IPv4 "0.0.0.0" or IPv6 "::") is not a + * connectable remote endpoint. When a server advertises one in a redirect + * recommendation, the client must ignore it and keep its current endpoint, + * consistent with apache/iotdb#18162. + */ +export function isWildcardAddress(host: string): boolean { + const h = host.trim().replace(/^\[/, "").replace(/\]$/, ""); + return h === "0.0.0.0" || h === "::" || h === "0:0:0:0:0:0:0:0"; +} + /** * Represents a redirect recommendation from the server. * Thrown when the server suggests a better endpoint for a device. diff --git a/tests/unit/Config.test.ts b/tests/unit/Config.test.ts index 1606f47..3f5c5c4 100644 --- a/tests/unit/Config.test.ts +++ b/tests/unit/Config.test.ts @@ -182,11 +182,29 @@ describe('parseNodeUrls', () => { test('Should handle whitespace in nodeUrls', () => { const nodeUrls = [' localhost : 6667 ']; - + const parsed = parseNodeUrls(nodeUrls); - + expect(parsed[0]).toEqual({ host: 'localhost', port: 6667 }); }); + + test('Should parse bracketed IPv6 nodeUrls', () => { + const parsed = parseNodeUrls(['[::1]:6667', '[2001:db8::1]:6668']); + + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual({ host: '::1', port: 6667 }); + expect(parsed[1]).toEqual({ host: '2001:db8::1', port: 6668 }); + }); + + test('Should throw for a bare (unbracketed) IPv6 address', () => { + expect(() => parseNodeUrls(['::1:6667'])).toThrow('Invalid nodeUrl format'); + }); + + test('Should throw for malformed bracketed IPv6', () => { + expect(() => parseNodeUrls(['[::1:6667'])).toThrow('Invalid nodeUrl format'); // unbalanced bracket + expect(() => parseNodeUrls(['[::1]6667'])).toThrow('Invalid nodeUrl format'); // missing colon before port + expect(() => parseNodeUrls(['[::1]:'])).toThrow('Invalid nodeUrl format'); // empty port + }); }); describe('ConfigBuilder', () => { diff --git a/tests/unit/Errors.test.ts b/tests/unit/Errors.test.ts new file mode 100644 index 0000000..bdc36fc --- /dev/null +++ b/tests/unit/Errors.test.ts @@ -0,0 +1,34 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isWildcardAddress } from "../../src/utils/Errors"; + +describe("isWildcardAddress", () => { + test("returns true for IPv4/IPv6 wildcard (listen-all) addresses", () => { + for (const h of ["0.0.0.0", "::", "0:0:0:0:0:0:0:0", "[::]", "[0.0.0.0]"]) { + expect(isWildcardAddress(h)).toBe(true); + } + }); + + test("returns false for real addresses", () => { + for (const h of ["127.0.0.1", "::1", "10.0.0.1", "example.com", "[::1]"]) { + expect(isWildcardAddress(h)).toBe(false); + } + }); +}); diff --git a/tests/unit/SessionRedirect.test.ts b/tests/unit/SessionRedirect.test.ts new file mode 100644 index 0000000..a4e8152 --- /dev/null +++ b/tests/unit/SessionRedirect.test.ts @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Session } from "../../src/client/Session"; + +function newSession(): Session { + return new Session({ + host: "localhost", + port: 6667, + username: "root", + password: "root", + }); +} + +// getAndClearLastRedirect is the single consumption point for the redirect +// endpoint the server advertises (Session.lastRedirectEndpoint), before the +// pool caches and connects to it — so the wildcard guard is exercised here. +describe("Session.getAndClearLastRedirect", () => { + test("returns a normal redirect endpoint and clears it", () => { + const session = newSession(); + (session as any).lastRedirectEndpoint = { host: "10.0.0.9", port: 6667 }; + + expect(session.getAndClearLastRedirect()).toEqual({ + host: "10.0.0.9", + port: 6667, + }); + // Cleared after reading. + expect(session.getAndClearLastRedirect()).toBeNull(); + }); + + test("ignores a redirect to a wildcard/listen-all address", () => { + for (const host of ["0.0.0.0", "::"]) { + const session = newSession(); + (session as any).lastRedirectEndpoint = { host, port: 6667 }; + + expect(session.getAndClearLastRedirect()).toBeNull(); + } + }); +}); From e62317527590a2a8f642f192ca9af9280d3cce9e Mon Sep 17 00:00:00 2001 From: Zihan Dai <99155080+PDGGK@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:49:45 +1000 Subject: [PATCH 2/2] Reject node URLs with a non-numeric port suffix parseNodeUrl used parseInt, which accepts a numeric prefix, so "[::1]:6667junk" and "[::1]:6667:9999" were silently parsed as port 6667. The port must now be an all-digits string before conversion. Adds regression tests for the bracketed, hostname and IPv4 forms. Addresses review feedback on #21. Signed-off-by: Zihan Dai <99155080+PDGGK@users.noreply.github.com> --- src/utils/Config.ts | 5 ++++- tests/unit/Config.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/utils/Config.ts b/src/utils/Config.ts index 1131144..3c574b6 100644 --- a/src/utils/Config.ts +++ b/src/utils/Config.ts @@ -61,7 +61,10 @@ function parseNodeUrl(url: string): EndPoint { host = trimmed.slice(0, idx).trim(); portStr = trimmed.slice(idx + 1).trim(); } - const port = parseInt(portStr, 10); + // Require the port to be all digits: parseInt would otherwise accept a numeric prefix, so + // "[::1]:6667junk" or "[::1]:6667:9999" would be silently read as port 6667 and mask a malformed + // endpoint (IoTDB's Java and Python parsers reject these because the whole value must convert). + const port = /^\d+$/.test(portStr) ? parseInt(portStr, 10) : NaN; if (!host || isNaN(port) || port <= 0 || port > 65535) { throw new Error(`Invalid nodeUrl format: ${url}. Host must be non-empty and port must be a valid number (1-65535)`); } diff --git a/tests/unit/Config.test.ts b/tests/unit/Config.test.ts index 3f5c5c4..254aedd 100644 --- a/tests/unit/Config.test.ts +++ b/tests/unit/Config.test.ts @@ -205,6 +205,15 @@ describe('parseNodeUrls', () => { expect(() => parseNodeUrls(['[::1]6667'])).toThrow('Invalid nodeUrl format'); // missing colon before port expect(() => parseNodeUrls(['[::1]:'])).toThrow('Invalid nodeUrl format'); // empty port }); + + test('Should throw for a port with trailing non-digits', () => { + // parseInt accepts a numeric prefix; the whole port string must be digits so a malformed + // endpoint is rejected instead of silently parsed as the leading number. + expect(() => parseNodeUrls(['[::1]:6667junk'])).toThrow('Invalid nodeUrl format'); // trailing text + expect(() => parseNodeUrls(['[::1]:6667:9999'])).toThrow('Invalid nodeUrl format'); // extra :port + expect(() => parseNodeUrls(['localhost:6667junk'])).toThrow('Invalid nodeUrl format'); // hostname + expect(() => parseNodeUrls(['127.0.0.1:80x'])).toThrow('Invalid nodeUrl format'); // IPv4 + }); }); describe('ConfigBuilder', () => {