From 6ad46e3b78d5fba2bba52973724014fce23f3a4b Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Wed, 4 Nov 2026 07:08:48 +0100 Subject: [PATCH] fix: reject remote writes to inspector state Generic shared-state RPCs let authenticated clients bypass report validation and ownership checks. Guard replacement and patch operations for Pangular-owned state while preserving local updates and remote Devframe settings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../shared-state-write-guard.test.ts | 69 +++++++++++++++++++ packages/devtools/src/devframe.ts | 2 + .../src/rpc/shared-state-write-guard.ts | 43 ++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 packages/devtools/src/__tests__/shared-state-write-guard.test.ts create mode 100644 packages/devtools/src/rpc/shared-state-write-guard.ts diff --git a/packages/devtools/src/__tests__/shared-state-write-guard.test.ts b/packages/devtools/src/__tests__/shared-state-write-guard.test.ts new file mode 100644 index 00000000..91e9aae8 --- /dev/null +++ b/packages/devtools/src/__tests__/shared-state-write-guard.test.ts @@ -0,0 +1,69 @@ +import { createHostContext } from 'devframe/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import pangular from '../devframe.ts'; + +async function boot() { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never }); + await pangular.setup(ctx as never); + vi.spyOn(ctx.rpc, 'getCurrentRpcSession').mockReturnValue({} as never); + const state = await ctx.rpc.sharedState.get('pangular:http'); + return { ctx, state }; +} + +describe('shared-state write guard', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('refuses remote replacement of Pangular shared state', async () => { + const { ctx, state } = await boot(); + const initial = structuredClone(state.value()); + + const set = await ctx.rpc.getHandler('devframe:rpc:server-state:set'); + await expect(set('pangular:http', { calls: [] }, 'remote-set')).rejects.toThrow( + 'Remote writes to Pangular Inspector shared state are not allowed', + ); + + expect(state.value()).toEqual(initial); + }); + + it('refuses remote patching of Pangular shared state', async () => { + const { ctx, state } = await boot(); + const initial = structuredClone(state.value()); + + const patch = await ctx.rpc.getHandler('devframe:rpc:server-state:patch'); + await expect( + patch( + 'pangular:http', + [{ op: 'replace', path: [], value: { calls: ['forged report'] } }], + 'remote-patch', + ), + ).rejects.toThrow('Remote writes to Pangular Inspector shared state are not allowed'); + + expect(state.value()).toEqual(initial); + }); + + it('keeps remote devframe settings writable', async () => { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never }); + await pangular.setup(ctx as never); + const state = await ctx.rpc.sharedState.get('devframe:user-settings', { + initialValue: { theme: 'dark' }, + }); + vi.spyOn(ctx.rpc, 'getCurrentRpcSession').mockReturnValue({} as never); + + const set = await ctx.rpc.getHandler('devframe:rpc:server-state:set'); + await set('devframe:user-settings', { theme: 'light' }, 'remote-set'); + + expect(state.value()).toEqual({ theme: 'light' }); + }); +}); diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 795ec82d..3fbaebc1 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -229,6 +229,7 @@ import { analogPages, nameServerFns, registerAnalog } from './rpc/analog-registe import { clearCalls } from './analog-server-log.ts'; import { createPageHeartbeats } from './rpc/page-heartbeats.ts'; import { createPageVisibility } from './rpc/page-ttl.ts'; +import { guardPangularSharedStateWrites } from './rpc/shared-state-write-guard.ts'; import { createAngularPages, sanitizeAngularReport, @@ -377,6 +378,7 @@ const pangular = defineDevframe({ dock: { visibility: 'false' }, async setup(ctx, info?: SetupInfo) { + guardPangularSharedStateWrites(ctx.rpc); const config = info?.config ?? resolvePangularConfig(); const pageTools = info?.pageTools ?? true; const agentExposed = (entry: { id: string }) => diff --git a/packages/devtools/src/rpc/shared-state-write-guard.ts b/packages/devtools/src/rpc/shared-state-write-guard.ts new file mode 100644 index 00000000..757a33e8 --- /dev/null +++ b/packages/devtools/src/rpc/shared-state-write-guard.ts @@ -0,0 +1,43 @@ +import type { DevframeNodeContext } from 'devframe/types'; + +const PANGULAR_STATE_PREFIX = 'pangular:'; +const REMOTE_WRITE_REFUSAL = + 'Remote writes to Pangular Inspector shared state are not allowed; use the inspector RPC actions.'; + +/** + * Keep remote clients from bypassing the validated report handlers by + * replacing or patching Pangular-owned shared state directly. + */ +export function guardPangularSharedStateWrites(rpc: DevframeNodeContext['rpc']): void { + const setDefinition = rpc.get('devframe:rpc:server-state:set'); + const setHandler = setDefinition?.handler; + if (!setDefinition || !setHandler) { + throw new Error('The devframe shared-state setter is unavailable.'); + } + rpc.update({ + ...setDefinition, + handler: async (key: string, value: unknown, syncId: string) => { + rejectRemotePangularWrite(rpc, key); + return setHandler(key, value, syncId); + }, + }); + + const patchDefinition = rpc.get('devframe:rpc:server-state:patch'); + const patchHandler = patchDefinition?.handler; + if (!patchDefinition || !patchHandler) { + throw new Error('The devframe shared-state patcher is unavailable.'); + } + rpc.update({ + ...patchDefinition, + handler: async (key: string, patches: unknown[], syncId: string) => { + rejectRemotePangularWrite(rpc, key); + return patchHandler(key, patches, syncId); + }, + }); +} + +function rejectRemotePangularWrite(rpc: DevframeNodeContext['rpc'], key: string): void { + if (key.startsWith(PANGULAR_STATE_PREFIX) && rpc.getCurrentRpcSession()) { + throw new Error(REMOTE_WRITE_REFUSAL); + } +}