diff --git a/jest.config.base.js b/jest.config.base.js index a5f902bad2..3e1d49b841 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -29,6 +29,7 @@ module.exports = { '!./src/**/*.test.tsx', '!./src/**/*.test.browser.ts', '!./src/test-utils/**/*.ts', + '!./src/test-utils/**/*.tsx', '!./src/**/*.d.ts', '!./src/**/__test__/**', '!./src/**/__mocks__/**', diff --git a/packages/examples/packages/images/snap.config.ts b/packages/examples/packages/images/snap.config.ts index ce31ca55a0..2c0327f5be 100644 --- a/packages/examples/packages/images/snap.config.ts +++ b/packages/examples/packages/images/snap.config.ts @@ -1,7 +1,7 @@ import type { SnapConfig } from '@metamask/snaps-cli'; const config: SnapConfig = { - input: './src/index.ts', + input: './src/index.tsx', server: { port: 8026, }, diff --git a/packages/examples/packages/images/snap.manifest.json b/packages/examples/packages/images/snap.manifest.json index 659f7e00a4..ec73b9e78f 100644 --- a/packages/examples/packages/images/snap.manifest.json +++ b/packages/examples/packages/images/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "1naltBL/H7TBXHn+Zxgrq7wz6HUKiUUxFQTYTbp5KkQ=", + "shasum": "A2pzU53b9kEBkckTMFDxcPr9/lbaEsOvNUJK+rNx3/8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/images/src/index.test.ts b/packages/examples/packages/images/src/index.test.tsx similarity index 70% rename from packages/examples/packages/images/src/index.test.ts rename to packages/examples/packages/images/src/index.test.tsx index bc50ae4bf8..9c36ee857b 100644 --- a/packages/examples/packages/images/src/index.test.ts +++ b/packages/examples/packages/images/src/index.test.tsx @@ -1,11 +1,11 @@ import { expect } from '@jest/globals'; -import { installSnap } from '@metamask/snaps-jest'; -import { DialogType, image, panel, text } from '@metamask/snaps-sdk'; +import { installSnap, assertIsAlertDialog } from '@metamask/snaps-jest'; +import { Box, Text, Image } from '@metamask/snaps-sdk/jsx'; import { renderSVG } from 'uqr'; describe('onRpcRequest', () => { it('throws an error if the requested method does not exist', async () => { - const { request, close } = await installSnap(); + const { request } = await installSnap(); const response = await request({ method: 'foo', @@ -20,8 +20,6 @@ describe('onRpcRequest', () => { cause: null, }, }); - - await close(); }); describe('getQrCode', () => { @@ -36,24 +34,25 @@ describe('onRpcRequest', () => { }); const ui = await response.getInterface(); + assertIsAlertDialog(ui); + expect(ui).toRender( - panel([ - text(`The following is a QR code for the data "Hello, world!":`), - image(renderSVG('Hello, world!')), - ]), + + + The following is a QR code for the data "{'Hello, world!'}": + + + , ); - // TODO(ritave): Fix types in SnapInterface - await (ui as any).ok(); + await ui.ok(); expect(await response).toRespondWith(null); }); }); describe('getCat', () => { - // This test is flaky, so we disable it for now. - // eslint-disable-next-line jest/no-disabled-tests - it.skip('shows a cat', async () => { + it('shows a cat using an external URL', async () => { const { request } = await installSnap(); const response = request({ @@ -61,27 +60,16 @@ describe('onRpcRequest', () => { }); const ui = await response.getInterface(); - expect(ui).toStrictEqual( - expect.objectContaining({ - type: DialogType.Alert, - content: { - type: 'panel', - children: [ - { - type: 'text', - value: 'Enjoy your cat!', - }, - { - type: 'image', - value: expect.any(String), - }, - ], - }, - }), + assertIsAlertDialog(ui); + + expect(ui).toRender( + + Enjoy your cat! + + , ); - // TODO(ritave): Fix types in SnapInterface - await (ui as any).ok(); + await ui.ok(); expect(await response).toRespondWith(null); }); @@ -96,6 +84,8 @@ describe('onRpcRequest', () => { }); const ui = await response.getInterface(); + assertIsAlertDialog(ui); + // eslint-disable-next-line jest/prefer-strict-equal expect(ui.content).toEqual({ type: 'Box', @@ -120,8 +110,7 @@ describe('onRpcRequest', () => { key: null, }); - // TODO(ritave): Fix types in SnapInterface - await (ui as any).ok(); + await ui.ok(); expect(await response).toRespondWith(null); }); @@ -136,6 +125,8 @@ describe('onRpcRequest', () => { }); const ui = await response.getInterface(); + assertIsAlertDialog(ui); + // eslint-disable-next-line jest/prefer-strict-equal expect(ui.content).toEqual({ type: 'Box', @@ -160,8 +151,7 @@ describe('onRpcRequest', () => { key: null, }); - // TODO(ritave): Fix types in SnapInterface - await (ui as any).ok(); + await ui.ok(); expect(await response).toRespondWith(null); }); diff --git a/packages/examples/packages/images/src/index.ts b/packages/examples/packages/images/src/index.tsx similarity index 64% rename from packages/examples/packages/images/src/index.ts rename to packages/examples/packages/images/src/index.tsx index 42e71a7f17..10388acb2d 100644 --- a/packages/examples/packages/images/src/index.ts +++ b/packages/examples/packages/images/src/index.tsx @@ -1,12 +1,6 @@ import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; -import { - DialogType, - getImageComponent, - image, - panel, - text, - MethodNotFoundError, -} from '@metamask/snaps-sdk'; +import { DialogType, MethodNotFoundError } from '@metamask/snaps-sdk'; +import { Box, Text, Image } from '@metamask/snaps-sdk/jsx'; import { renderSVG } from 'uqr'; import pngIcon from './images/icon.png'; @@ -26,10 +20,8 @@ type GetQrCodeParams = { * `wallet_invokeSnap` method. This handler handles two methods: * * - `getQrCode`: Show a QR code to the user. The QR code is generated using - * the `uqr` library, and rendered using the `image` component. - * - `getCat`: Show a cat to the user. The cat image is fetched using the - * `getImageComponent` helper. The helper returns an `image` component, which - * can be rendered in a Snap dialog, for example. + * the `uqr` library, and rendered using the `Image` component. + * - `getCat`: Show a cat to the user using an external image. * * @param params - The request parameters. * @param params.request - The JSON-RPC request object. @@ -44,17 +36,19 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { const { data } = request.params as GetQrCodeParams; // `renderSVG` returns a `` element as a string, which can be - // rendered using the `image` component. + // rendered using the `Image` component. const qr = renderSVG(data); return await snap.request({ method: 'snap_dialog', params: { type: DialogType.Alert, - content: panel([ - text(`The following is a QR code for the data "${data}":`), - image(qr), - ]), + content: ( + + The following is a QR code for the data "{data}": + + + ), }, }); } @@ -64,15 +58,12 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { method: 'snap_dialog', params: { type: DialogType.Alert, - content: panel([ - text('Enjoy your cat!'), - - // The `getImageComponent` helper can also be used to fetch an image - // from a URL and render it using the `image` component. - await getImageComponent('https://cataas.com/cat', { - width: 400, - }), - ]), + content: ( + + Enjoy your cat! + + + ), }, }); } @@ -84,8 +75,13 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { type: DialogType.Alert, // `.svg` files are imported as strings, so they can be used directly - // with the `image` component. - content: panel([text('Here is an SVG icon:'), image(svgIcon)]), + // with the `Image` component. + content: ( + + Here is an SVG icon: + + + ), }, }); } @@ -97,8 +93,13 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { type: DialogType.Alert, // `.png` files are imported as SVGs containing an `` tag, - // so they can be used directly with the `image` component. - content: panel([text('Here is a PNG icon:'), image(pngIcon)]), + // so they can be used directly with the `Image` component. + content: ( + + Here is a PNG icon: + + + ), }, }); } diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 89feea7f15..ba99bfafeb 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 94.77, - "functions": 98.03, + "branches": 95.16, + "functions": 99, "lines": 98.63, "statements": 98.43 } diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx index 1473239dfc..31bc780a69 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx @@ -524,6 +524,39 @@ describe('SnapInterfaceController', () => { ).toThrow('A Snap interface context may not be larger than 5 MB'); }); + it('throws if the Snap attempts to use external images without permission', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = + getRestrictedSnapInterfaceControllerMessenger(rootMessenger); + + rootMessenger.registerActionHandler( + 'PermissionController:hasPermission', + () => false, + ); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const element = ( + + + + ); + + expect(() => + rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + element, + {}, + ), + ).toThrow( + 'Using external images is only permitted with the `endowment:network-access` permission.', + ); + }); + it('throws if a link is on the phishing list', async () => { const rootMessenger = getRootSnapInterfaceControllerMessenger(); const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts index af81e680f1..77a83e556f 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -8,6 +8,7 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import type { HasPermission } from '@metamask/permission-controller'; import type { TestOrigin } from '@metamask/phishing-controller'; import type { InterfaceState, @@ -114,7 +115,8 @@ export type SnapInterfaceControllerAllowedActions = | MultichainAssetsControllerGetStateAction | AccountsControllerGetSelectedMultichainAccountAction | AccountsControllerGetAccountByAddressAction - | AccountsControllerListMultichainAccountsAction; + | AccountsControllerListMultichainAccountsAction + | HasPermission; export type SnapInterfaceControllerActions = | CreateInterface @@ -282,7 +284,7 @@ export class SnapInterfaceController extends BaseController< contentType?: ContentType, ) { const element = getJsxInterface(content); - this.#validateContent(element); + this.#validateContent(snapId, element); validateInterfaceContext(context); const id = nanoid(); @@ -339,7 +341,7 @@ export class SnapInterfaceController extends BaseController< ) { this.#validateArgs(snapId, id); const element = getJsxInterface(content); - this.#validateContent(element); + this.#validateContent(snapId, element); validateInterfaceContext(context); const oldState = this.state.interfaces[id].state; @@ -530,13 +532,22 @@ export class SnapInterfaceController extends BaseController< return this.messenger.call('SnapController:get', id); } + #hasPermission(snapId: SnapId, permission: string) { + return this.messenger.call( + 'PermissionController:hasPermission', + snapId, + permission, + ); + } + /** * Utility function to validate the components of an interface. * Throws if something is invalid. * + * @param snapId - The Snap ID. * @param element - The JSX element to verify. */ - #validateContent(element: JSXElement) { + #validateContent(snapId: SnapId, element: JSXElement) { // We assume the validity of this JSON to be validated by the caller. // E.g., in the RPC method implementation. const size = getJsonSizeUnsafe(element); @@ -549,6 +560,7 @@ export class SnapInterfaceController extends BaseController< isOnPhishingList: this.#checkPhishingList.bind(this), getSnap: this.#getSnap.bind(this), getAccountByAddress: this.#getAccountByAddress.bind(this), + hasPermission: this.#hasPermission.bind(this, snapId), }); } diff --git a/packages/snaps-controllers/src/test-utils/controller.tsx b/packages/snaps-controllers/src/test-utils/controller.tsx index 508fcabe48..51eda543aa 100644 --- a/packages/snaps-controllers/src/test-utils/controller.tsx +++ b/packages/snaps-controllers/src/test-utils/controller.tsx @@ -768,6 +768,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'SnapController:get', 'AccountsController:getSelectedMultichainAccount', 'AccountsController:listMultichainAccounts', + 'PermissionController:hasPermission', ], events: ['NotificationServicesController:notificationsListUpdated'], messenger: snapInterfaceControllerMessenger, @@ -830,6 +831,13 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( messenger.registerActionHandler('SnapController:get', (snapId: string) => { return getSnapObject({ id: snapId as SnapId }); }); + + messenger.registerActionHandler( + 'PermissionController:hasPermission', + () => { + return true; + }, + ); } jest.spyOn(snapInterfaceControllerMessenger, 'call'); diff --git a/packages/snaps-execution-environments/lavamoat/webpack/iframe/policy.json b/packages/snaps-execution-environments/lavamoat/webpack/iframe/policy.json index 03edb96300..e534639ec3 100644 --- a/packages/snaps-execution-environments/lavamoat/webpack/iframe/policy.json +++ b/packages/snaps-execution-environments/lavamoat/webpack/iframe/policy.json @@ -72,6 +72,9 @@ } }, "@metamask/snaps-sdk": { + "globals": { + "URL": true + }, "packages": { "@metamask/superstruct": true, "@metamask/utils": true, @@ -81,7 +84,6 @@ "@metamask/snaps-utils": { "globals": { "TextEncoder": true, - "URL": true, "console.error": true, "console.log": true, "console.warn": true diff --git a/packages/snaps-execution-environments/lavamoat/webpack/node-process/policy.json b/packages/snaps-execution-environments/lavamoat/webpack/node-process/policy.json index 984466b8b9..852d7308d8 100644 --- a/packages/snaps-execution-environments/lavamoat/webpack/node-process/policy.json +++ b/packages/snaps-execution-environments/lavamoat/webpack/node-process/policy.json @@ -79,6 +79,9 @@ } }, "@metamask/snaps-sdk": { + "globals": { + "URL": true + }, "packages": { "@metamask/superstruct": true, "@metamask/utils": true, @@ -88,7 +91,6 @@ "@metamask/snaps-utils": { "globals": { "TextEncoder": true, - "URL": true, "console.error": true, "console.log": true, "console.warn": true diff --git a/packages/snaps-execution-environments/lavamoat/webpack/node-thread/policy.json b/packages/snaps-execution-environments/lavamoat/webpack/node-thread/policy.json index 984466b8b9..852d7308d8 100644 --- a/packages/snaps-execution-environments/lavamoat/webpack/node-thread/policy.json +++ b/packages/snaps-execution-environments/lavamoat/webpack/node-thread/policy.json @@ -79,6 +79,9 @@ } }, "@metamask/snaps-sdk": { + "globals": { + "URL": true + }, "packages": { "@metamask/superstruct": true, "@metamask/utils": true, @@ -88,7 +91,6 @@ "@metamask/snaps-utils": { "globals": { "TextEncoder": true, - "URL": true, "console.error": true, "console.log": true, "console.warn": true diff --git a/packages/snaps-execution-environments/lavamoat/webpack/webview/policy.json b/packages/snaps-execution-environments/lavamoat/webpack/webview/policy.json index 03edb96300..e534639ec3 100644 --- a/packages/snaps-execution-environments/lavamoat/webpack/webview/policy.json +++ b/packages/snaps-execution-environments/lavamoat/webpack/webview/policy.json @@ -72,6 +72,9 @@ } }, "@metamask/snaps-sdk": { + "globals": { + "URL": true + }, "packages": { "@metamask/superstruct": true, "@metamask/utils": true, @@ -81,7 +84,6 @@ "@metamask/snaps-utils": { "globals": { "TextEncoder": true, - "URL": true, "console.error": true, "console.log": true, "console.warn": true diff --git a/packages/snaps-sdk/src/images.ts b/packages/snaps-sdk/src/images.ts index 686deb58d7..1aa2e9a6e2 100644 --- a/packages/snaps-sdk/src/images.ts +++ b/packages/snaps-sdk/src/images.ts @@ -105,6 +105,7 @@ export type ImageOptions = { * @param options.request - The options to use when fetching the image data. * This is passed directly to `fetch`. * @returns A promise that resolves to the image data as an image component. + * @deprecated Use `` instead. This function will be removed in a future release. */ export async function getImageComponent( url: string, diff --git a/packages/snaps-sdk/src/index.ts b/packages/snaps-sdk/src/index.ts index 720afd5560..b42dc6b833 100644 --- a/packages/snaps-sdk/src/index.ts +++ b/packages/snaps-sdk/src/index.ts @@ -1,5 +1,6 @@ // Only internals that are used by other Snaps packages should be exported here. export type { EnumToUnion } from './internals'; +export type { UriOptions } from './internals'; export { getErrorData, getErrorMessage, @@ -13,6 +14,7 @@ export { selectiveUnion, nonEmptyRecord, ISO8601DateStruct, + uri, } from './internals'; // Re-exported from `@metamask/utils` for convenience. diff --git a/packages/snaps-sdk/src/internals/index.ts b/packages/snaps-sdk/src/internals/index.ts index d11fba8537..203a5dc2c2 100644 --- a/packages/snaps-sdk/src/internals/index.ts +++ b/packages/snaps-sdk/src/internals/index.ts @@ -5,3 +5,4 @@ export * from './structs'; export * from './jsx'; export * from './svg'; export * from './time'; +export * from './uri'; diff --git a/packages/snaps-sdk/src/internals/uri.test.ts b/packages/snaps-sdk/src/internals/uri.test.ts new file mode 100644 index 0000000000..c6fd6babbb --- /dev/null +++ b/packages/snaps-sdk/src/internals/uri.test.ts @@ -0,0 +1,36 @@ +import { enums, is, literal } from '@metamask/superstruct'; + +import { uri } from './uri'; + +describe('uri', () => { + it.each([ + 'npm:foo-bar', + 'http://asd.com', + 'https://dsa.com/foo', + 'http://dsa.com/foo?asd=5&dsa=6#bar', + 'npm:foo/bar?asd', + 'local:asd.com', + 'http://asd@asd.com', + 'http://asd:foo@asd.com', + ])('validates correct uri', (value) => { + expect(is(value, uri())).toBe(true); + }); + + it.each([5, 'asd', undefined, null, {}, uri, URL])( + 'invalidates invalid uri', + (value) => { + expect(is(value, uri())).toBe(false); + }, + ); + + it('takes additional constraints', () => { + const constraints = { + protocol: enums(['foo:', 'bar:']), + hash: literal('#hello'), + }; + const struct = uri(constraints); + expect(is('foo://asd.com/#hello', struct)).toBe(true); + expect(is('foo://asd.com/', struct)).toBe(false); + expect(is('http://asd.com/#hello', struct)).toBe(false); + }); +}); diff --git a/packages/snaps-sdk/src/internals/uri.ts b/packages/snaps-sdk/src/internals/uri.ts new file mode 100644 index 0000000000..396804b3c5 --- /dev/null +++ b/packages/snaps-sdk/src/internals/uri.ts @@ -0,0 +1,35 @@ +import { + refine, + string, + type, + assert as assertSuperstruct, + StructError, +} from '@metamask/superstruct'; +import type { Struct } from '@metamask/superstruct'; + +import { getErrorMessage } from './errors'; + +export type UriOptions = { + protocol?: Struct; + hash?: Struct; + port?: Struct; + hostname?: Struct; + pathname?: Struct; + search?: Struct; +}; + +export const uri = (opts: UriOptions = {}) => + refine(string(), 'uri', (value) => { + try { + const url = new URL(value); + + const UrlStruct = type(opts); + assertSuperstruct(url, UrlStruct); + return true; + } catch (error) { + if (error instanceof StructError) { + return getErrorMessage(error); + } + return `Expected URL, got "${value.toString()}"`; + } + }); diff --git a/packages/snaps-sdk/src/jsx/components/Image.ts b/packages/snaps-sdk/src/jsx/components/Image.ts index a097f6d6f2..3fa023d3bb 100644 --- a/packages/snaps-sdk/src/jsx/components/Image.ts +++ b/packages/snaps-sdk/src/jsx/components/Image.ts @@ -14,6 +14,8 @@ type ImageProps = { src: string; alt?: string | undefined; borderRadius?: BorderRadius | undefined; + height?: number | undefined; + width?: number | undefined; }; const TYPE = 'Image'; @@ -30,6 +32,8 @@ const TYPE = 'Image'; * @param props.alt - The alternative text of the image, which describes the * image for users who cannot see it. * @param props.borderRadius - The border radius applied to the image. + * @param props.width - The width of the image. + * @param props.height - The height of the image. * @returns An image element. * @example * diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index e92863d325..d1f1a171ff 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -1305,6 +1305,8 @@ describe('ImageStruct', () => { , , , + , + , ])('validates an image element', (value) => { expect(is(value, ImageStruct)).toBe(true); }); @@ -1322,6 +1324,7 @@ describe('ImageStruct', () => { , // @ts-expect-error - Invalid props. , + , foo, foo diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index f1a6a7c988..2dab4873c1 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -91,6 +91,7 @@ import { svg, typedUnion, ISO8601DateStruct, + uri, } from '../internals'; import { NonEip155AssetTypeStruct, @@ -238,9 +239,11 @@ export const BorderRadiusStruct = nullUnion([ * A struct for the {@link ImageElement} type. */ export const ImageStruct: Describe = element('Image', { - src: svg(), + src: nullUnion([svg(), uri({ protocol: literal('https:') })]), alt: optional(string()), borderRadius: optional(BorderRadiusStruct), + width: optional(number()), + height: optional(number()), }); const IconNameStruct: Struct<`${IconName}`, null> = nullUnion( diff --git a/packages/snaps-simulation/src/controllers.ts b/packages/snaps-simulation/src/controllers.ts index 9d841beae0..020416bf85 100644 --- a/packages/snaps-simulation/src/controllers.ts +++ b/packages/snaps-simulation/src/controllers.ts @@ -95,6 +95,7 @@ export function getControllers(options: GetControllersOptions): Controllers { 'AccountsController:getSelectedMultichainAccount', 'AccountsController:listMultichainAccounts', 'MultichainAssetsController:getState', + 'PermissionController:hasPermission', ], events: ['NotificationServicesController:notificationsListUpdated'], }); diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index 5eee8de081..b2fa064f4b 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { - "branches": 99.76, - "functions": 99.01, - "lines": 98.69, - "statements": 97.29 + "branches": 99.77, + "functions": 99.03, + "lines": 98.43, + "statements": 96.94 } diff --git a/packages/snaps-utils/src/types.ts b/packages/snaps-utils/src/types.ts index f5337ab018..73949697a0 100644 --- a/packages/snaps-utils/src/types.ts +++ b/packages/snaps-utils/src/types.ts @@ -1,15 +1,7 @@ -import { getErrorMessage } from '@metamask/snaps-sdk'; -import { - is, - optional, - refine, - size, - string, - type, - assert as assertSuperstruct, - StructError, -} from '@metamask/superstruct'; -import type { Infer, Struct } from '@metamask/superstruct'; +import type { UriOptions } from '@metamask/snaps-sdk'; +import { uri } from '@metamask/snaps-sdk'; +import { is, optional, size, string, type } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import { definePattern, VersionStruct } from '@metamask/utils'; @@ -109,30 +101,7 @@ type ObjectParameters< export type SnapExportsParameters = ObjectParameters; -type UriOptions = { - protocol?: Struct; - hash?: Struct; - port?: Struct; - hostname?: Struct; - pathname?: Struct; - search?: Struct; -}; - -export const uri = (opts: UriOptions = {}) => - refine(string(), 'uri', (value) => { - try { - const url = new URL(value); - - const UrlStruct = type(opts); - assertSuperstruct(url, UrlStruct); - return true; - } catch (error) { - if (error instanceof StructError) { - return getErrorMessage(error); - } - return `Expected URL, got "${value.toString()}"`; - } - }); +export { uri } from '@metamask/snaps-sdk'; /** * Returns whether a given value is a valid URL. diff --git a/packages/snaps-utils/src/ui.test.tsx b/packages/snaps-utils/src/ui.test.tsx index b13149e557..f8ae588b72 100644 --- a/packages/snaps-utils/src/ui.test.tsx +++ b/packages/snaps-utils/src/ui.test.tsx @@ -843,7 +843,8 @@ describe('validateJsxElements', () => { Bar , - ])('does not throw for a safe JSX text component', async (element) => { + , + ])('does not throw for a safe JSX component', async (element) => { const isOnPhishingList = () => false; expect(() => @@ -851,6 +852,7 @@ describe('validateJsxElements', () => { isOnPhishingList, getSnap: jest.fn(), getAccountByAddress: jest.fn(), + hasPermission: jest.fn().mockReturnValue(true), }), ).not.toThrow(); }); @@ -868,6 +870,7 @@ describe('validateJsxElements', () => { isOnPhishingList, getSnap: jest.fn(), getAccountByAddress: jest.fn(), + hasPermission: jest.fn(), }, ), ).not.toThrow(); @@ -892,6 +895,7 @@ describe('validateJsxElements', () => { isOnPhishingList, getSnap: jest.fn(), getAccountByAddress: jest.fn(), + hasPermission: jest.fn(), }), ).toThrow('Invalid URL: The specified URL is not allowed.'); }); @@ -904,6 +908,7 @@ describe('validateJsxElements', () => { isOnPhishingList, getSnap: jest.fn(), getAccountByAddress: jest.fn(), + hasPermission: jest.fn(), }), ).toThrow( 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', @@ -918,6 +923,7 @@ describe('validateJsxElements', () => { isOnPhishingList, getSnap: jest.fn(), getAccountByAddress: jest.fn(), + hasPermission: jest.fn(), }), ).toThrow('Invalid URL: Unable to parse URL.'); }); @@ -939,6 +945,7 @@ describe('validateJsxElements', () => { getAccountByAddress, isOnPhishingList: jest.fn(), getSnap: jest.fn(), + hasPermission: jest.fn(), }, ), ).not.toThrow(); @@ -959,12 +966,26 @@ describe('validateJsxElements', () => { getAccountByAddress, isOnPhishingList: jest.fn(), getSnap: jest.fn(), + hasPermission: jest.fn(), }, ), ).toThrow( 'Could not find account for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ); }); + + it('throws if the Snap tries to use external images without permission', () => { + expect(() => + validateJsxElements(, { + getAccountByAddress: jest.fn(), + isOnPhishingList: jest.fn(), + getSnap: jest.fn(), + hasPermission: jest.fn().mockReturnValue(false), + }), + ).toThrow( + 'Using external images is only permitted with the `endowment:network-access` permission.', + ); + }); }); describe('getTotalTextLength', () => { diff --git a/packages/snaps-utils/src/ui.tsx b/packages/snaps-utils/src/ui.tsx index 845b06df43..7e8982e953 100644 --- a/packages/snaps-utils/src/ui.tsx +++ b/packages/snaps-utils/src/ui.tsx @@ -41,6 +41,7 @@ import type { Token, Tokens } from 'marked'; import type { InternalAccount } from './account'; import type { Snap } from './snaps'; +import { isValidUrl } from './types'; import { parseMetaMaskUrl } from './url'; const MAX_TEXT_LENGTH = 50_000; // 50 kb @@ -433,6 +434,7 @@ export function validateAssetSelector( * phishing list. * @param hooks.getSnap - The function that returns a snap if installed, undefined otherwise. * @param hooks.getAccountByAddress - The function that returns an account by address. + * @param hooks.hasPermission - A function that checks whether the Snap has a given permission. */ export function validateJsxElements( node: JSXElement, @@ -440,12 +442,14 @@ export function validateJsxElements( isOnPhishingList, getSnap, getAccountByAddress, + hasPermission, }: { isOnPhishingList: (url: string) => boolean; getSnap: (id: string) => Snap | undefined; getAccountByAddress: ( address: CaipAccountId, ) => InternalAccount | undefined; + hasPermission: (permission: string) => boolean; }, ) { walkJsx(node, (childNode) => { @@ -461,6 +465,15 @@ export function validateJsxElements( getAccountByAddress, ); break; + case 'Image': { + const { src } = childNode.props; + const isUrl = isValidUrl(src); + assert( + !isUrl || (isUrl && hasPermission('endowment:network-access')), + 'Using external images is only permitted with the `endowment:network-access` permission.', + ); + break; + } default: break; }