diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index d0e563b2c0..e6bfac87e3 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "jwgw2+/MUcLjedTMSpFqL/dhwR8/j3gHWmNJVevvfWE=", + "shasum": "oeprRlfJxxGH+tvOyO7i4hy3l0SjzR3rA73tZS7vlZk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index 10e6d77211..bf36e9f6a9 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "u+bnPdpw6us0nDDr3k28cfDoIA+5CT1Y7A88nYJLNGk=", + "shasum": "PL+Bg6eUiAl4uBSuGbX4gdcrgxFZIedy/N7FjVMzKIY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 34b7a7350f..194585d11c 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { "branches": 92.6, - "functions": 96.95, - "lines": 98.02, - "statements": 97.72 + "functions": 96.65, + "lines": 97.97, + "statements": 97.67 } diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts index fb323471f5..70cb81abdd 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -25,6 +25,7 @@ import { assert } from '@metamask/utils'; import { castDraft } from 'immer'; import { nanoid } from 'nanoid'; +import type { GetSnap } from '../snaps'; import { constructState, getJsxInterface, @@ -74,7 +75,8 @@ export type SnapInterfaceControllerAllowedActions = | TestOrigin | MaybeUpdateState | HasApprovalRequest - | AcceptRequest; + | AcceptRequest + | GetSnap; export type SnapInterfaceControllerActions = | CreateInterface @@ -379,6 +381,10 @@ export class SnapInterfaceController extends BaseController< ); await this.#triggerPhishingListUpdate(); - validateJsxLinks(element, this.#checkPhishingList.bind(this)); + validateJsxLinks( + element, + this.#checkPhishingList.bind(this), + (id: string) => this.messagingSystem.call('SnapController:get', id), + ); } } diff --git a/packages/snaps-rpc-methods/src/restricted/notify.test.ts b/packages/snaps-rpc-methods/src/restricted/notify.test.ts index aeecba1a38..fc62b2854a 100644 --- a/packages/snaps-rpc-methods/src/restricted/notify.test.ts +++ b/packages/snaps-rpc-methods/src/restricted/notify.test.ts @@ -20,6 +20,7 @@ describe('snap_notify', () => { showInAppNotification: jest.fn(), isOnPhishingList: jest.fn(), maybeUpdatePhishingList: jest.fn(), + getSnap: jest.fn(), }; expect( @@ -41,6 +42,7 @@ describe('snap_notify', () => { const showNativeNotification = jest.fn().mockResolvedValueOnce(true); const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); + const getSnap = jest.fn(); const maybeUpdatePhishingList = jest.fn(); const notificationImplementation = getImplementation({ @@ -48,6 +50,7 @@ describe('snap_notify', () => { showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + getSnap, }); await notificationImplementation({ @@ -72,12 +75,14 @@ describe('snap_notify', () => { const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); const maybeUpdatePhishingList = jest.fn(); + const getSnap = jest.fn(); const notificationImplementation = getImplementation({ showNativeNotification, showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + getSnap, }); await notificationImplementation({ @@ -102,12 +107,14 @@ describe('snap_notify', () => { const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); const maybeUpdatePhishingList = jest.fn(); + const getSnap = jest.fn(); const notificationImplementation = getImplementation({ showNativeNotification, showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + getSnap, }); await notificationImplementation({ @@ -132,12 +139,14 @@ describe('snap_notify', () => { const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(false); const maybeUpdatePhishingList = jest.fn(); + const getSnap = jest.fn(); const notificationImplementation = getImplementation({ showNativeNotification, showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + getSnap, }); await expect( @@ -160,12 +169,14 @@ describe('snap_notify', () => { const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(true); const maybeUpdatePhishingList = jest.fn(); + const getSnap = jest.fn(); const notificationImplementation = getImplementation({ showNativeNotification, showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + getSnap, }); await expect( @@ -187,12 +198,14 @@ describe('snap_notify', () => { const showInAppNotification = jest.fn().mockResolvedValueOnce(true); const isOnPhishingList = jest.fn().mockResolvedValueOnce(true); const maybeUpdatePhishingList = jest.fn(); + const getSnap = jest.fn(); const notificationImplementation = getImplementation({ showNativeNotification, showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + getSnap, }); await expect( @@ -207,7 +220,7 @@ describe('snap_notify', () => { }, }), ).rejects.toThrow( - 'Invalid URL: Protocol must be one of: https:, mailto:.', + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', ); }); }); diff --git a/packages/snaps-rpc-methods/src/restricted/notify.ts b/packages/snaps-rpc-methods/src/restricted/notify.ts index 8c4c11f882..86d551951a 100644 --- a/packages/snaps-rpc-methods/src/restricted/notify.ts +++ b/packages/snaps-rpc-methods/src/restricted/notify.ts @@ -11,7 +11,7 @@ import type { NotifyResult, EnumToUnion, } from '@metamask/snaps-sdk'; -import { validateTextLinks } from '@metamask/snaps-utils'; +import { type Snap, validateTextLinks } from '@metamask/snaps-utils'; import type { NonEmptyArray } from '@metamask/utils'; import { isObject } from '@metamask/utils'; @@ -53,6 +53,8 @@ export type NotifyMethodHooks = { isOnPhishingList: (url: string) => boolean; maybeUpdatePhishingList: () => Promise; + + getSnap: (snapId: string) => Snap | undefined; }; type SpecificationBuilderOptions = { @@ -95,6 +97,7 @@ const methodHooks: MethodHooksObject = { showInAppNotification: true, isOnPhishingList: true, maybeUpdatePhishingList: true, + getSnap: true, }; export const notifyBuilder = Object.freeze({ @@ -111,6 +114,7 @@ export const notifyBuilder = Object.freeze({ * @param hooks.showInAppNotification - A function that shows a notification in the MetaMask UI. * @param hooks.isOnPhishingList - A function that checks for links against the phishing list. * @param hooks.maybeUpdatePhishingList - A function that updates the phishing list if needed. + * @param hooks.getSnap - A function that checks if a snap is installed. * @returns The method implementation which returns `null` on success. * @throws If the params are invalid. */ @@ -119,6 +123,7 @@ export function getImplementation({ showInAppNotification, isOnPhishingList, maybeUpdatePhishingList, + getSnap, }: NotifyMethodHooks) { return async function implementation( args: RestrictedMethodOptions, @@ -132,7 +137,7 @@ export function getImplementation({ await maybeUpdatePhishingList(); - validateTextLinks(validatedParams.message, isOnPhishingList); + validateTextLinks(validatedParams.message, isOnPhishingList, getSnap); switch (validatedParams.type) { case NotificationType.Native: diff --git a/packages/snaps-sdk/src/jsx/components/Link.test.tsx b/packages/snaps-sdk/src/jsx/components/Link.test.tsx index a28fedd27f..31752f1a07 100644 --- a/packages/snaps-sdk/src/jsx/components/Link.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/Link.test.tsx @@ -1,3 +1,4 @@ +import { Icon } from './Icon'; import { Link } from './Link'; describe('Link', () => { @@ -27,6 +28,27 @@ describe('Link', () => { }); }); + it('renders a link with an icon', () => { + const result = ( + + + + ); + + expect(result).toStrictEqual({ + type: 'Link', + key: null, + props: { + href: 'metamask://client/', + children: { + type: 'Icon', + key: null, + props: { name: 'arrow-left', size: 'md' }, + }, + }, + }); + }); + it('renders a link with a conditional value', () => { const result = ( diff --git a/packages/snaps-sdk/src/jsx/components/Link.ts b/packages/snaps-sdk/src/jsx/components/Link.ts index 047ba7b0ed..b9698536f6 100644 --- a/packages/snaps-sdk/src/jsx/components/Link.ts +++ b/packages/snaps-sdk/src/jsx/components/Link.ts @@ -1,11 +1,15 @@ import type { SnapsChildren } from '../component'; import { createSnapComponent } from '../component'; import type { StandardFormattingElement } from './formatting'; +import { type IconElement } from './Icon'; +import { type ImageElement } from './Image'; /** * The children of the {@link Link} component. */ -export type LinkChildren = SnapsChildren; +export type LinkChildren = SnapsChildren< + string | StandardFormattingElement | IconElement | ImageElement +>; /** * The props of the {@link Link} component. diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index 8c6137e70b..428d2e8764 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -991,12 +991,14 @@ describe('ImageStruct', () => { }); describe('LinkStruct', () => { - it.each([foo])( - 'validates a link element', - (value) => { - expect(is(value, LinkStruct)).toBe(true); - }, - ); + it.each([ + foo, + + + , + ])('validates a link element', (value) => { + expect(is(value, LinkStruct)).toBe(true); + }); it.each([ 'foo', diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index 48449d7c67..a2832e6116 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -590,7 +590,7 @@ export const HeadingStruct: Describe = element('Heading', { */ export const LinkStruct: Describe = element('Link', { href: string(), - children: children([FormattingStruct, string()]), + children: children([FormattingStruct, string(), IconStruct, ImageStruct]), }); /** diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index bb96afe355..deb8bde080 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { - "branches": 99.73, + "branches": 99.74, "functions": 98.9, - "lines": 99.44, - "statements": 96.34 + "lines": 99.45, + "statements": 96.29 } diff --git a/packages/snaps-utils/src/ui.test.tsx b/packages/snaps-utils/src/ui.test.tsx index 5532c472fa..e68b13b307 100644 --- a/packages/snaps-utils/src/ui.test.tsx +++ b/packages/snaps-utils/src/ui.test.tsx @@ -546,8 +546,8 @@ describe('validateLink', () => { it('passes for a valid link', () => { const fn = jest.fn().mockReturnValue(false); - expect(() => validateLink('https://foo.bar', fn)).not.toThrow(); - expect(() => validateLink('mailto:foo@bar.com', fn)).not.toThrow(); + expect(() => validateLink('https://foo.bar', fn, fn)).not.toThrow(); + expect(() => validateLink('mailto:foo@bar.com', fn, fn)).not.toThrow(); expect(fn).toHaveBeenCalledTimes(2); expect(fn).toHaveBeenCalledWith('foo.bar'); @@ -581,17 +581,31 @@ describe('validateLink', () => { it('throws an error for an invalid protocol', () => { const fn = jest.fn().mockReturnValue(false); - expect(() => validateLink('http://foo.bar', fn)).toThrow( - 'Invalid URL: Protocol must be one of: https:, mailto:.', + expect(() => validateLink('http://foo.bar', fn, fn)).toThrow( + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', ); expect(fn).not.toHaveBeenCalled(); }); + it('throws an error if the snap being navigated to is not installed', () => { + const fn = jest.fn().mockReturnValue(false); + + expect(() => + validateLink( + 'metamask://snap/npm:@metamask/examplesnap/home', + fn, + jest.fn().mockReturnValue(false), + ), + ).toThrow('Invalid URL: The Snap being navigated to is not installed.'); + + expect(fn).not.toHaveBeenCalled(); + }); + it('throws an error for an invalid URL', () => { const fn = jest.fn().mockReturnValue(false); - expect(() => validateLink('foo.bar', fn)).toThrow( + expect(() => validateLink('foo.bar', fn, fn)).toThrow( 'Invalid URL: Unable to parse URL.', ); @@ -601,9 +615,9 @@ describe('validateLink', () => { it('throws an error for a phishing link', () => { const fn = jest.fn().mockReturnValue(true); - expect(() => validateLink('https://test.metamask-phishing.io', fn)).toThrow( - 'Invalid URL: The specified URL is not allowed.', - ); + expect(() => + validateLink('https://test.metamask-phishing.io', fn, fn), + ).toThrow('Invalid URL: The specified URL is not allowed.'); expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledWith('test.metamask-phishing.io'); @@ -613,7 +627,7 @@ describe('validateLink', () => { const fn = jest.fn().mockReturnValue(true); expect(() => - validateLink('mailto:foo@test.metamask-phishing.io', fn), + validateLink('mailto:foo@test.metamask-phishing.io', fn, fn), ).toThrow('Invalid URL: The specified URL is not allowed.'); expect(fn).toHaveBeenCalledTimes(1); @@ -662,27 +676,31 @@ describe('validateLink', () => { describe('validateTextLinks', () => { it('passes for valid links', () => { expect(() => - validateTextLinks('[test](https://foo.bar)', () => false), + validateTextLinks('[test](https://foo.bar)', () => false, jest.fn()), ).not.toThrow(); expect(() => - validateTextLinks('[test](mailto:foo@bar.baz)', () => false), + validateTextLinks('[test](mailto:foo@bar.baz)', () => false, jest.fn()), ).not.toThrow(); expect(() => - validateTextLinks('[](https://foo.bar)', () => false), + validateTextLinks('[](https://foo.bar)', () => false, jest.fn()), ).not.toThrow(); expect(() => - validateTextLinks('[[test]](https://foo.bar)', () => false), + validateTextLinks('[[test]](https://foo.bar)', () => false, jest.fn()), ).not.toThrow(); expect(() => - validateTextLinks('[test](https://foo.bar "foo bar baz")', () => false), + validateTextLinks( + '[test](https://foo.bar "foo bar baz")', + () => false, + jest.fn(), + ), ).not.toThrow(); expect(() => - validateTextLinks('', () => false), + validateTextLinks('', () => false, jest.fn()), ).not.toThrow(); expect(() => @@ -690,6 +708,7 @@ describe('validateTextLinks', () => { `[foo][1] [1]: https://foo.bar`, () => false, + jest.fn(), ), ).not.toThrow(); @@ -698,51 +717,77 @@ describe('validateTextLinks', () => { `[foo][1] [1]: https://foo.bar "foo bar baz"`, () => false, + jest.fn(), ), ).not.toThrow(); }); it('passes for non-links', () => { expect(() => - validateTextLinks('Hello **http://localhost:3000**', () => false), + validateTextLinks( + 'Hello **http://localhost:3000**', + () => false, + jest.fn(), + ), ).not.toThrow(); }); it('throws an error if an invalid link is found in text', () => { expect(() => - validateTextLinks('[test](http://foo.bar)', () => false), - ).toThrow('Invalid URL: Protocol must be one of: https:, mailto:.'); + validateTextLinks('[test](http://foo.bar)', () => false, jest.fn()), + ).toThrow( + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', + ); expect(() => - validateTextLinks('[[test]](http://foo.bar)', () => false), - ).toThrow('Invalid URL: Protocol must be one of: https:, mailto:.'); + validateTextLinks('[[test]](http://foo.bar)', () => false, jest.fn()), + ).toThrow( + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', + ); - expect(() => validateTextLinks('', () => false)).toThrow( - 'Invalid URL: Protocol must be one of: https:, mailto:.', + expect(() => + validateTextLinks('', () => false, jest.fn()), + ).toThrow( + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', ); expect(() => - validateTextLinks('[test](http://foo.bar "foo bar baz")', () => false), - ).toThrow('Invalid URL: Protocol must be one of: https:, mailto:.'); + validateTextLinks( + '[test](http://foo.bar "foo bar baz")', + () => false, + jest.fn(), + ), + ).toThrow( + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', + ); expect(() => - validateTextLinks('[foo][1]\n\n[1]: http://foo.bar', () => false), - ).toThrow('Invalid URL: Protocol must be one of: https:, mailto:.'); + validateTextLinks( + '[foo][1]\n\n[1]: http://foo.bar', + () => false, + jest.fn(), + ), + ).toThrow( + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', + ); expect(() => validateTextLinks( `[foo][1]\n\n[1]: http://foo.bar "foo bar baz"`, () => false, + jest.fn(), ), - ).toThrow('Invalid URL: Protocol must be one of: https:, mailto:.'); - - expect(() => validateTextLinks('[test](#code)', () => false)).toThrow( - 'Invalid URL: Unable to parse URL.', + ).toThrow( + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', ); - expect(() => validateTextLinks('[test](foo.bar)', () => false)).toThrow( - 'Invalid URL: Unable to parse URL.', - ); + expect(() => + validateTextLinks('[test](#code)', () => false, jest.fn()), + ).toThrow('Invalid URL: Unable to parse URL.'); + + expect(() => + validateTextLinks('[test](foo.bar)', () => false, jest.fn()), + ).toThrow('Invalid URL: Unable to parse URL.'); }); }); @@ -761,7 +806,9 @@ describe('validateJsxLinks', () => { ])('does not throw for a safe JSX text component', async (element) => { const isOnPhishingList = () => false; - expect(() => validateJsxLinks(element, isOnPhishingList)).not.toThrow(); + expect(() => + validateJsxLinks(element, isOnPhishingList, jest.fn()), + ).not.toThrow(); }); it('does not throw for a JSX component with a link outside of a Link component', async () => { @@ -774,6 +821,7 @@ describe('validateJsxLinks', () => { https://foo.bar , isOnPhishingList, + jest.fn(), ), ).not.toThrow(); }); @@ -792,24 +840,34 @@ describe('validateJsxLinks', () => { ])('throws for an unsafe JSX text component', async (element) => { const isOnPhishingList = () => true; - expect(() => validateJsxLinks(element, isOnPhishingList)).toThrow( - 'Invalid URL: The specified URL is not allowed.', - ); + expect(() => + validateJsxLinks(element, isOnPhishingList, jest.fn()), + ).toThrow('Invalid URL: The specified URL is not allowed.'); }); it('throws if the protocol is not allowed', () => { const isOnPhishingList = () => false; expect(() => - validateJsxLinks(Foo, isOnPhishingList), - ).toThrow('Invalid URL: Protocol must be one of: https:, mailto:.'); + validateJsxLinks( + Foo, + isOnPhishingList, + jest.fn(), + ), + ).toThrow( + 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', + ); }); it('throws if the URL cannot be parsed', () => { const isOnPhishingList = () => false; expect(() => - validateJsxLinks(Foo, isOnPhishingList), + validateJsxLinks( + Foo, + isOnPhishingList, + jest.fn(), + ), ).toThrow('Invalid URL: Unable to parse URL.'); }); }); diff --git a/packages/snaps-utils/src/ui.tsx b/packages/snaps-utils/src/ui.tsx index 9af9380c4d..9802b4e10f 100644 --- a/packages/snaps-utils/src/ui.tsx +++ b/packages/snaps-utils/src/ui.tsx @@ -39,8 +39,11 @@ import { import { lexer, walkTokens } from 'marked'; import type { Token, Tokens } from 'marked'; +import type { Snap } from './snaps'; +import { parseMetaMaskUrl } from './url'; + const MAX_TEXT_LENGTH = 50_000; // 50 kb -const ALLOWED_PROTOCOLS = ['https:', 'mailto:']; +const ALLOWED_PROTOCOLS = ['https:', 'mailto:', 'metamask:']; /** * Get the button variant from a legacy button component variant. @@ -330,10 +333,13 @@ function getMarkdownLinks(text: string) { * @param link - The link to validate. * @param isOnPhishingList - The function that checks the link against the * phishing list. + * @param getSnap - The function that returns a snap if installed, undefined otherwise. + * @throws If the link is invalid. */ export function validateLink( link: string, isOnPhishingList: (url: string) => boolean, + getSnap: (id: string) => Snap | undefined, ) { try { const url = new URL(link); @@ -342,7 +348,15 @@ export function validateLink( `Protocol must be one of: ${ALLOWED_PROTOCOLS.join(', ')}.`, ); - if (url.protocol === 'mailto:') { + if (url.protocol === 'metamask:') { + const linkData = parseMetaMaskUrl(link); + if (linkData.snapId) { + assert( + getSnap(linkData.snapId), + 'The Snap being navigated to is not installed.', + ); + } + } else if (url.protocol === 'mailto:') { const emails = url.pathname.split(','); for (const email of emails) { const hostname = email.split('@')[1]; @@ -375,16 +389,18 @@ export function validateLink( * @param text - The text to verify. * @param isOnPhishingList - The function that checks the link against the * phishing list. + * @param getSnap - The function that returns a snap if installed, undefined otherwise. * @throws If the text contains a link that is not allowed. */ export function validateTextLinks( text: string, isOnPhishingList: (url: string) => boolean, + getSnap: (id: string) => Snap | undefined, ) { const links = getMarkdownLinks(text); for (const link of links) { - validateLink(link.href, isOnPhishingList); + validateLink(link.href, isOnPhishingList, getSnap); } } @@ -395,17 +411,19 @@ export function validateTextLinks( * @param node - The JSX node to walk. * @param isOnPhishingList - The function that checks the link against the * phishing list. + * @param getSnap - The function that returns a snap if installed, undefined otherwise. */ export function validateJsxLinks( node: JSXElement, isOnPhishingList: (url: string) => boolean, + getSnap: (id: string) => Snap | undefined, ) { walkJsx(node, (childNode) => { if (childNode.type !== 'Link') { return; } - validateLink(childNode.props.href, isOnPhishingList); + validateLink(childNode.props.href, isOnPhishingList, getSnap); }); } diff --git a/packages/snaps-utils/src/url.test.ts b/packages/snaps-utils/src/url.test.ts new file mode 100644 index 0000000000..aec81cf7b4 --- /dev/null +++ b/packages/snaps-utils/src/url.test.ts @@ -0,0 +1,67 @@ +import { parseMetaMaskUrl } from './url'; + +describe('parseMetaMaskUrl', () => { + it('can parse a valid url with the client authority', () => { + expect(parseMetaMaskUrl('metamask://client/')).toStrictEqual({ + authority: 'client', + path: '/', + }); + }); + + it('can parse a valid url with a namespaced snap', () => { + expect( + parseMetaMaskUrl('metamask://snap/npm:@metamask/examplesnap/home'), + ).toStrictEqual({ + authority: 'snap', + path: '/home', + snapId: 'npm:@metamask/examplesnap', + }); + }); + + it('can parse a valid url with a non-namespaced snap', () => { + expect( + parseMetaMaskUrl('metamask://snap/npm:examplesnap/home'), + ).toStrictEqual({ + authority: 'snap', + path: '/home', + snapId: 'npm:examplesnap', + }); + }); + + it('can parse a valid url with a local snap', () => { + expect( + parseMetaMaskUrl('metamask://snap/local:http://localhost:8080/home'), + ).toStrictEqual({ + authority: 'snap', + path: '/home', + snapId: 'local:http://localhost:8080', + }); + }); + + it('will throw on an invalid scheme', () => { + expect(() => parseMetaMaskUrl('metmask://client/')).toThrow( + 'Unable to parse URL. Expected the protocol to be "metamask:", but received "metmask:".', + ); + }); + + it('will throw on an invalid authority', () => { + expect(() => parseMetaMaskUrl('metamask://bar/')).toThrow( + 'Expected "metamask:" URL to start with "client" or "snap", but received "bar".', + ); + }); + + it.each([ + 'metamask://snap/npm:examplesnap/foo', + 'metamask://snap/npm:@metamask/examplesnap/foo', + ])('will throw on an invalid snap page', (url) => { + expect(() => parseMetaMaskUrl(url)).toThrow( + 'Invalid MetaMask url: invalid snap path.', + ); + }); + + it('will throw on an invalid client page', () => { + expect(() => parseMetaMaskUrl('metamask://client/foo')).toThrow( + 'Unable to navigate to "/foo". The provided path is not allowed.', + ); + }); +}); diff --git a/packages/snaps-utils/src/url.ts b/packages/snaps-utils/src/url.ts new file mode 100644 index 0000000000..4e9ac30340 --- /dev/null +++ b/packages/snaps-utils/src/url.ts @@ -0,0 +1,100 @@ +import { type SnapId } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/utils'; + +import { assertIsValidSnapId, stripSnapPrefix } from './snaps'; + +export type Authority = 'client' | 'snap'; + +export const CLIENT_PATHS = ['/']; + +export const SNAP_PATHS = ['/home']; + +/** + * Parse a url string to an object containing the authority, path and Snap id (if snap authority). + * This also validates the url before parsing it. + * + * Note: The Snap id returned from this function should always be validated to be an installed snap. + * + * @param str - The url string to validate and parse. + * @returns A parsed url object. + * @throws If the authority or path is invalid. + */ +export function parseMetaMaskUrl(str: string): { + authority: Authority; + snapId?: SnapId; + path: string; +} { + const url = new URL(str); + const { hostname: authority, pathname: path, protocol } = url; + if (protocol !== 'metamask:') { + throw new Error( + `Unable to parse URL. Expected the protocol to be "metamask:", but received "${protocol}".`, + ); + } + switch (authority) { + case 'client': + assert( + CLIENT_PATHS.includes(path), + `Unable to navigate to "${path}". The provided path is not allowed.`, + ); + return { + authority, + path, + }; + case 'snap': + return parseSnapPath(path); + default: + throw new Error( + `Expected "metamask:" URL to start with "client" or "snap", but received "${authority}".`, + ); + } +} + +/** + * Parse a snap path and throws if it is invalid, returns an object with link data otherwise. + * + * @param path - The snap path to be parsed. + * @returns A parsed url object. + * @throws If the path or Snap id is invalid. + */ +function parseSnapPath(path: string): { + authority: Authority; + snapId: SnapId; + path: string; +} { + const baseErrorMessage = 'Invalid MetaMask url:'; + const strippedPath = stripSnapPrefix(path.slice(1)); + const location = path.slice(1).startsWith('npm:') ? 'npm:' : 'local:'; + const isNameSpaced = strippedPath.startsWith('@'); + const pathTokens = strippedPath.split('/'); + const lastPathToken = `/${pathTokens[pathTokens.length - 1]}`; + let partialSnapId; + if (location === 'local:') { + const [localProtocol, , ...rest] = pathTokens.slice(0, -1); + partialSnapId = `${localProtocol}//${rest.join('/')}`; + // we can't make assumptions of the structure of the local snap url since it can have a nested path + // so we only check that the last path token is one of the allowed paths + assert( + SNAP_PATHS.includes(lastPathToken), + `${baseErrorMessage} invalid snap path.`, + ); + } else { + partialSnapId = isNameSpaced + ? `${pathTokens[0]}/${pathTokens[1]}` + : pathTokens[0]; + assert( + isNameSpaced + ? pathTokens.length === 3 && SNAP_PATHS.includes(lastPathToken) + : pathTokens.length === 2 && SNAP_PATHS.includes(lastPathToken), + `${baseErrorMessage} invalid snap path.`, + ); + } + const snapId = `${location}${partialSnapId}`; + assertIsValidSnapId(snapId); + + return { + authority: 'snap' as Authority, + snapId, + path: lastPathToken, + }; +}