diff --git a/sdks/node/README.md b/sdks/node/README.md index ee49ef37..160158dc 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -492,4 +492,4 @@ compiled in via `--features postgres,redis`. ## Not yet covered -Resources / dependency-injection and Python⇄Node cross-language interop. +End-to-end interop test coverage for the shared cross-SDK wire contract. diff --git a/sdks/node/src/errors.ts b/sdks/node/src/errors.ts index 9ede48ad..9d844b74 100644 --- a/sdks/node/src/errors.ts +++ b/sdks/node/src/errors.ts @@ -151,6 +151,16 @@ export class PredicateRejectedError extends TaskitoError { } } +// ── Proxies ───────────────────────────────────────────────────────────────── + +/** Thrown on proxy handler, signature, expiry, purpose, or allowlist failures. */ +export class ProxyError extends TaskitoError { + constructor(message: string) { + super(message); + this.name = "ProxyError"; + } +} + /** Thrown when an enqueue interceptor rejects, misbehaves, or redirects illegally. */ export class InterceptionError extends TaskitoError { constructor(message: string) { diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts index d3a38a25..eb4a1233 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -9,6 +9,7 @@ export { LockNotAcquiredError, NotesValidationError, PredicateRejectedError, + ProxyError, QueueError, ResourceError, ResourceNotFoundError, @@ -30,6 +31,8 @@ export { type Predicate, type PredicateContext, } from "./predicates"; +export type { ProxyHandler, ProxyRef } from "./proxies"; +export { canonicalJson, FileProxyHandler, FileReference, Proxies } from "./proxies"; export { Queue, type QueueOptions } from "./queue"; export { type MockResource, diff --git a/sdks/node/src/proxies/canonical.ts b/sdks/node/src/proxies/canonical.ts new file mode 100644 index 00000000..da04eaf7 --- /dev/null +++ b/sdks/node/src/proxies/canonical.ts @@ -0,0 +1,43 @@ +import { ProxyError } from "../errors"; + +/** + * Canonical compact JSON with recursively sorted keys — the signing form of + * the cross-SDK contract. Keys sort by UTF-16 code units; `undefined` object + * values are dropped (wire peers have no undefined); non-safe-integer numbers + * are rejected because their textual rendering is not stable across + * implementations — encode decimals as strings. + * + * Hand-rolled rather than round-tripping through an object: JS objects + * reorder integer-like keys numerically, which would diverge from the + * contract's lexical ordering. + */ +export function canonicalJson(value: unknown): string { + if (value === null || value === undefined) { + return "null"; + } + switch (typeof value) { + case "string": + case "boolean": + return JSON.stringify(value); + case "number": + if (!Number.isSafeInteger(value)) { + throw new ProxyError( + `proxy reference numbers must be safe integers, got ${value} — encode decimals as strings`, + ); + } + return JSON.stringify(value); + default: + break; + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(",")}]`; + } + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`); + return `{${entries.join(",")}}`; + } + throw new ProxyError(`proxy reference values must be JSON-serializable, got ${typeof value}`); +} diff --git a/sdks/node/src/proxies/file-handler.ts b/sdks/node/src/proxies/file-handler.ts new file mode 100644 index 00000000..9e3ae9e9 --- /dev/null +++ b/sdks/node/src/proxies/file-handler.ts @@ -0,0 +1,83 @@ +import { existsSync, realpathSync } from "node:fs"; +import { dirname, normalize, relative, resolve, sep } from "node:path"; +import { ProxyError } from "../errors"; +import type { ProxyHandler } from "./types"; + +/** A file identified by path — the value type {@link FileProxyHandler} proxies. */ +export class FileReference { + readonly path: string; + + constructor(path: string) { + this.path = path; + } +} + +/** + * Proxies a {@link FileReference} by its absolute path. An optional allowlist + * of root directories is enforced on reconstruct, so a tampered or hostile + * ref cannot resolve to a path outside them (an empty allowlist permits any + * path). Roots and candidate paths are compared by their real (symlink- + * resolved) locations. + */ +export class FileProxyHandler implements ProxyHandler { + readonly id = "file"; + private readonly allowedRoots: readonly string[]; + + constructor(allowedRoots: readonly string[] = []) { + // Resolve each root to its real path so containment is checked against + // the true filesystem location, not a symlinked alias. + this.allowedRoots = allowedRoots.map((root) => realPath(resolve(root))); + } + + handles(value: unknown): boolean { + return value instanceof FileReference; + } + + deconstruct(value: FileReference): Record { + return { path: resolve(value.path) }; + } + + reconstruct(reference: Record): FileReference { + const path = reference.path; + if (typeof path !== "string") { + throw new ProxyError('file proxy ref missing "path"'); + } + const resolved = normalize(resolve(path)); + if (!this.allowed(resolved)) { + throw new ProxyError(`file path not in allowlist: ${resolved}`); + } + return new FileReference(resolved); + } + + private allowed(path: string): boolean { + if (this.allowedRoots.length === 0) { + return true; + } + const real = realPath(path); + return this.allowedRoots.some((root) => real === root || real.startsWith(root + sep)); + } +} + +/** + * The real (symlink-resolved) path. Since the target may not exist yet, + * resolve the nearest existing ancestor to its real path — collapsing any + * symlinked ancestor — then re-append the remaining segments. A path whose + * ancestors do not exist yet has no symlink to hide behind, so its normalized + * form stands. + */ +function realPath(path: string): string { + let existing = path; + while (!existsSync(existing)) { + const parent = dirname(existing); + if (parent === existing) { + return normalize(path); + } + existing = parent; + } + try { + const realExisting = realpathSync(existing); + return normalize(resolve(realExisting, relative(existing, path))); + } catch (error) { + throw new ProxyError(`failed to resolve real path for ${path} (${String(error)})`); + } +} diff --git a/sdks/node/src/proxies/index.ts b/sdks/node/src/proxies/index.ts new file mode 100644 index 00000000..bff810d0 --- /dev/null +++ b/sdks/node/src/proxies/index.ts @@ -0,0 +1,4 @@ +export { canonicalJson } from "./canonical"; +export { FileProxyHandler, FileReference } from "./file-handler"; +export { Proxies } from "./proxies"; +export type { ProxyHandler, ProxyRef } from "./types"; diff --git a/sdks/node/src/proxies/proxies.ts b/sdks/node/src/proxies/proxies.ts new file mode 100644 index 00000000..b362ab4c --- /dev/null +++ b/sdks/node/src/proxies/proxies.ts @@ -0,0 +1,112 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { ProxyError } from "../errors"; +import { canonicalJson } from "./canonical"; +import type { ProxyHandler, ProxyRef } from "./types"; + +/** + * Registry that deconstructs resources into signed {@link ProxyRef}s and + * reconstructs them. Construct with an HMAC key (shared by producer and + * worker), register a {@link ProxyHandler} per resource type, then + * `deconstruct` on the producer and `reconstruct` (or `resolve`) on the + * worker. Signature layout is the cross-SDK contract, so refs verify across + * SDKs sharing the key. + */ +export class Proxies { + private readonly handlers = new Map(); + private readonly key: Buffer; + + constructor(hmacKey: Uint8Array) { + if (hmacKey.length === 0) { + throw new ProxyError("proxy HMAC key must not be empty"); + } + this.key = Buffer.from(hmacKey); + } + + /** Register a handler under its non-empty, unique id; returns `this`. */ + register(handler: ProxyHandler): this { + if (!handler.id) { + throw new ProxyError("proxy handler id must not be empty"); + } + // Fail fast on a duplicate: silently overwriting would let a producer and + // worker disagree on what a given ProxyRef's handler id means. + if (this.handlers.has(handler.id)) { + throw new ProxyError(`proxy handler "${handler.id}" is already registered`); + } + this.handlers.set(handler.id, handler as ProxyHandler); + return this; + } + + /** + * Deconstruct `value` into a signed ref, optionally bound to a TTL and a + * purpose; throws if no registered handler accepts it (registration order, + * first match wins). + */ + deconstruct(value: unknown, opts?: { ttlMs?: number; purpose?: string }): ProxyRef { + if (value === null || value === undefined) { + throw new ProxyError("cannot deconstruct null"); + } + const expiresAtMs = opts?.ttlMs === undefined ? null : Date.now() + opts.ttlMs; + const purpose = opts?.purpose ?? null; + for (const handler of this.handlers.values()) { + if (handler.handles(value)) { + const reference = handler.deconstruct(value); + const signature = this.sign(handler.id, reference, expiresAtMs, purpose); + return { handler: handler.id, reference, signature, expiresAtMs, purpose }; + } + } + throw new ProxyError(`no proxy handler for value of type ${typeof value}`); + } + + /** + * Verify a ref's signature, expiry, and (when `expectedPurpose` is given) + * its bound purpose, then reconstruct the resource. + */ + reconstruct(ref: ProxyRef, expectedPurpose?: string): unknown { + const handler = this.handlers.get(ref.handler); + if (!handler) { + throw new ProxyError(`unknown proxy handler "${ref.handler}"`); + } + this.verify(ref, expectedPurpose); + return handler.reconstruct(ref.reference); + } + + /** {@link Proxies.reconstruct} cast to the caller's type. */ + resolve(ref: ProxyRef, expectedPurpose?: string): T { + return this.reconstruct(ref, expectedPurpose) as T; + } + + private verify(ref: ProxyRef, expectedPurpose?: string): void { + const expected = Buffer.from( + this.sign(ref.handler, ref.reference, ref.expiresAtMs, ref.purpose), + "utf8", + ); + const actual = Buffer.from(ref.signature ?? "", "utf8"); + // timingSafeEqual throws on length mismatch — treat that as a plain mismatch. + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + throw new ProxyError(`proxy signature mismatch for handler "${ref.handler}"`); + } + if (ref.expiresAtMs != null && Date.now() > ref.expiresAtMs) { + throw new ProxyError(`proxy ref expired for handler "${ref.handler}"`); + } + if (expectedPurpose !== undefined && (ref.purpose ?? null) !== expectedPurpose) { + throw new ProxyError(`proxy purpose mismatch for handler "${ref.handler}"`); + } + } + + private sign( + handlerId: string, + reference: Record, + expiresAtMs: number | null | undefined, + purpose: string | null | undefined, + ): string { + // Cross-SDK contract: an absent expiry signs as the literal string "null"; + // an absent purpose signs as the empty string. + const message = [ + handlerId, + canonicalJson(reference), + expiresAtMs == null ? "null" : String(expiresAtMs), + purpose ?? "", + ].join("\n"); + return createHmac("sha256", this.key).update(message, "utf8").digest("base64"); + } +} diff --git a/sdks/node/src/proxies/types.ts b/sdks/node/src/proxies/types.ts new file mode 100644 index 00000000..9a3361e1 --- /dev/null +++ b/sdks/node/src/proxies/types.ts @@ -0,0 +1,39 @@ +/** + * A signed, serializable reference to a non-serializable resource. Produced by + * {@link Proxies.deconstruct} on the producer, carried in a task payload, and + * resolved back on the worker. Wire peers emit explicit JSON nulls for the + * optional fields, so both `null` and `undefined` must be accepted. + */ +export interface ProxyRef { + /** Id of the handler that produced (and can resolve) this ref. */ + handler: string; + /** Serializable reference data (e.g. a file path). */ + reference: Record; + /** Base64 HMAC-SHA256 over the ref's canonical form — the cross-SDK contract. */ + signature: string; + /** Unix-ms expiry, or null/absent for no expiry. */ + expiresAtMs?: number | null; + /** Optional purpose the ref is bound to; workers may require a match. */ + purpose?: string | null; +} + +/** + * Deconstructs a non-serializable resource of type `T` into a serializable + * reference, and reconstructs it on the worker. Register handlers with a + * {@link Proxies} registry. + * + * Reference values must stay within the canonical signing form — strings, + * booleans, safe integers, nulls, and nested objects/arrays of those. + * Non-integer numbers are rejected at signing time (their textual form is not + * stable across the cross-SDK contract); encode decimals as strings. + */ +export interface ProxyHandler { + /** Stable id stored in the {@link ProxyRef} and used to find this handler on the worker. */ + readonly id: string; + /** Whether this handler can proxy `value`. */ + handles(value: unknown): boolean; + /** Reduce `value` to a serializable reference (e.g. a file path, a config map). */ + deconstruct(value: T): Record; + /** Rebuild the resource from a reference produced by {@link ProxyHandler.deconstruct}. */ + reconstruct(reference: Record): T; +} diff --git a/sdks/node/test/resources/proxies.test.ts b/sdks/node/test/resources/proxies.test.ts new file mode 100644 index 00000000..336e16dd --- /dev/null +++ b/sdks/node/test/resources/proxies.test.ts @@ -0,0 +1,186 @@ +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + canonicalJson, + FileProxyHandler, + FileReference, + Proxies, + ProxyError, + type ProxyHandler, + type ProxyRef, +} from "../../src/index"; + +const KEY = Buffer.from("proxy-secret-key"); + +function fileProxies(allowedRoots: readonly string[] = []): Proxies { + return new Proxies(KEY).register(new FileProxyHandler(allowedRoots)); +} + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), "taskito-proxies-")); +} + +/** Simulate the wire: serialize the ref to JSON and parse it back. */ +function overTheWire(ref: ProxyRef): ProxyRef { + return JSON.parse(JSON.stringify(ref)) as ProxyRef; +} + +describe("Proxies", () => { + it("round-trips a file ref across the wire", () => { + const proxies = fileProxies(); + const path = join(tempDir(), "data.txt"); + const ref = overTheWire(proxies.deconstruct(new FileReference(path))); + expect(proxies.resolve(ref).path).toBe(resolve(path)); + }); + + it("rejects a tampered ref", () => { + const proxies = fileProxies(); + const ref = proxies.deconstruct(new FileReference(join(tempDir(), "a"))); + const tampered: ProxyRef = { ...ref, reference: { path: "/etc/passwd" } }; + expect(() => proxies.reconstruct(tampered)).toThrow(ProxyError); + expect(() => proxies.reconstruct(tampered)).toThrow(/signature mismatch/); + }); + + it("enforces the allowlist", () => { + const dir = tempDir(); + const proxies = fileProxies([dir]); + const inside = join(dir, "ok.txt"); + expect( + proxies.resolve(proxies.deconstruct(new FileReference(inside))).path, + ).toBe(resolve(inside)); + const outside = join(dir, "..", "outside.txt"); + const ref = proxies.deconstruct(new FileReference(outside)); + expect(() => proxies.reconstruct(ref)).toThrow(/not in allowlist/); + }); + + it("rejects a symlinked-ancestor escape from the allowlist", () => { + const dir = tempDir(); + const allowed = join(dir, "allowed"); + const secret = join(dir, "secret"); + for (const d of [allowed, secret]) { + writeFileSync(join(mkdirp(d), ".keep"), ""); + } + writeFileSync(join(secret, "data.txt"), "top secret"); + // A symlink inside the allowed root pointing at the secret dir: lexically + // under the allowlist, but its real target is not. + symlinkSync(secret, join(allowed, "link"), "dir"); + + const proxies = fileProxies([allowed]); + const ref = proxies.deconstruct(new FileReference(join(allowed, "link", "data.txt"))); + expect(() => proxies.reconstruct(ref)).toThrow(/not in allowlist/); + }); + + it("rejects a value with no handler", () => { + expect(() => fileProxies().deconstruct({ not: "a file" })).toThrow(/no proxy handler/); + }); + + it("rejects an unknown handler on reconstruct", () => { + const proxies = fileProxies(); + const ref = proxies.deconstruct(new FileReference("/tmp/x")); + expect(() => proxies.reconstruct({ ...ref, handler: "nope" })).toThrow(/unknown proxy handler/); + }); + + it("rejects a null value on deconstruct", () => { + expect(() => fileProxies().deconstruct(null)).toThrow(/cannot deconstruct null/); + }); + + it("rejects a duplicate handler id", () => { + expect(() => fileProxies().register(new FileProxyHandler())).toThrow(/already registered/); + }); + + it("rejects an empty key", () => { + expect(() => new Proxies(new Uint8Array())).toThrow(/must not be empty/); + }); + + it("round-trips within the TTL", () => { + const proxies = fileProxies(); + const path = join(tempDir(), "ttl.txt"); + const ref = proxies.deconstruct(new FileReference(path), { ttlMs: 60_000 }); + expect(proxies.resolve(ref).path).toBe(resolve(path)); + }); + + it("rejects an expired ref", async () => { + const proxies = fileProxies(); + const ref = proxies.deconstruct(new FileReference("/tmp/x"), { ttlMs: 1 }); + await new Promise((r) => setTimeout(r, 20)); + expect(() => proxies.reconstruct(ref)).toThrow(/expired/); + }); + + it("rejects a tampered expiry", () => { + const proxies = fileProxies(); + const ref = proxies.deconstruct(new FileReference("/tmp/x"), { ttlMs: 1 }); + const tampered: ProxyRef = { ...ref, expiresAtMs: Date.now() + 3_600_000 }; + expect(() => proxies.reconstruct(tampered)).toThrow(/signature mismatch/); + }); + + it("enforces the purpose when requested", () => { + const proxies = fileProxies(); + const path = join(tempDir(), "p.txt"); + const ref = proxies.deconstruct(new FileReference(path), { purpose: "emails" }); + expect(proxies.resolve(ref, "emails").path).toBe(resolve(path)); + expect(proxies.resolve(ref).path).toBe(resolve(path)); // unchecked + expect(() => proxies.reconstruct(ref, "billing")).toThrow(/purpose mismatch/); + }); + + it("dispatches to the first handler that accepts, in registration order", () => { + const calls: string[] = []; + const handler = (id: string): ProxyHandler => ({ + id, + handles: (value) => typeof value === "string", + deconstruct: (value) => { + calls.push(id); + return { value }; + }, + reconstruct: (reference) => String(reference.value), + }); + const proxies = new Proxies(KEY).register(handler("first")).register(handler("second")); + expect(proxies.deconstruct("hello").handler).toBe("first"); + expect(calls).toEqual(["first"]); + }); +}); + +describe("canonicalJson", () => { + it("sorts keys recursively and stays compact", () => { + expect(canonicalJson({ b: { d: 1, c: 2 }, a: [3, { z: 4, y: 5 }] })).toBe( + '{"a":[3,{"y":5,"z":4}],"b":{"c":2,"d":1}}', + ); + }); + + it("sorts integer-like keys lexically, not numerically", () => { + expect(canonicalJson({ "10": "ten", "2": "two" })).toBe('{"10":"ten","2":"two"}'); + }); + + it("drops undefined object values and nullifies undefined array items", () => { + expect(canonicalJson({ a: undefined, b: 1 })).toBe('{"b":1}'); + expect(canonicalJson([undefined, 1])).toBe("[null,1]"); + }); + + it("rejects non-safe-integer numbers", () => { + expect(() => canonicalJson({ a: 1.5 })).toThrow(ProxyError); + expect(() => canonicalJson({ a: Number.MAX_SAFE_INTEGER + 1 })).toThrow(/safe integers/); + }); +}); + +describe("cross-SDK contract", () => { + it("produces the contract vector signature", () => { + // A verbatim handler keeps the reference platform-independent — the file + // handler would resolve the path against the local filesystem root. + const verbatim: ProxyHandler = { + id: "file", + handles: (value) => typeof value === "string", + deconstruct: (value) => ({ path: value }), + reconstruct: (reference) => String(reference.path), + }; + const proxies = new Proxies(KEY).register(verbatim); + const ref = proxies.deconstruct("/tmp/data.txt"); + expect(ref.signature).toBe("FgmudNqaGsUBFsIKC4uBgtfZ+IAHrzBT+xRjUWGePyQ="); + expect(proxies.resolve(ref)).toBe("/tmp/data.txt"); + }); +}); + +function mkdirp(dir: string): string { + mkdirSync(dir, { recursive: true }); + return dir; +}