From ee86e3f13ec9bd1aadc4054f9cfa26e2e59d5b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 13 May 2026 06:05:30 +0100 Subject: [PATCH] fix(sandbox): re-enforce SDK manifest, org-slug guard, and allowCrossOrg on __sdk_dispatch host side A malicious guest script can call the __sdk_dispatch global directly with an un-manifested method, a poisoned orgPath ('__proto__'/'constructor'), or a cross-org path the manifest never advertised. Re-check all three on the host: - reject any path not in the manifest's dispatchable set, regardless of mode (dry-run only skips writes for modes that have it - it is not an auth gate) - reject org slugs that are __proto__/constructor/prototype, non-string, or when 'org' isn't an own property of the target SDK, before target.org(slug) - reject any non-empty orgPath when allowCrossOrg is false Adds packages/server/src/__tests__/unit/sandbox/manifest-enforcement.test.ts. --- .../unit/sandbox/manifest-enforcement.test.ts | 100 ++++++++++++++++++ packages/server/src/sandbox/run-script.ts | 39 ++++++- 2 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/__tests__/unit/sandbox/manifest-enforcement.test.ts diff --git a/packages/server/src/__tests__/unit/sandbox/manifest-enforcement.test.ts b/packages/server/src/__tests__/unit/sandbox/manifest-enforcement.test.ts new file mode 100644 index 000000000..2d58be734 --- /dev/null +++ b/packages/server/src/__tests__/unit/sandbox/manifest-enforcement.test.ts @@ -0,0 +1,100 @@ +/** + * `__sdk_dispatch` is a guest-visible global; a malicious script can call it + * directly with an un-manifested method, a poisoned `orgPath`, or a cross-org + * path the manifest never advertised. The host must re-enforce the manifest, + * the org-slug guard, and `allowCrossOrg` at dispatch time — regardless of mode. + */ + +import { describe, expect, it } from "bun:test"; +import type { ClientSDK } from "../../../sandbox/client-sdk"; +import { runOrSkip, stubSDK } from "./_helpers"; + +const DISPATCH = (path: string, args: unknown[], orgPath: string[] = []) => + [ + "export default async () => {", + ` const payload = JSON.stringify({ args: ${JSON.stringify(args)}, orgPath: ${JSON.stringify(orgPath)} });`, + ` const r = await __sdk_dispatch.apply(undefined, [${JSON.stringify(path)}, payload], { result: { promise: true, copy: true } });`, + " return r === undefined ? null : JSON.parse(r);", + "};", + ].join("\n"); + +describe("host-side manifest enforcement on __sdk_dispatch", () => { + it("rejects an un-manifested write in run_sdk mode even though the SDK exposes it", async () => { + let called = false; + const sdk = stubSDK({ + entities: { + // Not present in METHOD_METADATA → never advertised by the manifest. + wipeEverything: async () => { + called = true; + return { wiped: true }; + }, + list: async () => ({ entities: [] }), + } as never, + }); + const result = await runOrSkip({ + source: DISPATCH("entities.wipeEverything", [{}]), + sdk, + sdkMode: "full", + }); + if (!result) return; + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/Unknown SDK method/); + expect(called).toBe(false); + }); + + it("rejects a __proto__ org slug before calling target.org", async () => { + let orgCalls = 0; + const sdk = stubSDK({ + org: async (slug: string): Promise => { + orgCalls += 1; + return stubSDK({ entities: { list: async () => ({ entities: [] }) } as never, _slug: slug } as never); + }, + entities: { list: async () => ({ entities: [] }) } as never, + }); + const result = await runOrSkip({ + source: DISPATCH("entities.list", [{}], ["__proto__"]), + sdk, + sdkMode: "full", + allowCrossOrg: true, + }); + if (!result) return; + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/Invalid org slug/); + expect(orgCalls).toBe(0); + }); + + it("rejects a non-empty orgPath when allowCrossOrg is false", async () => { + let orgCalls = 0; + const sdk = stubSDK({ + org: async (): Promise => { + orgCalls += 1; + return stubSDK(); + }, + entities: { list: async () => ({ entities: [] }) } as never, + }); + const result = await runOrSkip({ + source: DISPATCH("entities.list", [{}], ["other-org"]), + sdk, + sdkMode: "full", + allowCrossOrg: false, + }); + if (!result) return; + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/CrossOrgAccessDenied/); + expect(orgCalls).toBe(0); + }); + + it("still allows a manifested call", async () => { + const sdk = stubSDK({ + entities: { list: async () => ({ entities: [{ id: 1 }] }) } as never, + }); + const result = await runOrSkip({ + source: DISPATCH("entities.list", [{}]), + sdk, + sdkMode: "full", + }); + if (!result) return; + expect(result.success).toBe(true); + expect(result.returnValue).toEqual({ entities: [{ id: 1 }] } as never); + }); +}); diff --git a/packages/server/src/sandbox/run-script.ts b/packages/server/src/sandbox/run-script.ts index 7758cb5a4..0f58c52df 100644 --- a/packages/server/src/sandbox/run-script.ts +++ b/packages/server/src/sandbox/run-script.ts @@ -276,9 +276,19 @@ export async function runScript( const started = Date.now(); const limits = clampLimits(options.limits); const sdkMode: SDKMode = options.sdkMode ?? "full"; - const manifest = enumerateSDKManifest(sdkMode, { - allowCrossOrg: options.allowCrossOrg ?? false, - }); + const allowCrossOrg = options.allowCrossOrg ?? false; + const manifest = enumerateSDKManifest(sdkMode, { allowCrossOrg }); + + // Host-side mirror of the manifest's dispatchable paths. `__sdk_dispatch` is a + // guest-visible global, so a malicious script can call it with an un-manifested + // method directly — the guest-side Proxy filter is the friendly path, this is + // the security backstop. `org` is a guest-side construct (re-walks `orgPath`), + // never a dispatch path, so it isn't in this set. + const allowedDispatchPaths = new Set(["log", "query"]); + for (const [ns, methods] of Object.entries(manifest.byNamespace)) { + for (const method of methods) allowedDispatchPaths.add(`${ns}.${method}`); + } + const FORBIDDEN_ORG_SLUGS = new Set(["__proto__", "constructor", "prototype"]); // Wall-clock timeout. Each dispatch races the abort signal so the script // returns promptly; upstream DB/HTTP itself doesn't cancel today. @@ -376,8 +386,29 @@ export async function runScript( orgPath: string[]; }; + // Re-enforce the manifest on the host: reject any method the manifest + // wouldn't advertise, regardless of run mode (dry-run only skips writes + // for the modes that have it — it is not an authorization gate). + if (!allowedDispatchPaths.has(path)) { + throw new Error(`Unknown SDK method: '${path}'`); + } + // Cross-org access is gated here too, not just by the manifest omitting + // `org` from `topLevel`. + if (!allowCrossOrg && orgPath.length > 0) { + throw new Error("CrossOrgAccessDenied: cross-org access is not available here."); + } + let target: ClientSDK = baseSdk; - for (const slug of orgPath) target = await target.org(slug); + for (const slug of orgPath) { + if ( + typeof slug !== "string" || + FORBIDDEN_ORG_SLUGS.has(slug) || + !Object.prototype.hasOwnProperty.call(target, "org") + ) { + throw new Error(`Invalid org slug: '${String(slug)}'`); + } + target = await target.org(slug); + } const dispatchPromise: Promise = (async () => { if (path === "log") {