From 3e503ff674cae6ec10db7d8e69edc1022c239fc4 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Feb 2026 16:53:10 +0100 Subject: [PATCH 1/3] fix: Require any UI interface permission to use interface RPC methods --- .../src/permitted/createInterface.test.tsx | 58 +++++++++++++++++ .../src/permitted/createInterface.ts | 20 +++++- .../src/permitted/getInterfaceContext.test.ts | 48 ++++++++++++++ .../src/permitted/getInterfaceContext.ts | 20 +++++- .../src/permitted/getInterfaceState.test.ts | 54 +++++++++++++++- .../src/permitted/getInterfaceState.ts | 20 +++++- .../src/permitted/resolveInterface.test.ts | 51 +++++++++++++++ .../src/permitted/resolveInterface.ts | 18 +++++- .../src/permitted/updateInterface.test.tsx | 64 ++++++++++++++++++- .../src/permitted/updateInterface.ts | 18 +++++- packages/snaps-rpc-methods/src/utils.ts | 15 +++++ 11 files changed, 369 insertions(+), 17 deletions(-) diff --git a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx index 96cb78c886..233646c7ce 100644 --- a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx +++ b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx @@ -23,6 +23,7 @@ describe('snap_createInterface', () => { methodNames: ['snap_createInterface'], implementation: expect.any(Function), hookNames: { + hasPermission: true, createInterface: true, }, }); @@ -30,12 +31,61 @@ describe('snap_createInterface', () => { }); describe('implementation', () => { + it('throws if the origin does not have permission to show UI', async () => { + const { implementation } = createInterfaceHandler; + + const hasPermission = jest.fn().mockReturnValue(false); + const createInterface = jest.fn().mockReturnValue('foo'); + + const hooks = { hasPermission, createInterface }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_createInterface', + params: { + ui: ( + + Hello, world! + + ) as JSXElement, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + it('returns the result from the `createInterface` hook', async () => { const { implementation } = createInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const createInterface = jest.fn().mockReturnValue('foo'); const hooks = { + hasPermission, createInterface, }; @@ -68,9 +118,11 @@ describe('snap_createInterface', () => { it('creates an interface from a JSX element', async () => { const { implementation } = createInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const createInterface = jest.fn().mockReturnValue('foo'); const hooks = { + hasPermission, createInterface, }; @@ -108,9 +160,11 @@ describe('snap_createInterface', () => { it('throws on invalid params', async () => { const { implementation } = createInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const createInterface = jest.fn().mockReturnValue('foo'); const hooks = { + hasPermission, createInterface, }; @@ -152,9 +206,11 @@ describe('snap_createInterface', () => { it('throws on invalid UI', async () => { const { implementation } = createInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const createInterface = jest.fn().mockReturnValue('foo'); const hooks = { + hasPermission, createInterface, }; @@ -202,9 +258,11 @@ describe('snap_createInterface', () => { it('throws on invalid nested UI', async () => { const { implementation } = createInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const createInterface = jest.fn().mockReturnValue('foo'); const hooks = { + hasPermission, createInterface, }; diff --git a/packages/snaps-rpc-methods/src/permitted/createInterface.ts b/packages/snaps-rpc-methods/src/permitted/createInterface.ts index e7e4547a3f..66bb48382e 100644 --- a/packages/snaps-rpc-methods/src/permitted/createInterface.ts +++ b/packages/snaps-rpc-methods/src/permitted/createInterface.ts @@ -1,6 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { CreateInterfaceParams, CreateInterfaceResult, @@ -18,16 +18,26 @@ import { StructError, create, object, optional } from '@metamask/superstruct'; import type { PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; +import { UI_PERMISSIONS } from '../utils'; const methodName = 'snap_createInterface'; const hookNames: MethodHooksObject = { + hasPermission: true, createInterface: true, }; export type CreateInterfaceMethodHooks = { + /** + * @param permissionName - The name of the permission to check. + * @returns Whether the Snap has the permission. + */ + hasPermission: (permissionName: string) => boolean; + /** * @param ui - The UI components. + * @param context - An optional interface context object. + * @param contentType - The optional content type. * @returns The unique identifier of the interface. */ createInterface: ( @@ -66,6 +76,8 @@ export type CreateInterfaceParameters = InferMatching< * function. * @param end - The `json-rpc-engine` "end" callback. * @param hooks - The RPC method hooks. + * @param hooks.hasPermission - The function to check if the Snap has a given + * permission. * @param hooks.createInterface - The function to create the interface. * @returns Nothing. */ @@ -74,8 +86,12 @@ function getCreateInterfaceImplementation( res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { createInterface }: CreateInterfaceMethodHooks, + { hasPermission, createInterface }: CreateInterfaceMethodHooks, ): void { + if (!UI_PERMISSIONS.some(hasPermission)) { + return end(providerErrors.unauthorized()); + } + const { params } = req; try { diff --git a/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.test.ts b/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.test.ts index 11198c235c..e64dfbeaeb 100644 --- a/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.test.ts @@ -12,6 +12,7 @@ describe('snap_getInterfaceContext', () => { methodNames: ['snap_getInterfaceContext'], implementation: expect.any(Function), hookNames: { + hasPermission: true, getInterfaceContext: true, }, }); @@ -19,12 +20,57 @@ describe('snap_getInterfaceContext', () => { }); describe('implementation', () => { + it('throws if the origin does not have permission to show UI', async () => { + const { implementation } = getInterfaceContextHandler; + + const hasPermission = jest.fn().mockReturnValue(false); + const getInterfaceContext = jest.fn().mockReturnValue({ foo: 'bar' }); + + const hooks = { hasPermission, getInterfaceContext }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getInterfaceContext', + params: { + id: 'foo', + }, + }); + + expect(response).toStrictEqual({ + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + it('returns the result from the `getInterfaceContext` hook', async () => { const { implementation } = getInterfaceContextHandler; + const hasPermission = jest.fn().mockReturnValue(true); const getInterfaceContext = jest.fn().mockReturnValue({ foo: 'bar' }); const hooks = { + hasPermission, getInterfaceContext, }; @@ -61,9 +107,11 @@ describe('snap_getInterfaceContext', () => { it('throws on invalid params', async () => { const { implementation } = getInterfaceContextHandler; + const hasPermission = jest.fn().mockReturnValue(true); const getInterfaceContext = jest.fn().mockReturnValue({ foo: 'bar' }); const hooks = { + hasPermission, getInterfaceContext, }; diff --git a/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.ts b/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.ts index 38c81c888e..e0a8298672 100644 --- a/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.ts +++ b/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.ts @@ -1,6 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { GetInterfaceContextParams, GetInterfaceContextResult, @@ -12,14 +12,22 @@ import { StructError, create, object, string } from '@metamask/superstruct'; import type { PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; +import { UI_PERMISSIONS } from '../utils'; const methodName = 'snap_getInterfaceContext'; const hookNames: MethodHooksObject = { + hasPermission: true, getInterfaceContext: true, }; export type GetInterfaceContextMethodHooks = { + /** + * @param permissionName - The name of the permission to check. + * @returns Whether the Snap has the permission. + */ + hasPermission: (permissionName: string) => boolean; + /** * @param id - The interface ID. * @returns The interface context. @@ -55,16 +63,22 @@ export type GetInterfaceContextParameters = InferMatching< * function. * @param end - The `json-rpc-engine` "end" callback. * @param hooks - The RPC method hooks. + * @param hooks.hasPermission - The function to check if the Snap has a given + * permission. * @param hooks.getInterfaceContext - The function to get the interface context. - * @returns Noting. + * @returns Nothing. */ function getInterfaceContextImplementation( req: JsonRpcRequest, res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { getInterfaceContext }: GetInterfaceContextMethodHooks, + { hasPermission, getInterfaceContext }: GetInterfaceContextMethodHooks, ): void { + if (!UI_PERMISSIONS.some(hasPermission)) { + return end(providerErrors.unauthorized()); + } + const { params } = req; try { diff --git a/packages/snaps-rpc-methods/src/permitted/getInterfaceState.test.ts b/packages/snaps-rpc-methods/src/permitted/getInterfaceState.test.ts index 9d336a55d3..b325fa39b8 100644 --- a/packages/snaps-rpc-methods/src/permitted/getInterfaceState.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/getInterfaceState.test.ts @@ -3,7 +3,7 @@ import { type GetInterfaceStateResult } from '@metamask/snaps-sdk'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { getInterfaceStateHandler } from './getInterfaceState'; -import type { UpdateInterfaceParameters } from './updateInterface'; +import type { GetInterfaceStateParameters } from './getInterfaceState'; describe('snap_getInterfaceState', () => { describe('getInterfaceStateHandler', () => { @@ -12,6 +12,7 @@ describe('snap_getInterfaceState', () => { methodNames: ['snap_getInterfaceState'], implementation: expect.any(Function), hookNames: { + hasPermission: true, getInterfaceState: true, }, }); @@ -19,12 +20,57 @@ describe('snap_getInterfaceState', () => { }); describe('implementation', () => { + it('throws if the origin does not have permission to show UI', async () => { + const { implementation } = getInterfaceStateHandler; + + const hasPermission = jest.fn().mockReturnValue(false); + const getInterfaceState = jest.fn().mockReturnValue({ foo: 'bar' }); + + const hooks = { hasPermission, getInterfaceState }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getInterfaceState', + params: { + id: 'foo', + }, + }); + + expect(response).toStrictEqual({ + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + it('returns the result from the `getInterfaceState` hook', async () => { const { implementation } = getInterfaceStateHandler; + const hasPermission = jest.fn().mockReturnValue(true); const getInterfaceState = jest.fn().mockReturnValue({ foo: 'bar' }); const hooks = { + hasPermission, getInterfaceState, }; @@ -32,7 +78,7 @@ describe('snap_getInterfaceState', () => { engine.push((request, response, next, end) => { const result = implementation( - request as JsonRpcRequest, + request as JsonRpcRequest, response as PendingJsonRpcResponse, next, end, @@ -61,9 +107,11 @@ describe('snap_getInterfaceState', () => { it('throws on invalid params', async () => { const { implementation } = getInterfaceStateHandler; + const hasPermission = jest.fn().mockReturnValue(true); const getInterfaceState = jest.fn().mockReturnValue({ foo: 'bar' }); const hooks = { + hasPermission, getInterfaceState, }; @@ -71,7 +119,7 @@ describe('snap_getInterfaceState', () => { engine.push((request, response, next, end) => { const result = implementation( - request as JsonRpcRequest, + request as JsonRpcRequest, response as PendingJsonRpcResponse, next, end, diff --git a/packages/snaps-rpc-methods/src/permitted/getInterfaceState.ts b/packages/snaps-rpc-methods/src/permitted/getInterfaceState.ts index 20c10fe412..3dedeefdbe 100644 --- a/packages/snaps-rpc-methods/src/permitted/getInterfaceState.ts +++ b/packages/snaps-rpc-methods/src/permitted/getInterfaceState.ts @@ -1,6 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { GetInterfaceStateParams, GetInterfaceStateResult, @@ -12,14 +12,22 @@ import { StructError, create, object, string } from '@metamask/superstruct'; import type { PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; +import { UI_PERMISSIONS } from '../utils'; const methodName = 'snap_getInterfaceState'; const hookNames: MethodHooksObject = { + hasPermission: true, getInterfaceState: true, }; export type GetInterfaceStateMethodHooks = { + /** + * @param permissionName - The name of the permission to check. + * @returns Whether the Snap has the permission. + */ + hasPermission: (permissionName: string) => boolean; + /** * @param id - The interface ID. * @returns The interface state. @@ -55,16 +63,22 @@ export type GetInterfaceStateParameters = InferMatching< * function. * @param end - The `json-rpc-engine` "end" callback. * @param hooks - The RPC method hooks. + * @param hooks.hasPermission - The function to check if the Snap has a given + * permission. * @param hooks.getInterfaceState - The function to get the interface state. - * @returns Noting. + * @returns Nothing. */ function getGetInterfaceStateImplementation( req: JsonRpcRequest, res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { getInterfaceState }: GetInterfaceStateMethodHooks, + { hasPermission, getInterfaceState }: GetInterfaceStateMethodHooks, ): void { + if (!UI_PERMISSIONS.some(hasPermission)) { + return end(providerErrors.unauthorized()); + } + const { params } = req; try { diff --git a/packages/snaps-rpc-methods/src/permitted/resolveInterface.test.ts b/packages/snaps-rpc-methods/src/permitted/resolveInterface.test.ts index fbe23ebed2..e564bdd677 100644 --- a/packages/snaps-rpc-methods/src/permitted/resolveInterface.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/resolveInterface.test.ts @@ -14,6 +14,7 @@ describe('snap_resolveInterface', () => { methodNames: ['snap_resolveInterface'], implementation: expect.any(Function), hookNames: { + hasPermission: true, resolveInterface: true, }, }); @@ -21,12 +22,58 @@ describe('snap_resolveInterface', () => { }); describe('implementation', () => { + it('throws if the origin does not have permission to show UI', async () => { + const { implementation } = resolveInterfaceHandler; + + const hasPermission = jest.fn().mockReturnValue(false); + const resolveInterface = jest.fn(); + + const hooks = { hasPermission, resolveInterface }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_resolveInterface', + params: { + id: 'foo', + value: 'bar', + }, + }); + + expect(response).toStrictEqual({ + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + it('returns null after calling the `resolveInterface` hook', async () => { const { implementation } = resolveInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const resolveInterface = jest.fn(); const hooks = { + hasPermission, resolveInterface, }; @@ -60,9 +107,11 @@ describe('snap_resolveInterface', () => { it('resolves an interface', async () => { const { implementation } = resolveInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const resolveInterface = jest.fn(); const hooks = { + hasPermission, resolveInterface, }; @@ -97,9 +146,11 @@ describe('snap_resolveInterface', () => { it('throws on invalid params', async () => { const { implementation } = resolveInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const resolveInterface = jest.fn(); const hooks = { + hasPermission, resolveInterface, }; diff --git a/packages/snaps-rpc-methods/src/permitted/resolveInterface.ts b/packages/snaps-rpc-methods/src/permitted/resolveInterface.ts index d3718685e4..497afa3a13 100644 --- a/packages/snaps-rpc-methods/src/permitted/resolveInterface.ts +++ b/packages/snaps-rpc-methods/src/permitted/resolveInterface.ts @@ -1,6 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest, ResolveInterfaceParams, @@ -12,14 +12,22 @@ import type { Json, PendingJsonRpcResponse } from '@metamask/utils'; import { JsonStruct } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; +import { UI_PERMISSIONS } from '../utils'; const methodName = 'snap_resolveInterface'; const hookNames: MethodHooksObject = { + hasPermission: true, resolveInterface: true, }; export type ResolveInterfaceMethodHooks = { + /** + * @param permissionName - The name of the permission to check. + * @returns Whether the Snap has the permission. + */ + hasPermission: (permissionName: string) => boolean; + /** * @param id - The interface id. * @param value - The value to resolve the interface with. @@ -56,6 +64,8 @@ export type ResolveInterfaceParameters = InferMatching< * function. * @param end - The `json-rpc-engine` "end" callback. * @param hooks - The RPC method hooks. + * @param hooks.hasPermission - The function to check if the Snap has a given + * permission. * @param hooks.resolveInterface - The function to resolve the interface. * @returns Nothing. */ @@ -64,8 +74,12 @@ async function getResolveInterfaceImplementation( res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { resolveInterface }: ResolveInterfaceMethodHooks, + { hasPermission, resolveInterface }: ResolveInterfaceMethodHooks, ): Promise { + if (!UI_PERMISSIONS.some(hasPermission)) { + return end(providerErrors.unauthorized()); + } + const { params } = req; try { diff --git a/packages/snaps-rpc-methods/src/permitted/updateInterface.test.tsx b/packages/snaps-rpc-methods/src/permitted/updateInterface.test.tsx index 27d1846679..1fd7b46f5c 100644 --- a/packages/snaps-rpc-methods/src/permitted/updateInterface.test.tsx +++ b/packages/snaps-rpc-methods/src/permitted/updateInterface.test.tsx @@ -3,7 +3,6 @@ import type { UpdateInterfaceParams, UpdateInterfaceResult, } from '@metamask/snaps-sdk'; -import { text } from '@metamask/snaps-sdk'; import { Box, type JSXElement, Text } from '@metamask/snaps-sdk/jsx'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; @@ -16,6 +15,7 @@ describe('snap_updateInterface', () => { methodNames: ['snap_updateInterface'], implementation: expect.any(Function), hookNames: { + hasPermission: true, updateInterface: true, }, }); @@ -23,12 +23,62 @@ describe('snap_updateInterface', () => { }); describe('implementation', () => { + it('throws if the origin does not have permission to show UI', async () => { + const { implementation } = updateInterfaceHandler; + + const hasPermission = jest.fn().mockReturnValue(false); + const updateInterface = jest.fn(); + + const hooks = { hasPermission, updateInterface }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_updateInterface', + params: { + id: 'foo', + ui: ( + + Hello, world! + + ) as JSXElement, + }, + }); + + expect(response).toStrictEqual({ + error: { + code: 4100, + message: + 'The requested account and/or method has not been authorized by the user.', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + it('returns the result from the `updateInterface` hook', async () => { const { implementation } = updateInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const updateInterface = jest.fn(); const hooks = { + hasPermission, updateInterface, }; @@ -52,7 +102,11 @@ describe('snap_updateInterface', () => { method: 'snap_updateInterface', params: { id: 'foo', - ui: text('foo'), + ui: ( + + Hello, world! + + ) as JSXElement, }, }); @@ -62,9 +116,11 @@ describe('snap_updateInterface', () => { it('updates a JSX interface', async () => { const { implementation } = updateInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const updateInterface = jest.fn(); const hooks = { + hasPermission, updateInterface, }; @@ -109,9 +165,11 @@ describe('snap_updateInterface', () => { it('updates the interface context', async () => { const { implementation } = updateInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const updateInterface = jest.fn(); const hooks = { + hasPermission, updateInterface, }; @@ -156,9 +214,11 @@ describe('snap_updateInterface', () => { it('throws on invalid params', async () => { const { implementation } = updateInterfaceHandler; + const hasPermission = jest.fn().mockReturnValue(true); const updateInterface = jest.fn(); const hooks = { + hasPermission, updateInterface, }; diff --git a/packages/snaps-rpc-methods/src/permitted/updateInterface.ts b/packages/snaps-rpc-methods/src/permitted/updateInterface.ts index 06b79ff808..5948f1a649 100644 --- a/packages/snaps-rpc-methods/src/permitted/updateInterface.ts +++ b/packages/snaps-rpc-methods/src/permitted/updateInterface.ts @@ -1,6 +1,6 @@ import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PermittedHandlerExport } from '@metamask/permission-controller'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import type { UpdateInterfaceParams, UpdateInterfaceResult, @@ -23,14 +23,22 @@ import { import type { PendingJsonRpcResponse } from '@metamask/utils'; import type { MethodHooksObject } from '../utils'; +import { UI_PERMISSIONS } from '../utils'; const methodName = 'snap_updateInterface'; const hookNames: MethodHooksObject = { + hasPermission: true, updateInterface: true, }; export type UpdateInterfaceMethodHooks = { + /** + * @param permissionName - The name of the permission to check. + * @returns Whether the Snap has the permission. + */ + hasPermission: (permissionName: string) => boolean; + /** * @param id - The interface ID. * @param ui - The UI components. @@ -73,6 +81,8 @@ export type UpdateInterfaceParameters = InferMatching< * function. * @param end - The `json-rpc-engine` "end" callback. * @param hooks - The RPC method hooks. + * @param hooks.hasPermission - The function to check if the Snap has a given + * permission. * @param hooks.updateInterface - The function to update the interface. * @returns Nothing. */ @@ -81,8 +91,12 @@ function getUpdateInterfaceImplementation( res: PendingJsonRpcResponse, _next: unknown, end: JsonRpcEngineEndCallback, - { updateInterface }: UpdateInterfaceMethodHooks, + { hasPermission, updateInterface }: UpdateInterfaceMethodHooks, ): void { + if (!UI_PERMISSIONS.some(hasPermission)) { + return end(providerErrors.unauthorized()); + } + const { params } = req; try { diff --git a/packages/snaps-rpc-methods/src/utils.ts b/packages/snaps-rpc-methods/src/utils.ts index 203fe8f642..84b91a21e5 100644 --- a/packages/snaps-rpc-methods/src/utils.ts +++ b/packages/snaps-rpc-methods/src/utils.ts @@ -19,6 +19,8 @@ import { } from '@metamask/utils'; import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; +import { SnapEndowments } from './endowments'; + const HARDENED_VALUE = 0x80000000; export const FORBIDDEN_KEYS = ['constructor', '__proto__', 'prototype']; @@ -366,3 +368,16 @@ export async function getValueFromEntropySource( }); } } + +/** + * The permissions that allow a Snap to show UI. Snaps must have at least one + * of these permissions to use the interface management RPC methods. + */ +export const UI_PERMISSIONS = [ + 'snap_dialog', + 'snap_notify', + SnapEndowments.HomePage, + SnapEndowments.SettingsPage, + SnapEndowments.TransactionInsight, + SnapEndowments.SignatureInsight, +] as const; From 709f369b3d7df4c11f59ac75e5460f756dee864d Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Feb 2026 16:59:32 +0100 Subject: [PATCH 2/3] Bump coverage --- packages/snaps-rpc-methods/jest.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index bc279b1e60..f574b3c7a0 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 96.55, + branches: 96.6, functions: 99.2, - lines: 99.05, - statements: 98.77, + lines: 99.06, + statements: 98.79, }, }, }); From ebc981adbd6718f56ecee2ef87bc874020ad21be Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Feb 2026 17:09:17 +0100 Subject: [PATCH 3/3] Update error message --- .../src/permitted/createInterface.test.tsx | 2 +- packages/snaps-rpc-methods/src/permitted/createInterface.ts | 6 +++++- .../src/permitted/getInterfaceContext.test.ts | 2 +- .../snaps-rpc-methods/src/permitted/getInterfaceContext.ts | 6 +++++- .../src/permitted/getInterfaceState.test.ts | 2 +- .../snaps-rpc-methods/src/permitted/getInterfaceState.ts | 6 +++++- .../src/permitted/resolveInterface.test.ts | 2 +- .../snaps-rpc-methods/src/permitted/resolveInterface.ts | 6 +++++- .../src/permitted/updateInterface.test.tsx | 2 +- packages/snaps-rpc-methods/src/permitted/updateInterface.ts | 6 +++++- 10 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx index 233646c7ce..75975da8b9 100644 --- a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx +++ b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx @@ -70,7 +70,7 @@ describe('snap_createInterface', () => { error: { code: 4100, message: - 'The requested account and/or method has not been authorized by the user.', + 'This method can only be used if the Snap has one of the following permissions: snap_dialog, snap_notify, endowment:page-home, endowment:page-settings, endowment:transaction-insight, endowment:signature-insight.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/createInterface.ts b/packages/snaps-rpc-methods/src/permitted/createInterface.ts index 66bb48382e..1bafbe8dec 100644 --- a/packages/snaps-rpc-methods/src/permitted/createInterface.ts +++ b/packages/snaps-rpc-methods/src/permitted/createInterface.ts @@ -89,7 +89,11 @@ function getCreateInterfaceImplementation( { hasPermission, createInterface }: CreateInterfaceMethodHooks, ): void { if (!UI_PERMISSIONS.some(hasPermission)) { - return end(providerErrors.unauthorized()); + return end( + providerErrors.unauthorized({ + message: `This method can only be used if the Snap has one of the following permissions: ${UI_PERMISSIONS.join(', ')}.`, + }), + ); } const { params } = req; diff --git a/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.test.ts b/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.test.ts index e64dfbeaeb..4effaf1364 100644 --- a/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.test.ts @@ -55,7 +55,7 @@ describe('snap_getInterfaceContext', () => { error: { code: 4100, message: - 'The requested account and/or method has not been authorized by the user.', + 'This method can only be used if the Snap has one of the following permissions: snap_dialog, snap_notify, endowment:page-home, endowment:page-settings, endowment:transaction-insight, endowment:signature-insight.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.ts b/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.ts index e0a8298672..84989c56c9 100644 --- a/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.ts +++ b/packages/snaps-rpc-methods/src/permitted/getInterfaceContext.ts @@ -76,7 +76,11 @@ function getInterfaceContextImplementation( { hasPermission, getInterfaceContext }: GetInterfaceContextMethodHooks, ): void { if (!UI_PERMISSIONS.some(hasPermission)) { - return end(providerErrors.unauthorized()); + return end( + providerErrors.unauthorized({ + message: `This method can only be used if the Snap has one of the following permissions: ${UI_PERMISSIONS.join(', ')}.`, + }), + ); } const { params } = req; diff --git a/packages/snaps-rpc-methods/src/permitted/getInterfaceState.test.ts b/packages/snaps-rpc-methods/src/permitted/getInterfaceState.test.ts index b325fa39b8..ab32a2e26d 100644 --- a/packages/snaps-rpc-methods/src/permitted/getInterfaceState.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/getInterfaceState.test.ts @@ -55,7 +55,7 @@ describe('snap_getInterfaceState', () => { error: { code: 4100, message: - 'The requested account and/or method has not been authorized by the user.', + 'This method can only be used if the Snap has one of the following permissions: snap_dialog, snap_notify, endowment:page-home, endowment:page-settings, endowment:transaction-insight, endowment:signature-insight.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/getInterfaceState.ts b/packages/snaps-rpc-methods/src/permitted/getInterfaceState.ts index 3dedeefdbe..5c9c6c282e 100644 --- a/packages/snaps-rpc-methods/src/permitted/getInterfaceState.ts +++ b/packages/snaps-rpc-methods/src/permitted/getInterfaceState.ts @@ -76,7 +76,11 @@ function getGetInterfaceStateImplementation( { hasPermission, getInterfaceState }: GetInterfaceStateMethodHooks, ): void { if (!UI_PERMISSIONS.some(hasPermission)) { - return end(providerErrors.unauthorized()); + return end( + providerErrors.unauthorized({ + message: `This method can only be used if the Snap has one of the following permissions: ${UI_PERMISSIONS.join(', ')}.`, + }), + ); } const { params } = req; diff --git a/packages/snaps-rpc-methods/src/permitted/resolveInterface.test.ts b/packages/snaps-rpc-methods/src/permitted/resolveInterface.test.ts index e564bdd677..5d8fe2fc67 100644 --- a/packages/snaps-rpc-methods/src/permitted/resolveInterface.test.ts +++ b/packages/snaps-rpc-methods/src/permitted/resolveInterface.test.ts @@ -58,7 +58,7 @@ describe('snap_resolveInterface', () => { error: { code: 4100, message: - 'The requested account and/or method has not been authorized by the user.', + 'This method can only be used if the Snap has one of the following permissions: snap_dialog, snap_notify, endowment:page-home, endowment:page-settings, endowment:transaction-insight, endowment:signature-insight.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/resolveInterface.ts b/packages/snaps-rpc-methods/src/permitted/resolveInterface.ts index 497afa3a13..d641c7477e 100644 --- a/packages/snaps-rpc-methods/src/permitted/resolveInterface.ts +++ b/packages/snaps-rpc-methods/src/permitted/resolveInterface.ts @@ -77,7 +77,11 @@ async function getResolveInterfaceImplementation( { hasPermission, resolveInterface }: ResolveInterfaceMethodHooks, ): Promise { if (!UI_PERMISSIONS.some(hasPermission)) { - return end(providerErrors.unauthorized()); + return end( + providerErrors.unauthorized({ + message: `This method can only be used if the Snap has one of the following permissions: ${UI_PERMISSIONS.join(', ')}.`, + }), + ); } const { params } = req; diff --git a/packages/snaps-rpc-methods/src/permitted/updateInterface.test.tsx b/packages/snaps-rpc-methods/src/permitted/updateInterface.test.tsx index 1fd7b46f5c..c6fbad98cb 100644 --- a/packages/snaps-rpc-methods/src/permitted/updateInterface.test.tsx +++ b/packages/snaps-rpc-methods/src/permitted/updateInterface.test.tsx @@ -63,7 +63,7 @@ describe('snap_updateInterface', () => { error: { code: 4100, message: - 'The requested account and/or method has not been authorized by the user.', + 'This method can only be used if the Snap has one of the following permissions: snap_dialog, snap_notify, endowment:page-home, endowment:page-settings, endowment:transaction-insight, endowment:signature-insight.', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-rpc-methods/src/permitted/updateInterface.ts b/packages/snaps-rpc-methods/src/permitted/updateInterface.ts index 5948f1a649..8d4cfaa624 100644 --- a/packages/snaps-rpc-methods/src/permitted/updateInterface.ts +++ b/packages/snaps-rpc-methods/src/permitted/updateInterface.ts @@ -94,7 +94,11 @@ function getUpdateInterfaceImplementation( { hasPermission, updateInterface }: UpdateInterfaceMethodHooks, ): void { if (!UI_PERMISSIONS.some(hasPermission)) { - return end(providerErrors.unauthorized()); + return end( + providerErrors.unauthorized({ + message: `This method can only be used if the Snap has one of the following permissions: ${UI_PERMISSIONS.join(', ')}.`, + }), + ); } const { params } = req;