From 9a3ca68bda4db67efc506ec114bffe64635e8569 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 15 Oct 2026 06:25:18 +0000 Subject: [PATCH] fix(chord): remote references refuse a foreign ctx A call through a remote reference checked the ctx against its own host, so a ctx from another host, or an object not made by Chord, passed and its root was carried over the link. It now runs the same checks as a local reference and rejects with invalid/foreign_ctx. closes #10959 --- packages/chord/CHANGELOG.md | 1 + packages/chord/src/object.ts | 5 ++-- packages/chord/src/remote.ts | 19 ++++++++------ packages/chord/test/review.test.ts | 40 ++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 1a95ee247..67de7cae6 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -53,6 +53,7 @@ - IPC links and the host manager's control channel wrote to a peer process that had exited without a callback, so the `EPIPE` Node reports for a write in the gap between the exit and `disconnect` (an `ack` sent just after a worker shut down) became an unhandled `error` event that crashed the process. The link now ends on a failed write (reason `ipc send failed: EPIPE`), and control messages to a gone peer are dropped - A relayed call ran under the origin's cancel signal only, so a forced retirement of the relaying host could not cancel it before closing the old code's resources; it now follows its root's signal - A resource kept across a reload closed with the `close` of the factory run that first opened it, and kept that run's code reachable; it now adopts the newest declaration at commit +- A call through a remote reference accepted a ctx from another host, or an object not made by Chord, and carried its root over the link; it now rejects it with `invalid/foreign_ctx` like a local reference ([#10959](https://github.com/earendil-works/pi/issues/10959)) ## [1.2.0] - 2026-10-12 diff --git a/packages/chord/src/object.ts b/packages/chord/src/object.ts index 2d72017fd..6bd1ee24f 100644 --- a/packages/chord/src/object.ts +++ b/packages/chord/src/object.ts @@ -29,6 +29,7 @@ import type { EventOf, ListenerDecl, Resource, ResourceDeclaration, Subscribable import { asChordError, ChordError, invalid, runAll } from "./errors.ts"; import { enqueue } from "./publish.ts"; import type { Registration, Registry } from "./registry.ts"; +import type { RemoteHost } from "./remote.ts"; import { assertValid, normalize } from "./schema.ts"; import type { Ctx, Exposed, GetOptions, Readable, RefStatus, Shape, StateEvent } from "./types.ts"; @@ -418,8 +419,8 @@ export class CtxImpl implements Ctx { return this.#closed; } - /** Throws unless this ctx may start work on `host` now (not expired, not aborted, not from another host). */ - check(host: HostCore): void { + /** Throws unless this ctx may start work on `host` (the called reference's host) now: not expired, aborted or foreign. */ + check(host: HostCore | RemoteHost): void { if (this.host !== host) throw invalid("foreign_ctx", "ctx belongs to another host"); if (this.#closed) throw new ChordError("aborted", "ctx expired: its call already settled", { reason: "expired" }); if (this.signal.aborted) { diff --git a/packages/chord/src/remote.ts b/packages/chord/src/remote.ts index 80226a534..73c1e1d24 100644 --- a/packages/chord/src/remote.ts +++ b/packages/chord/src/remote.ts @@ -2,7 +2,7 @@ import { Value } from "typebox/value"; import { HYDRATION, type Hydration } from "./cell.ts"; import { type AnyContract, takesNoArgs } from "./contract.ts"; import { applyImmutable, type Decoder, decoder } from "./delta/index.ts"; -import { ChordError } from "./errors.ts"; +import { ChordError, invalid } from "./errors.ts"; import { CtxImpl, notifyStatus, @@ -631,14 +631,17 @@ export class RemoteRef implements RefCore { const host = this.binding.host; if (host.inFactory > 0) throw new ChordError("not_ready", `${this.address}.${name} called inside a factory`); - if (ctx instanceof CtxImpl) { - try { - ctx.check(ctx.host); - } catch (error) { - return Promise.reject(error); - } + // The same checks as a local reference's caller (RefImpl.caller and ObjectImpl.call): a ctx from another host + // or one not made by Chord would carry a root this host does not own over its link. + if (ctx !== undefined && !(ctx instanceof CtxImpl)) { + return Promise.reject(invalid("foreign_ctx", "ctx must come from a Chord host")); } - if (ctx === undefined || (ctx instanceof CtxImpl && ctx.holder)) { + try { + ctx?.check(host); + } catch (error) { + return Promise.reject(error); + } + if (ctx === undefined || ctx.holder) { return host.root((root) => this.#call(name, args, root), { signal: ctx?.signal }); } return this.#call(name, args, ctx); diff --git a/packages/chord/test/review.test.ts b/packages/chord/test/review.test.ts index acf6a3746..554a24097 100644 --- a/packages/chord/test/review.test.ts +++ b/packages/chord/test/review.test.ts @@ -11,11 +11,13 @@ import { hostNamed, isChordError, local, + memoryLinkPair, method, type Ref, remote, } from "../src/index.ts"; import { fits } from "../src/schema.ts"; +import { CountingLink } from "./fixtures/counting.ts"; const tick = () => new Promise((resolve) => setImmediate(resolve)); const ticks = async (n = 10) => { @@ -643,4 +645,42 @@ describe("review: ctx races", () => { }); assert.ok(foreign); }); + + // #10959 + it("a remote reference refuses a foreign ctx and a ctx not made by Chord, without calling", async () => { + const RemoteTarget = remote("t.remote-target", { + version: 1, + input: Type.Object({}), + host: hostNamed("server"), + variants: { main: { state: {}, methods: { hit: method() } } }, + }); + let hits = 0; + const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); + server.serve(RemoteTarget, () => ({ + variant: "main", + hit: () => { + hits++; + }, + })); + const [down, up] = memoryLinkPair(); + server.accept(down, { kind: "host", name: "worker", principal: { worker: true } }); + const link = new CountingLink(up); + const worker = createHost({ self: { kind: "host" }, name: "worker", onError: () => {} }); + worker.connect(link, { principal: { kind: "server" } }); + const other = createHost({ self: { kind: "host" }, name: "other", onError: () => {} }); + const target = worker.hold(RemoteTarget, {}); + await target.ready; + const foreign = await other.root((ctx) => target.hit(ctx).catch((error: unknown) => error)); + assert.ok(isChordError(foreign, "invalid") && foreign.reason === "foreign_ctx", String(foreign)); + const forged = { rootId: "forged", codeVersion: 1, signal: new AbortController().signal } as unknown as Ctx; + await assert.rejects( + target.hit(forged), + (error) => isChordError(error, "invalid") && error.reason === "foreign_ctx", + ); + await ticks(); + assert.equal(hits, 0); + assert.equal(link.count("sent", "call"), 0); + await worker.root((ctx) => target.hit(ctx)); + assert.equal(hits, 1); + }); });