fix: reject invalid discovery ports

This commit is contained in:
Peter Steinberger
2026-05-28 15:23:47 -04:00
parent 76130fd988
commit 5eed10fd6e
2 changed files with 69 additions and 15 deletions
+47 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { runCommandWithTimeout } from "../process/exec.js";
import { discoverGatewayBeacons } from "./bonjour-discovery.js";
import { discoverGatewayBeacons, resolveGatewayDiscoveryEndpoint } from "./bonjour-discovery.js";
const WIDE_AREA_DOMAIN = "openclaw.internal.";
@@ -187,6 +187,52 @@ describe("bonjour-discovery", () => {
expect(beacon.txt?.displayName).toBe("Peters Mac Studio");
});
it("rejects malformed and out-of-range advertised ports", async () => {
const run = vi.fn(async (argv: string[]) => {
const domain = argv[3] ?? "";
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
return {
stdout: ["Add 2 3 local. _openclaw-gw._tcp. Broken Gateway", ""].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
if (argv[0] === "dns-sd" && argv[1] === "-L") {
return {
stdout: [
"Broken Gateway._openclaw-gw._tcp. can be reached at broken.local:18789abc",
"txtvers=1 displayName=Broken gatewayPort=70000 sshPort=22x",
"",
].join("\n"),
stderr: "",
code: 0,
signal: null,
killed: false,
};
}
throw new Error(`unexpected argv: ${argv.join(" ")}`);
});
const beacons = await discoverGatewayBeacons({
platform: "darwin",
timeoutMs: 800,
domains: ["local."],
run: run as unknown as typeof runCommandWithTimeout,
});
expect(beacons).toHaveLength(1);
const beacon = beacons[0] as BeaconRecord;
expect(beacon.host).toBe("broken.local");
expect(beacon.port).toBeUndefined();
expect(beacon.gatewayPort).toBeUndefined();
expect(beacon.sshPort).toBeUndefined();
expect(resolveGatewayDiscoveryEndpoint(beacon)).toBeNull();
});
it("falls back to tailnet DNS probing for wide-area when split DNS is not configured", async () => {
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
const zone = WIDE_AREA_DOMAIN.replace(/\.$/, "");
+22 -14
View File
@@ -37,7 +37,13 @@ export function resolveGatewayDiscoveryEndpoint(
): GatewayDiscoveryResolvedEndpoint | null {
const host = beacon.host?.trim();
const port = beacon.port;
if (!host || typeof port !== "number" || !Number.isFinite(port) || port <= 0) {
if (
!host ||
typeof port !== "number" ||
!Number.isSafeInteger(port) ||
port <= 0 ||
port > MAX_TCP_PORT
) {
return null;
}
const gatewayTls = beacon.gatewayTls === true;
@@ -70,6 +76,7 @@ export type GatewayBonjourDiscoverOpts = {
const DEFAULT_TIMEOUT_MS = 2000;
const GATEWAY_SERVICE_TYPE = "_openclaw-gw._tcp";
const MAX_TCP_PORT = 65_535;
function decodeDnsSdEscapes(value: string): string {
let decoded = false;
@@ -146,9 +153,9 @@ function parseDigSrv(stdout: string): { host: string; port: number } | null {
if (parts.length < 4) {
return null;
}
const port = Number.parseInt(parts[2] ?? "", 10);
const port = parsePortOrUndefined(parts[2]);
const hostRaw = parts[3] ?? "";
if (!Number.isFinite(port) || port <= 0) {
if (port === undefined) {
return null;
}
const host = hostRaw.replace(/\.$/, "");
@@ -193,11 +200,12 @@ function parseTailscaleStatusIPv4s(stdout: string): string[] {
return uniqueStrings(out);
}
function parseIntOrNull(value: string | undefined): number | undefined {
function parsePortOrUndefined(value: string | undefined): number | undefined {
if (!value) {
return undefined;
}
return parseStrictInteger(value);
const parsed = parseStrictInteger(value);
return parsed !== undefined && parsed > 0 && parsed <= MAX_TCP_PORT ? parsed : undefined;
}
function parseTxtTokens(tokens: string[]): Record<string, string> {
@@ -246,12 +254,12 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour
}
if (line.includes("can be reached at")) {
const match = line.match(/can be reached at\s+([^\s:]+):(\d+)/i);
const match = line.match(/can be reached at\s+([^\s:]+):([^\s]+)/i);
if (match?.[1]) {
beacon.host = match[1].replace(/\.$/, "");
}
if (match?.[2]) {
beacon.port = parseIntOrNull(match[2]);
beacon.port = parsePortOrUndefined(match[2]);
}
continue;
}
@@ -275,8 +283,8 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour
if (txt.cliPath) {
beacon.cliPath = txt.cliPath;
}
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
beacon.sshPort = parseIntOrNull(txt.sshPort);
beacon.gatewayPort = parsePortOrUndefined(txt.gatewayPort);
beacon.sshPort = parsePortOrUndefined(txt.sshPort);
if (txt.gatewayTls) {
const raw = normalizeOptionalLowercaseString(txt.gatewayTls);
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
@@ -450,8 +458,8 @@ async function discoverWideAreaViaTailnetDns(
host: srvParsed.host,
port: srvParsed.port,
txt: Object.keys(txtMap).length ? txtMap : undefined,
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
sshPort: parseIntOrNull(txtMap.sshPort),
gatewayPort: parsePortOrUndefined(txtMap.gatewayPort),
sshPort: parsePortOrUndefined(txtMap.sshPort),
tailnetDns: txtMap.tailnetDns || undefined,
cliPath: txtMap.cliPath || undefined,
};
@@ -516,7 +524,7 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
if (trimmed.startsWith("port =")) {
const match = trimmed.match(/port\s*=\s*\[(\d+)\]/);
if (match?.[1]) {
current.port = parseIntOrNull(match[1]);
current.port = parsePortOrUndefined(match[1]);
}
continue;
}
@@ -537,8 +545,8 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
if (txt.cliPath) {
current.cliPath = txt.cliPath;
}
current.gatewayPort = parseIntOrNull(txt.gatewayPort);
current.sshPort = parseIntOrNull(txt.sshPort);
current.gatewayPort = parsePortOrUndefined(txt.gatewayPort);
current.sshPort = parsePortOrUndefined(txt.sshPort);
if (txt.gatewayTls) {
const raw = normalizeOptionalLowercaseString(txt.gatewayTls);
current.gatewayTls = raw === "1" || raw === "true" || raw === "yes";