fix: harden bonjour DNS label truncation (#73022)

This commit is contained in:
Peter Steinberger
2026-04-27 21:33:02 +01:00
committed by GitHub
parent 7d2d8af3ab
commit e60905d754
3 changed files with 52 additions and 20 deletions

View File

@@ -34,7 +34,7 @@ Docs: https://docs.openclaw.ai
- Memory-core/dreaming: give narrative generation a 60-second timeout so slower local or remote models can finish instead of timing out at 15 seconds. Fixes #72837. (#72852) Thanks @RayWoo.
- Plugins/hooks: inject each plugin's resolved config into internal hook event context without mutating the shared event object. (#72888) Thanks @jalapeno777.
- Agents/ACP: pass the resolved ACP agent directory into media understanding so per-agent media caches and config are used for ACP-dispatched image turns. (#72832) Thanks @luyao618.
- Gateway/Bonjour: truncate mDNS service names and host labels to the 63-byte DNS label limit without splitting multibyte characters. (#72809) Thanks @luyao618.
- Gateway/Bonjour: truncate mDNS service names and host labels to the 63-byte DNS label limit at valid UTF-8 boundaries. (#72809) Thanks @luyao618.
- Feishu: treat groups explicitly configured under channels.feishu.groups as admitted even when groupAllowFrom is empty, so per-group mention overrides work with the default allowlist policy. Fixes #67687. (#72789) Thanks @MoerAI.
- Gateway/startup: keep hot Gateway boot paths on leaf config imports and add max-RSS reporting to the gateway startup bench so low-memory startup regressions are visible before release. Thanks @vincentkoc.
- WebChat: read `chat.history` from active transcript branches, drop stale streamed assistant tails once final history catches up, and coalesce duplicate in-flight Control UI submits, so rewritten prompts, completed replies, and rapid send events no longer render or process twice. Fixes #72975, #72963, and #72974. Thanks @dmagdici, @lhtpluto, and @Benjamin5281999.

View File

@@ -29,10 +29,19 @@ const {
registerUnhandledRejectionHandler,
logger,
} = mocks;
const dnsLabelEncoder = new TextEncoder();
const asString = (value: unknown, fallback: string) =>
typeof value === "string" && value.trim() ? value : fallback;
function expectDnsLabelByteLength(value: string, expected: number) {
expect(dnsLabelEncoder.encode(value).byteLength).toBe(expected);
}
function expectDnsLabelWithinLimit(value: string) {
expect(dnsLabelEncoder.encode(value).byteLength).toBeLessThanOrEqual(63);
}
function enableAdvertiserUnitMode(hostname = "test-host") {
// Allow advertiser to run in unit tests.
delete process.env.VITEST;
@@ -735,8 +744,32 @@ describe("gateway bonjour advertiser", () => {
await started.stop();
});
it("truncates service name exceeding 63-byte DNS label limit", async () => {
const longHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf-abcdefghi";
it("truncates reported Kubernetes service name at the DNS label byte limit", async () => {
const reportedHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf";
enableAdvertiserUnitMode(reportedHostname);
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
mockCiaoService({ advertise, destroy });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
const serviceName = gatewayCall?.[0]?.name as string;
const hostname = gatewayCall?.[0]?.hostname as string;
expectDnsLabelByteLength(`${reportedHostname} (OpenClaw)`, 64);
expect(hostname).toBe(reportedHostname);
expectDnsLabelWithinLimit(serviceName);
await started.stop();
});
it("truncates host labels exceeding the 63-byte DNS label limit", async () => {
const longHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf-abcdefghij";
enableAdvertiserUnitMode(longHostname);
const destroy = vi.fn().mockResolvedValue(undefined);
@@ -752,10 +785,11 @@ describe("gateway bonjour advertiser", () => {
const serviceName = gatewayCall?.[0]?.name as string;
const hostname = gatewayCall?.[0]?.hostname as string;
// Both name and hostname must be within the 63-byte DNS label limit
expect(new TextEncoder().encode(serviceName).byteLength).toBeLessThanOrEqual(63);
expect(new TextEncoder().encode(hostname).byteLength).toBeLessThanOrEqual(63);
expectDnsLabelByteLength(longHostname, 64);
expectDnsLabelByteLength(hostname, 63);
expect(hostname).toBe(longHostname.slice(0, -1));
expect(hostname).not.toMatch(/-$/);
expectDnsLabelWithinLimit(serviceName);
await started.stop();
});
@@ -777,8 +811,7 @@ describe("gateway bonjour advertiser", () => {
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
const serviceName = gatewayCall?.[0]?.name as string;
expect(new TextEncoder().encode(serviceName).byteLength).toBeLessThanOrEqual(63);
// Should not end with a replacement character from incomplete multi-byte truncation
expectDnsLabelWithinLimit(serviceName);
expect(serviceName).not.toMatch(/\uFFFD$/);
await started.stop();

View File

@@ -184,23 +184,22 @@ function resolveSystemMdnsHostname(): string | null {
}
const MAX_DNS_LABEL_BYTES = 63;
const utf8Encoder = new TextEncoder();
function truncateToDnsLabel(name: string, fallback = "OpenClaw"): string {
const encoder = new TextEncoder();
const encoded = encoder.encode(name);
const encoded = utf8Encoder.encode(name);
if (encoded.byteLength <= MAX_DNS_LABEL_BYTES) {
return name;
}
// Truncate at byte boundary, then decode back (TextDecoder handles incomplete sequences)
const truncated = encoded.slice(0, MAX_DNS_LABEL_BYTES);
const decoded = new TextDecoder("utf-8", { fatal: false }).decode(truncated);
// Strip any replacement character from incomplete multi-byte sequence at the end
return (
decoded
.replace(/\uFFFD$/, "")
.replace(/-+$/, "")
.trim() || fallback
);
for (let end = MAX_DNS_LABEL_BYTES; end > 0; end -= 1) {
try {
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(encoded.subarray(0, end));
return decoded.replace(/-+$/, "").trim() || fallback;
} catch {
// Try the next shorter prefix until the byte slice ends on a UTF-8 boundary.
}
}
return fallback;
}
function safeServiceName(name: string) {