Skip to content
This repository has been archived by the owner on Oct 7, 2024. It is now read-only.

Commit

Permalink
feat: enforce async request redirect URL is in the snaps 'allowedOrig…
Browse files Browse the repository at this point in the history
…ins' (#228)

* Feat: added check for async requests to enforce redirect url is in the snap's allowed origins

* Feat: created 'getSnapAllowedOrigins' private utility function and updated optional chaining logic

* Feat: updated tests for validating async redirect urls and added checks for undefined

* Fix: no allowed origin error message check in tests

* Feat: updated allowed origins check to return empty [] instead of throw an error if undefined

* Feat: updated not allowed origins redirect test to use helper function
  • Loading branch information
k-g-j authored Mar 5, 2024
1 parent 9a9ea6a commit b82f2ab
Show file tree
Hide file tree
Showing 2 changed files with 113 additions and 0 deletions.
83 changes: 83 additions & 0 deletions src/SnapKeyring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,19 @@ describe('SnapKeyring', () => {
])('returns a redirect %s', async (redirect) => {
const spy = jest.spyOn(console, 'log').mockImplementation();

const snapObject = {
id: snapId,
manifest: {
initialPermissions: {
'endowment:keyring': {
allowedOrigins: ['https://example.com'],
},
},
},
enabled: true,
};
mockSnapController.get.mockReturnValue(snapObject);

mockSnapController.handleRequest.mockResolvedValue({
pending: true,
redirect,
Expand Down Expand Up @@ -353,6 +366,76 @@ describe('SnapKeyring', () => {
spy.mockRestore();
});

describe('async request redirect url', () => {
const isNotAllowedOrigin = async (
allowedOrigins: string[],
redirectUrl: string,
) => {
const { origin } = new URL(redirectUrl);
const snapObject = {
id: snapId,
manifest: {
initialPermissions:
allowedOrigins.length > 0
? { 'endowment:keyring': { allowedOrigins } }
: {},
},
enabled: true,
};
mockSnapController.get.mockReturnValue(snapObject);
mockSnapController.handleRequest.mockResolvedValue({
pending: true,
redirect: {
message: 'Go to dapp to continue.',
url: redirectUrl,
},
});
const requestPromise = keyring.signPersonalMessage(
accounts[0].address,
'hello',
);

await expect(requestPromise).rejects.toThrow(
`Redirect URL domain '${origin}' is not an allowed origin by snap '${snapId}'`,
);
};

it('throws an error if async request redirect url is not an allowed origin', async () => {
expect.hasAssertions();
await isNotAllowedOrigin(
['https://allowed.com'],
'https://notallowed.com/sign?tx=1234',
);
});

it('throws an error if no allowed origins', async () => {
expect.hasAssertions();
await isNotAllowedOrigin([], 'https://example.com/sign?tx=1234');
});

it('throws an error if the snap is undefined', async () => {
const redirect = {
message: 'Go to dapp to continue.',
url: 'https://example.com/sign?tx=1234',
};

mockSnapController.get.mockReturnValue(undefined);

mockSnapController.handleRequest.mockResolvedValue({
pending: true,
redirect,
});
const requestPromise = keyring.signPersonalMessage(
accounts[0].address,
'hello',
);

await expect(requestPromise).rejects.toThrow(
`Snap '${snapId}' not found.`,
);
});
});

it('rejects an async request', async () => {
mockSnapController.handleRequest.mockResolvedValue({
pending: true,
Expand Down
30 changes: 30 additions & 0 deletions src/SnapKeyring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '@metamask/keyring-api';
import type { SnapController } from '@metamask/snaps-controllers';
import type { SnapId } from '@metamask/snaps-sdk';
import type { Snap } from '@metamask/snaps-utils';
import type { Json } from '@metamask/utils';
import { bigIntToHex } from '@metamask/utils';
import { EventEmitter } from 'events';
Expand Down Expand Up @@ -436,6 +437,22 @@ export class SnapKeyring extends EventEmitter {
// If the snap answers asynchronously, we will inform the user with a redirect
if (response.redirect?.message || response.redirect?.url) {
const { message = '', url = '' } = response.redirect;

// Check redirect url domain is in the snap allowed origins
if (url) {
const { origin } = new URL(url);
const snap = this.#snapClient.getController().get(snapId);
if (!snap) {
throw new Error(`Snap '${snapId}' not found.`);
}
const allowedOrigins = this.#getSnapAllowedOrigins(snap);
if (!allowedOrigins.includes(origin)) {
throw new Error(
`Redirect URL domain '${origin}' is not an allowed origin by snap '${snapId}'`,
);
}
}

await this.#callbacks.redirectUser(snapId, url, message);
}

Expand Down Expand Up @@ -695,6 +712,19 @@ export class SnapKeyring extends EventEmitter {
: undefined;
}

/**
* Get the allowed origins of a snap.
*
* @param snap - Snap.
* @returns The allowed origins of the snap.
*/
#getSnapAllowedOrigins(snap: Snap): string[] {
return (
snap.manifest.initialPermissions['endowment:keyring']?.allowedOrigins ??
[]
);
}

/**
* Return an internal account object for a given address.
*
Expand Down

0 comments on commit b82f2ab

Please sign in to comment.