Skip to content

Commit

Permalink
Parse and validate deep link in main process
Browse files Browse the repository at this point in the history
  • Loading branch information
ravicious committed Oct 19, 2023
1 parent d7bec41 commit d568d54
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 3 deletions.
84 changes: 84 additions & 0 deletions web/packages/teleterm/src/deepLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Copyright 2023 Gravitational, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { DeepLinkParseResult, parseDeepLink } from './deepLinks';
import { routing } from './ui/uri';

beforeEach(() => {
jest.restoreAllMocks();
});

describe('parseDeepLink', () => {
describe('valid input', () => {
const tests: Array<string> = [
'teleport:///clusters/foo/connect_my_computer',
'teleport:///clusters/test.example.com/connect_my_computer?username=alice@example.com',
];

test.each(tests)('%s', input => {
jest.spyOn(routing, 'parseDeepLinkUri');
const uri = input.replace('teleport://', '');

const result = parseDeepLink(input);

expect(result.status).toBe('success');
expect(result.status === 'success' && result.parsedUri).not.toBeFalsy();
expect(routing.parseDeepLinkUri).toHaveBeenCalledWith(uri);
});
});

describe('invalid input', () => {
const tests: Array<{ input: string; output: DeepLinkParseResult }> = [
{
input: 'teleport://hello\\foo@bar:baz',
output: {
status: 'error',
reason: 'malformed-url',
error: expect.any(TypeError),
},
},
{
input: 'teleport:///clusters/foo',
output: {
status: 'error',
reason: 'unsupported-uri',
},
},
{
input: 'teleport:///foo/bar',
output: {
status: 'error',
reason: 'unsupported-uri',
},
},
{
input: 'foobar:///clusters/foo/connect_my_computer',
output: {
status: 'error',
reason: 'unknown-protocol',
protocol: 'foobar:',
},
},
];

test.each(tests)('$input', ({ input, output }) => {
jest.spyOn(routing, 'parseDeepLinkUri').mockImplementation(() => null);

const result = parseDeepLink(input);
expect(result).toEqual(output);
});
});
});
81 changes: 81 additions & 0 deletions web/packages/teleterm/src/deepLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Copyright 2023 Gravitational, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as whatwg from 'whatwg-url';

import {
TELEPORT_CUSTOM_PROTOCOL,
DeepLinkParsedUri,
routing,
} from 'teleterm/ui/uri';

export type DeepLinkParseResult =
// Just having a field like `ok: true` for success and `status: 'error'` for errors would be much more
// ergonomic. Unfortunately, `if (!result.ok)` doesn't narrow down the type properly with
// strictNullChecks off. https://github.com/microsoft/TypeScript/issues/10564
| { status: 'success'; parsedUri: DeepLinkParsedUri }
| ParseError<'malformed-url', { error: TypeError }>
| ParseError<'unknown-protocol', { protocol: string }>
| ParseError<'unsupported-uri'>;

type ParseError<Reason, AdditionalData = void> = AdditionalData extends void
? {
status: 'error';
reason: Reason;
}
: {
status: 'error';
reason: Reason;
} & AdditionalData;

/**
* parseDeepLink receives a full URL of a deep link passed to Connect, e.g.
* teleport:///clusters/foo/connect_my_computer and returns its parsed form if the underlying URI is
* supported by the app.
*
* Returning a parsed form was a conscious decision – this way it's clear that the parsed form is
* valid and can be passed along safely from the main process to the renderer vs raw string URLs
* which don't carry any information by themselves about their validity – in that scenario, they'd
* have to be parsed on both ends.
*/
export function parseDeepLink(rawUrl: string): DeepLinkParseResult {
let url: whatwg.URL;
try {
url = new whatwg.URL(rawUrl);
} catch (error) {
if (error instanceof TypeError) {
// Invalid URL.
return { status: 'error', reason: 'malformed-url', error };
}
throw error;
}

if (url.protocol !== `${TELEPORT_CUSTOM_PROTOCOL}:`) {
return {
status: 'error',
reason: 'unknown-protocol',
protocol: url.protocol,
};
}

const uri = url.pathname + url.search;
const parsedUri = routing.parseDeepLinkUri(uri);

if (!parsedUri) {
return { status: 'error', reason: 'unsupported-uri' };
}

return { status: 'success', parsedUri };
}
32 changes: 32 additions & 0 deletions web/packages/teleterm/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
import { createFileStorage } from 'teleterm/services/fileStorage';
import { WindowsManager } from 'teleterm/mainProcess/windowsManager';
import { TELEPORT_CUSTOM_PROTOCOL } from 'teleterm/ui/uri';
import { parseDeepLink } from 'teleterm/deepLinks';
import { assertUnreachable } from 'teleterm/ui/utils';

// Set the app as a default protocol client only if it wasn't started through `electron .`.
if (!process.defaultApp) {
Expand Down Expand Up @@ -290,6 +292,7 @@ function setUpDeepLinks(
windowsManager.focusWindow();

logger.info(`Deep link launch from open-url, URL: ${url}`);
launchDeepLink(logger, url);
});
return;
}
Expand All @@ -308,6 +311,7 @@ function setUpDeepLinks(
const url = findCustomProtocolUrlInArgv(argv);
if (url) {
logger.info(`Deep link launch from second-instance, URI: ${url}`);
launchDeepLink(logger, url);
}
});

Expand All @@ -318,10 +322,38 @@ function setUpDeepLinks(
return;
}
logger.info(`Deep link launch from process.argv, URL: ${url}`);
launchDeepLink(logger, url);
}

// We don't know the exact position of the URL is in argv. Chromium might inject its own arguments
// into argv. See https://www.electronjs.org/docs/latest/api/app#event-second-instance.
function findCustomProtocolUrlInArgv(argv: string[]) {
return argv.find(arg => arg.startsWith(`${TELEPORT_CUSTOM_PROTOCOL}://`));
}

function launchDeepLink(logger: Logger, rawUrl: string): void {
const result = parseDeepLink(rawUrl);

if (result.status === 'error') {
let reason: string;
switch (result.reason) {
case 'unknown-protocol': {
reason = `unknown protocol of the deep link ("${result.protocol}")`;
break;
}
case 'unsupported-uri': {
reason = 'unsupported URI received';
break;
}
case 'malformed-url': {
reason = `malformed URL (${result.error.message})`;
break;
}
default: {
assertUnreachable(result);
}
}

logger.error(`Skipping deep link launch, ${reason}`);
}
}
26 changes: 23 additions & 3 deletions web/packages/teleterm/src/ui/uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ export type ClusterOrResourceUri = ResourceUri | ClusterUri;
* These are for actions that can be performed by clicking on teleport-connect links.
*/

export const TELEPORT_CUSTOM_PROTOCOL = 'teleport' as const;

export type DeepLinkUri = ConnectMyComputerUri;

/**
* DeepLinkParsedUri values are passed through webContents.send and thus they must contain only
* values that work with the structured clone algorithm.
*
* https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
*/
export type DeepLinkParsedUri = ConnectMyComputerParsedUri;

export type ConnectMyComputerUri =
`/clusters/${RootClusterId}/connect_my_computer`;
export type ConnectMyComputerParsedUri = match<ConnectMyComputerUriParams> & {
Expand Down Expand Up @@ -93,8 +105,6 @@ export type DocumentUri = `/docs/${DocumentId}`;
type GatewayId = string;
export type GatewayUri = `/gateways/${GatewayId}`;

export const TELEPORT_CUSTOM_PROTOCOL = 'teleport' as const;

export const paths = {
// Resources.
rootCluster: '/clusters/:rootClusterId',
Expand Down Expand Up @@ -141,6 +151,16 @@ export const routing = {
return routing.parseUri(uri, paths.server);
},

/**
* parseDeepLinkUri returns extracted params from a URI if it matches one of the supported deep
* link paths. Returns null otherwise.
*
* @param uri - uri is expected to follow the format of DeepLinkUri.
*/
parseDeepLinkUri(uri: string): DeepLinkParsedUri {
return routing.parseConnectMyComputerUri(uri);
},

/**
* Returns extracted params from a URI if it matches the path of ConnectMyComputerUri. Returns
* null otherwise.
Expand All @@ -165,7 +185,7 @@ export const routing = {
try {
// The second argument doesn't play any role beyond making the URL constructor correctly parse
// the passed in uri, which in essence is just the pathname part of a URL.
url = new whatwg.URL(uri, `${CONNECT_CUSTOM_PROTOCOL}://`);
url = new whatwg.URL(uri, `${TELEPORT_CUSTOM_PROTOCOL}://`);
} catch (error) {
if (error instanceof TypeError) {
// Invalid URL. Return null to behave like matchPath.
Expand Down

0 comments on commit d568d54

Please sign in to comment.