From b9163fbcb95ef3315cf9b0f9116684e7e970a0d2 Mon Sep 17 00:00:00 2001 From: bcoll Date: Fri, 6 Oct 2023 18:13:49 +0100 Subject: [PATCH] Add additional traps to magic proxy stubs This change adds `getOwnPropertyDescriptor`, `ownKeys` and `getPrototypeOf` traps to magic proxy stubs. The first two allow proxy stubs to be `JSON.stringify`ed. The last ensures proxies aren't detected as plain objects. --- .../src/plugins/core/proxy/client.ts | 59 ++++++++++++++++++- .../miniflare/src/workers/core/constants.ts | 4 ++ .../src/workers/core/proxy.worker.ts | 14 ++++- .../test/plugins/core/proxy/client.spec.ts | 23 ++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/miniflare/src/plugins/core/proxy/client.ts b/packages/miniflare/src/plugins/core/proxy/client.ts index 77cbcf9dc..881623257 100644 --- a/packages/miniflare/src/plugins/core/proxy/client.ts +++ b/packages/miniflare/src/plugins/core/proxy/client.ts @@ -185,7 +185,12 @@ class ProxyClientBridge { class ProxyStubHandler implements ProxyHandler { readonly #version: number; readonly #stringifiedTarget: string; - readonly #known = new Map(); + readonly #knownValues = new Map(); + readonly #knownDescriptors = new Map< + string, + PropertyDescriptor | undefined + >(); + #knownOwnKeys?: string[]; revivers: ReducersRevivers = { ...revivers, @@ -327,7 +332,7 @@ class ProxyStubHandler implements ProxyHandler { if (typeof key === "symbol" || key === "then") return undefined; // See optimisation comments below for cases where this will be set - const maybeKnown = this.#known.get(key); + const maybeKnown = this.#knownValues.get(key); if (maybeKnown !== undefined) return maybeKnown; // Always perform a synchronous GET, if this returns a `Promise`, we'll @@ -361,7 +366,7 @@ class ProxyStubHandler implements ProxyHandler { // (e.g. accessing `R2ObjectBody#body` multiple times) result instanceof ReadableStream ) { - this.#known.set(key, result); + this.#knownValues.set(key, result); } return result; } @@ -371,6 +376,54 @@ class ProxyStubHandler implements ProxyHandler { return this.get(target, key, undefined) !== undefined; } + getOwnPropertyDescriptor(target: T, key: string | symbol) { + if (typeof key === "symbol") return undefined; + + // Optimisation: assume constant prototypes of proxied objects, descriptors + // should never change after we've fetched them + const maybeKnown = this.#knownDescriptors.get(key); + if (maybeKnown !== undefined) return maybeKnown; + + const syncRes = this.bridge.sync.fetch(this.bridge.url, { + method: "POST", + headers: { + [CoreHeaders.OP]: ProxyOps.GET_OWN_DESCRIPTOR, + [CoreHeaders.OP_KEY]: key, + [CoreHeaders.OP_TARGET]: this.#stringifiedTarget, + }, + }); + const result = this.#parseSyncResponse( + syncRes, + this.getOwnPropertyDescriptor + ) as PropertyDescriptor | undefined; + + this.#knownDescriptors.set(key, result); + return result; + } + + ownKeys(_target: T) { + // Optimisation: assume constant prototypes of proxied objects, own keys + // should never change after we've fetched them + if (this.#knownOwnKeys !== undefined) return this.#knownOwnKeys; + + const syncRes = this.bridge.sync.fetch(this.bridge.url, { + method: "POST", + headers: { + [CoreHeaders.OP]: ProxyOps.GET_OWN_KEYS, + [CoreHeaders.OP_TARGET]: this.#stringifiedTarget, + }, + }); + const result = this.#parseSyncResponse(syncRes, this.ownKeys) as string[]; + + this.#knownOwnKeys = result; + return result; + } + + getPrototypeOf(_target: T) { + // Return a `null` prototype, so users know this isn't a plain object + return null; + } + #createFunction(key: string) { // Optimisation: if the function returns a `Promise`, we know it must be // async (assuming all async functions always return `Promise`s). When diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index 5f1fe34cf..f243f93ea 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -30,6 +30,10 @@ export const CoreBindings = { export const ProxyOps = { // Get the target or a property of the target GET: "GET", + // Get the descriptor for a property of the target + GET_OWN_DESCRIPTOR: "GET_OWN_DESCRIPTOR", + // Get the target's own property names + GET_OWN_KEYS: "GET_OWN_KEYS", // Call a method on the target CALL: "CALL", // Remove the strong reference to the target on the "heap", allowing it to be diff --git a/packages/miniflare/src/workers/core/proxy.worker.ts b/packages/miniflare/src/workers/core/proxy.worker.ts index 861dd380f..f5a26be42 100644 --- a/packages/miniflare/src/workers/core/proxy.worker.ts +++ b/packages/miniflare/src/workers/core/proxy.worker.ts @@ -156,7 +156,7 @@ export class ProxyServer implements DurableObject { const targetName = target.constructor.name; let status = 200; - let result; + let result: unknown; let unbufferedRest: ReadableStream | undefined; if (opHeader === ProxyOps.GET) { // If no key header is specified, just return the target @@ -168,6 +168,18 @@ export class ProxyServer implements DurableObject { headers: { [CoreHeaders.OP_RESULT_TYPE]: "Function" }, }); } + } else if (opHeader === ProxyOps.GET_OWN_DESCRIPTOR) { + if (keyHeader === null) return new Response(null, { status: 400 }); + const descriptor = Object.getOwnPropertyDescriptor(target, keyHeader); + if (descriptor !== undefined) { + result = { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + writable: descriptor.writable, + }; + } + } else if (opHeader === ProxyOps.GET_OWN_KEYS) { + result = Object.getOwnPropertyNames(target); } else if (opHeader === ProxyOps.CALL) { // We don't allow callable targets yet (could be useful to implement if // we ever need to proxy functions that return functions) diff --git a/packages/miniflare/test/plugins/core/proxy/client.spec.ts b/packages/miniflare/test/plugins/core/proxy/client.spec.ts index e610450ae..b4386c5e9 100644 --- a/packages/miniflare/test/plugins/core/proxy/client.spec.ts +++ b/packages/miniflare/test/plugins/core/proxy/client.spec.ts @@ -215,3 +215,26 @@ test("ProxyClient: returns empty ReadableStream synchronously", async (t) => { assert(objectBody != null); t.is(await text(objectBody.body), ""); // Synchronous empty stream access }); +test("ProxyClient: can `JSON.stringify()` proxies", async (t) => { + const mf = new Miniflare({ script: nullScript, r2Buckets: ["BUCKET"] }); + t.teardown(() => mf.dispose()); + + const bucket = await mf.getR2Bucket("BUCKET"); + const object = await bucket.put("key", "value"); + assert(object !== null); + t.is(Object.getPrototypeOf(object), null); + const plainObject = JSON.parse(JSON.stringify(object)); + t.deepEqual(plainObject, { + checksums: { + md5: "2063c1608d6e0baf80249c42e2be5804", + }, + customMetadata: {}, + etag: "2063c1608d6e0baf80249c42e2be5804", + httpEtag: '"2063c1608d6e0baf80249c42e2be5804"', + httpMetadata: {}, + key: "key", + size: 5, + uploaded: object.uploaded.toISOString(), + version: object.version, + }); +});