diff --git a/packages/client/src/promise/service.ts b/packages/client/src/promise/service.ts index d54da6c4e9..6a52165931 100644 --- a/packages/client/src/promise/service.ts +++ b/packages/client/src/promise/service.ts @@ -10,7 +10,7 @@ import { } from "../service-contender.js" import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js" import { matchesVersion } from "../service-version.js" -import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js" +import type { ServiceStopResponse } from "./generated/types.js" export * from "../service.js" @@ -130,7 +130,15 @@ async function read(file?: string) { const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined) if (text === undefined) return undefined try { - return JSON.parse(text) as Info + const value: unknown = JSON.parse(text) + if (typeof value !== "object" || value === null) return undefined + if (!("url" in value) || typeof value.url !== "string") return undefined + if (!("pid" in value) || !Number.isInteger(value.pid) || typeof value.pid !== "number" || value.pid <= 0) + return undefined + if ("id" in value && value.id !== undefined && typeof value.id !== "string") return undefined + if ("version" in value && value.version !== undefined && typeof value.version !== "string") return undefined + if ("password" in value && value.password !== undefined && typeof value.password !== "string") return undefined + return value as Info } catch { return undefined } @@ -163,7 +171,7 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns }) .then(async (response) => ({ response, - body: (await response.json()) as ServiceHealth | { readonly healthy: true }, + body: (await response.json()) as unknown, })) .then( (value) => ({ value }), @@ -172,7 +180,18 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns if ("cause" in result) return { service: undefined, timedOut: signal.aborted } const response = result.value.response const body = result.value.body - if (body !== undefined && "version" in body && "pid" in body) { + if ( + typeof body === "object" && + body !== null && + "healthy" in body && + body.healthy === true && + "version" in body && + typeof body.version === "string" && + "pid" in body && + typeof body.pid === "number" && + Number.isInteger(body.pid) && + body.pid > 0 + ) { if (body.pid !== info.pid) return { service: undefined, timedOut: false } if (info.version !== undefined && body.version !== info.version) return { service: undefined, timedOut: false } return { @@ -186,7 +205,16 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns timedOut: false, } } - if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false } + if ( + !allowLegacy || + typeof body !== "object" || + body === null || + !("healthy" in body) || + body.healthy !== true || + "version" in body || + "pid" in body + ) + return { service: undefined, timedOut: false } return { service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService, timedOut: false, diff --git a/packages/client/test/promise-service.test.ts b/packages/client/test/promise-service.test.ts index 78d871ab5f..f6925a69d5 100644 --- a/packages/client/test/promise-service.test.ts +++ b/packages/client/test/promise-service.test.ts @@ -38,6 +38,50 @@ test("discovers a compatible registered service", async () => { expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined() }) +test("rejects malformed registrations without probing or signaling", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const malformed = [ + null, + [], + {}, + { url: "http://127.0.0.1:1" }, + { url: "http://127.0.0.1:1", pid: 0 }, + { url: "http://127.0.0.1:1", pid: -1 }, + { url: "http://127.0.0.1:1", pid: 1.5 }, + { url: "http://127.0.0.1:1", pid: "1" }, + { url: "http://127.0.0.1:1", pid: 1, id: 1 }, + ] + + for (const value of malformed) { + await Bun.write(registration, JSON.stringify(value)) + expect(await Service.discover({ file: registration })).toBeUndefined() + } +}) + +test("rejects primitive and partial modern health responses", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const bodies = [ + null, + 1, + "healthy", + [], + {}, + { healthy: false, version: "test", pid: process.pid }, + { healthy: true, version: null, pid: process.pid }, + { healthy: true, version: "test", pid: "1" }, + { healthy: true, version: "test" }, + { healthy: true, pid: process.pid }, + ] + + for (const body of bodies) { + using server = Bun.serve({ port: 0, fetch: () => Response.json(body) }) + await Bun.write(registration, JSON.stringify({ url: server.url.toString(), pid: process.pid })) + expect(await Service.discover({ file: registration })).toBeUndefined() + } +}) + test("ensures a missing service with native promises", async () => { const directory = await temp() const registration = join(directory, "service.json")