mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 23:17:23 +00:00
don't allow prototype defined method to be called in drivers (#3413)
* don't allow prototype defined method to be called in drivers * fix test
This commit is contained in:
@@ -295,6 +295,30 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// A lifecycle hook *exists* on every driver (inherited from PuterDriver),
|
||||
// so the old `typeof driver[method] === 'function'` dispatch would have
|
||||
// invoked it. It must not be reachable over /drivers/call.
|
||||
it.each([
|
||||
'onServerStart',
|
||||
'onServerShutdown',
|
||||
'onServerPrepareShutdown',
|
||||
'getReportedCosts',
|
||||
'constructor',
|
||||
'toString',
|
||||
])('rejects the framework method %s with 404', async (method) => {
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq({ interface: 'puter-kvstore', method }, actor);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects a bare session ("root" token) actor on a noUserSession driver with a helpful 403', async () => {
|
||||
// The AI drivers all set `noUserSession` — a session token must
|
||||
// not double as an AI credential. The message has to point the
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
resolveDriverMeta,
|
||||
resolveDriverMethodConcurrent,
|
||||
resolveDriverMethodRateLimit,
|
||||
@@ -124,6 +125,14 @@ export class DriverController extends PuterController {
|
||||
* lookup doesn't have to walk prototype chains on every request.
|
||||
*/
|
||||
#meta = new WeakMap<DriverInstance, DriverMeta>();
|
||||
/**
|
||||
* driver instance → the set of method names callable via `/drivers/call`.
|
||||
* Resolved once at registration (server startup) via
|
||||
* `resolveCallableMethods`; the request path only does a `Set.has` lookup.
|
||||
* This is what stops framework/lifecycle methods (`onServerStart`, etc.)
|
||||
* and `Object.prototype` members from being invoked by remote callers.
|
||||
*/
|
||||
#callableMethods = new WeakMap<DriverInstance, Set<string>>();
|
||||
|
||||
constructor(...args: ConstructorParameters<typeof PuterController>) {
|
||||
super(...args);
|
||||
@@ -202,14 +211,19 @@ export class DriverController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const fn = driver[method];
|
||||
if (typeof fn !== 'function') {
|
||||
// Only methods in the pre-resolved callable set are dispatchable.
|
||||
// This excludes framework/lifecycle hooks (onServerStart, etc.),
|
||||
// inherited base methods, and Object.prototype members, none of
|
||||
// which are part of any interface's RPC contract.
|
||||
const callable = this.#callableMethods.get(driver);
|
||||
if (!callable?.has(method)) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
`Method '${method}' not found on driver '${ifaceName}'`,
|
||||
{ legacyCode: 'not_found' },
|
||||
);
|
||||
}
|
||||
const fn = driver[method];
|
||||
|
||||
// Resolve the concrete driver name for permission keys, falling
|
||||
// back through prototype metadata → instance field → requested name.
|
||||
@@ -513,6 +527,11 @@ export class DriverController extends PuterController {
|
||||
// Cache the resolved meta so the request hot-path can read the
|
||||
// per-method rate-limit spec without re-walking the prototype.
|
||||
this.#meta.set(instance, meta);
|
||||
// Resolve the callable RPC surface once, at startup. The request
|
||||
// path checks membership against this set instead of reflecting on
|
||||
// the live instance, so lifecycle hooks / inherited framework
|
||||
// methods can never be dispatched.
|
||||
this.#callableMethods.set(instance, resolveCallableMethods(instance));
|
||||
// Register each alias pointing at the same instance. Legacy puter-js
|
||||
// calls that pass a provider id in the `driver` slot (e.g. the TTS
|
||||
// module sends `aws-polly` / `openai-tts` / `elevenlabs-tts` instead
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { puterDrivers } from './index.js';
|
||||
import { RESERVED_DRIVER_METHODS, resolveCallableMethods } from './meta.js';
|
||||
|
||||
// Guard on the exact set of methods each driver exposes over `/drivers/call`.
|
||||
// The RPC surface is derived structurally (see `resolveCallableMethods`): a
|
||||
// method is callable iff it is a novel public method on the concrete driver
|
||||
// class. This test pins that surface so that ADDING a plain public method to a
|
||||
// driver — which would silently make it a remote endpoint — fails CI until the
|
||||
// expected list here is updated. It is the "fail loud" backstop for the
|
||||
// otherwise fail-open structural gate.
|
||||
//
|
||||
// PuterDriver's constructor signature is (config, clients, stores, services);
|
||||
// field initializers don't read them, so empty mocks are fine (same trick as
|
||||
// driverPolicies.test.ts).
|
||||
const fake = () => [{}, {}, {}, {}] as [any, any, any, any];
|
||||
|
||||
// Expected callable surface, keyed by the registry key in `puterDrivers`.
|
||||
// Keep alphabetical within each list for easy diffing.
|
||||
const EXPECTED: Record<string, string[]> = {
|
||||
kvStore: [
|
||||
'add', 'batchPut', 'decr', 'del', 'expire', 'expireAt', 'flush',
|
||||
'get', 'incr', 'list', 'remove', 'set', 'update',
|
||||
],
|
||||
aiChat: ['complete', 'list', 'models'],
|
||||
aiImage: ['generate', 'list', 'models'],
|
||||
aiTts: ['list', 'list_engines', 'list_voices', 'synthesize'],
|
||||
aiVideo: ['generate', 'list', 'models'],
|
||||
aiSpeech2Speech: ['convert'],
|
||||
aiSpeech2Txt: ['list_models', 'transcribe', 'translate'],
|
||||
aiSpeech2TxtXai: ['list_models', 'transcribe', 'translate'],
|
||||
aiOcr: ['recognize'],
|
||||
// AppDriver is the legacy `.js` driver: its non-RPC helpers are plain
|
||||
// public methods (not `#`-private), so `isNameAvailable` (an AppController
|
||||
// helper) and `toClientView` (a safe-field projection used by the homepage
|
||||
// shell) sit on the callable surface. Both are (and always were, under the
|
||||
// old reflection dispatch) remotely callable — pinned here rather than
|
||||
// silently exposed. See the follow-up note: lock these down by making them
|
||||
// `#`-private with dedicated call sites if the exposure is unwanted.
|
||||
apps: [
|
||||
'create', 'delete', 'isNameAvailable', 'read', 'select',
|
||||
'toClientView', 'update', 'upsert',
|
||||
],
|
||||
subdomains: ['create', 'delete', 'read', 'select', 'update', 'upsert'],
|
||||
notifications: ['create', 'mark_acknowledged', 'mark_shown', 'read', 'select'],
|
||||
workers: ['create', 'destroy', 'getFilePaths', 'getLoggingUrl'],
|
||||
};
|
||||
|
||||
describe('driver callable-method surface', () => {
|
||||
for (const [key, DriverClass] of Object.entries(puterDrivers)) {
|
||||
const instance = new (DriverClass as new (
|
||||
...a: [any, any, any, any]
|
||||
) => object)(...fake());
|
||||
const callable = resolveCallableMethods(instance);
|
||||
|
||||
it(`${key}: exposes exactly its declared RPC methods`, () => {
|
||||
expect([...callable].sort()).toEqual(EXPECTED[key]);
|
||||
});
|
||||
|
||||
it(`${key}: never exposes lifecycle/framework methods`, () => {
|
||||
for (const reserved of RESERVED_DRIVER_METHODS) {
|
||||
expect(callable.has(reserved)).toBe(false);
|
||||
}
|
||||
for (const framework of [
|
||||
'constructor',
|
||||
'toString',
|
||||
'valueOf',
|
||||
'hasOwnProperty',
|
||||
'isPrototypeOf',
|
||||
]) {
|
||||
expect(callable.has(framework)).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -411,3 +411,65 @@ export function resolveDriverMeta(
|
||||
noUserSession,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework/lifecycle method names that must never be reachable via
|
||||
* `/drivers/call`. These live on `PuterDriver` (see `drivers/types.ts`) and
|
||||
* are the machinery the dispatch surface must exclude. For class-based
|
||||
* drivers a concrete `override` of one still carries the same name and is
|
||||
* caught here; for plain-object drivers (registered by extensions — see
|
||||
* `server.ts`, `typeof DriverClass === 'object'`) there is no base prototype
|
||||
* to distinguish them, so this denylist is the *only* thing keeping a
|
||||
* hook off the RPC surface. Any lifecycle hook added to `PuterDriver` must
|
||||
* be added here too — the per-driver guard test (`callableMethods.test.ts`)
|
||||
* fails loudly if a base method starts leaking into every driver's surface.
|
||||
*/
|
||||
export const RESERVED_DRIVER_METHODS: ReadonlySet<string> = new Set([
|
||||
'onServerStart',
|
||||
'onServerPrepareShutdown',
|
||||
'onServerShutdown',
|
||||
'getReportedCosts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Compute the set of method names a driver exposes over `/drivers/call`.
|
||||
*
|
||||
* The RPC surface is defined structurally rather than by a hand-maintained
|
||||
* per-method allow-list. Walking from the instance up to (but not including)
|
||||
* `Object.prototype`, a name is callable iff it resolves to a function and is
|
||||
* neither `constructor` nor a `RESERVED_DRIVER_METHODS` entry. This covers
|
||||
* both driver shapes the server accepts (`server.ts`): class instances (RPC
|
||||
* methods on the concrete prototype, config on the instance) and plain
|
||||
* objects (everything own, used verbatim by extensions). It excludes all
|
||||
* `Object.prototype` members (`toString`, `valueOf`, `__proto__`, …), the
|
||||
* `constructor`, and the lifecycle hooks.
|
||||
*
|
||||
* `#`-private helpers need no handling: they are not real property keys, so
|
||||
* `getOwnPropertyNames` never lists them and `driver['#x']` is `undefined`.
|
||||
* Only *plain* public methods can appear here.
|
||||
*
|
||||
* Getters are excluded (we read the descriptor's `.value`, never access the
|
||||
* property), so evaluating this set never runs driver code. Intended to be
|
||||
* called once per driver at registration and cached — not on the hot path.
|
||||
*/
|
||||
export function resolveCallableMethods(driver: object): Set<string> {
|
||||
const callable = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
for (
|
||||
let o: object | null = driver;
|
||||
o && o !== Object.prototype;
|
||||
o = Object.getPrototypeOf(o) as object | null
|
||||
) {
|
||||
for (const name of Object.getOwnPropertyNames(o)) {
|
||||
// First (lowest) definition wins — a subclass override shadows
|
||||
// the base, and we decide against the resolved descriptor.
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
if (name === 'constructor') continue;
|
||||
if (RESERVED_DRIVER_METHODS.has(name)) continue;
|
||||
const desc = Object.getOwnPropertyDescriptor(o, name);
|
||||
if (desc && typeof desc.value === 'function') callable.add(name);
|
||||
}
|
||||
}
|
||||
return callable;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Focused unit coverage for hot-reload subscription idempotency. The main
|
||||
// WorkerDriver.test.ts boots a full server where workers aren't Cloudflare-
|
||||
// configured (so listeners never register); here we drive onServerStart
|
||||
// directly with a local-server config and a spy event client.
|
||||
//
|
||||
// Regression guard for the /drivers/call exposure fix: onServerStart was
|
||||
// remotely invokable, and each invocation used to stack another set of
|
||||
// fs.* listeners — so a single caller could multiply every user's
|
||||
// worker-source save into N edge redeploys. Dispatch is now gated, but the
|
||||
// subscription must also be structurally idempotent regardless.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WorkerDriver } from './WorkerDriver.js';
|
||||
|
||||
const build = () => {
|
||||
const on = vi.fn();
|
||||
const clients = { event: { on } } as any;
|
||||
const config = { workers: { localServer: true } } as any;
|
||||
const driver = new WorkerDriver(config, clients, {} as any, {} as any);
|
||||
return { driver, on };
|
||||
};
|
||||
|
||||
describe('WorkerDriver hot-reload subscription', () => {
|
||||
it('registers each fs listener exactly once across repeated onServerStart calls', () => {
|
||||
const { driver, on } = build();
|
||||
|
||||
driver.onServerStart();
|
||||
driver.onServerStart();
|
||||
driver.onServerStart();
|
||||
|
||||
expect(on).toHaveBeenCalledTimes(3);
|
||||
expect(on.mock.calls.map((c) => c[0]).sort()).toEqual([
|
||||
'fs.move.node',
|
||||
'fs.remove.node',
|
||||
'fs.write.file',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
readonly isDefault = true;
|
||||
|
||||
#cfBaseUrl = '';
|
||||
#hotReloadSubscribed = false;
|
||||
|
||||
static currentPreambleVersion(): string | null {
|
||||
return preambleVersion;
|
||||
@@ -622,6 +623,11 @@ export class WorkerDriver extends PuterDriver {
|
||||
|
||||
#subscribeHotReload(): void {
|
||||
if (!this.#cfBaseUrl && !USE_LOCAL_WORKERD) return;
|
||||
// Idempotent: re-entry (e.g. a second onServerStart) must not stack
|
||||
// duplicate listeners — each duplicate would multiply redeploys on
|
||||
// every user's worker-source save.
|
||||
if (this.#hotReloadSubscribed) return;
|
||||
this.#hotReloadSubscribed = true;
|
||||
|
||||
this.clients.event.on(
|
||||
'fs.write.file',
|
||||
|
||||
Reference in New Issue
Block a user