diff --git a/app/spell_check.js b/app/spell_check.js index ab09e81a07..43b69411cb 100644 --- a/app/spell_check.js +++ b/app/spell_check.js @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable strict */ @@ -7,6 +7,7 @@ const { Menu, clipboard, nativeImage } = require('electron'); const osLocale = require('os-locale'); const { uniq } = require('lodash'); const url = require('url'); +const { maybeParseUrl } = require('../ts/util/url'); function getLanguages(userLocale, availableLocales) { const baseLocale = userLocale.split('-')[0]; @@ -97,7 +98,8 @@ exports.setup = (browserWindow, messages) => { label = messages.contextMenuCopyLink.message; } else if (isImage) { click = () => { - if (url.parse(params.srcURL).protocol !== 'file:') { + const parsedSrcUrl = maybeParseUrl(params.srcURL); + if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'file:') { return; } diff --git a/js/modules/link_previews.js b/js/modules/link_previews.js index 6dd0b49b5d..ced80a8121 100644 --- a/js/modules/link_previews.js +++ b/js/modules/link_previews.js @@ -1,11 +1,10 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global URL */ - const { isNumber, compact, isEmpty, range } = require('lodash'); const nodeUrl = require('url'); const LinkifyIt = require('linkify-it'); +const { maybeParseUrl } = require('../../ts/util/url'); const linkify = LinkifyIt(); @@ -18,16 +17,8 @@ module.exports = { isStickerPack, }; -function maybeParseHref(href) { - try { - return new URL(href); - } catch (err) { - return null; - } -} - function isLinkSafeToPreview(href) { - const url = maybeParseHref(href); + const url = maybeParseUrl(href); return Boolean(url && url.protocol === 'https:' && !isLinkSneaky(href)); } @@ -64,7 +55,7 @@ function findLinks(text, caretLocation) { } function getDomain(href) { - const url = maybeParseHref(href); + const url = maybeParseUrl(href); return url ? url.hostname : null; } @@ -109,7 +100,7 @@ function isLinkSneaky(href) { return true; } - const url = maybeParseHref(href); + const url = maybeParseUrl(href); // If we can't parse it, it's sneaky. if (!url) { diff --git a/js/modules/stickers.js b/js/modules/stickers.js index 6c53e3f21d..aafcb4bcaa 100644 --- a/js/modules/stickers.js +++ b/js/modules/stickers.js @@ -1,4 +1,4 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* global @@ -8,7 +8,6 @@ navigator, reduxStore, reduxActions, - URL, URLSearchParams */ @@ -38,6 +37,7 @@ const pMap = require('p-map'); const Queue = require('p-queue').default; const { makeLookup } = require('../../ts/util/makeLookup'); +const { maybeParseUrl } = require('../../ts/util/url'); const { base64ToArrayBuffer, deriveStickerPackKey, @@ -96,10 +96,8 @@ async function load() { } function getDataFromLink(link) { - let url; - try { - url = new URL(link); - } catch (err) { + const url = maybeParseUrl(link); + if (!url) { return null; } diff --git a/main.js b/main.js index 36c5e9e4a4..3d739a26e1 100644 --- a/main.js +++ b/main.js @@ -4,7 +4,7 @@ /* eslint-disable no-console */ const path = require('path'); -const url = require('url'); +const { pathToFileURL } = require('url'); const os = require('os'); const fs = require('fs-extra'); const crypto = require('crypto'); @@ -123,6 +123,7 @@ const { } = require('./ts/types/Settings'); const { Environment } = require('./ts/environment'); const { ChallengeMainHandler } = require('./ts/main/challengeMain'); +const { maybeParseUrl, setUrlSearchParams } = require('./ts/util/url'); const sql = new MainSQL(); const challengeHandler = new ChallengeMainHandler(); @@ -228,49 +229,57 @@ const loadLocale = require('./app/locale').load; let logger; let locale; -function prepareURL(pathSegments, moreKeys) { - const parsed = url.parse(path.join(...pathSegments)); +function prepareFileUrl( + pathSegments /* : ReadonlyArray */, + moreKeys /* : undefined | Record */ +) /* : string */ { + const filePath = path.join(...pathSegments); + const fileUrl = pathToFileURL(filePath); + return prepareUrl(fileUrl, moreKeys); +} - return url.format({ - ...parsed, - protocol: parsed.protocol || 'file:', - slashes: true, - query: { - name: packageJson.productName, - locale: locale.name, - version: app.getVersion(), - buildExpiration: config.get('buildExpiration'), - serverUrl: config.get('serverUrl'), - storageUrl: config.get('storageUrl'), - directoryUrl: config.get('directoryUrl'), - directoryEnclaveId: config.get('directoryEnclaveId'), - directoryTrustAnchor: config.get('directoryTrustAnchor'), - cdnUrl0: config.get('cdn').get('0'), - cdnUrl2: config.get('cdn').get('2'), - certificateAuthority: config.get('certificateAuthority'), - environment: enableCI ? 'production' : config.environment, - enableCI: enableCI ? true : undefined, - node_version: process.versions.node, - hostname: os.hostname(), - appInstance: process.env.NODE_APP_INSTANCE, - proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, - contentProxyUrl: config.contentProxyUrl, - sfuUrl: config.get('sfuUrl'), - importMode: importMode ? true : undefined, // for stringify() - reducedMotionSetting: animationSettings.prefersReducedMotion - ? true - : undefined, - serverPublicParams: config.get('serverPublicParams'), - serverTrustRoot: config.get('serverTrustRoot'), - appStartInitialSpellcheckSetting, - ...moreKeys, - }, - }); +function prepareUrl( + url /* : URL */, + moreKeys = {} /* : undefined | Record */ +) /* : string */ { + return setUrlSearchParams(url, { + name: packageJson.productName, + locale: locale.name, + version: app.getVersion(), + buildExpiration: config.get('buildExpiration'), + serverUrl: config.get('serverUrl'), + storageUrl: config.get('storageUrl'), + directoryUrl: config.get('directoryUrl'), + directoryEnclaveId: config.get('directoryEnclaveId'), + directoryTrustAnchor: config.get('directoryTrustAnchor'), + cdnUrl0: config.get('cdn').get('0'), + cdnUrl2: config.get('cdn').get('2'), + certificateAuthority: config.get('certificateAuthority'), + environment: enableCI ? 'production' : config.environment, + enableCI: enableCI ? 'true' : '', + node_version: process.versions.node, + hostname: os.hostname(), + appInstance: process.env.NODE_APP_INSTANCE, + proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, + contentProxyUrl: config.contentProxyUrl, + sfuUrl: config.get('sfuUrl'), + importMode: importMode ? 'true' : '', + reducedMotionSetting: animationSettings.prefersReducedMotion ? 'true' : '', + serverPublicParams: config.get('serverPublicParams'), + serverTrustRoot: config.get('serverTrustRoot'), + appStartInitialSpellcheckSetting, + ...moreKeys, + }).href; } async function handleUrl(event, target) { event.preventDefault(); - const { protocol, hostname } = url.parse(target); + const parsedUrl = maybeParseUrl(target); + if (!parsedUrl) { + return; + } + + const { protocol, hostname } = parsedUrl; const isDevServer = config.enableHttp && hostname === 'localhost'; // We only want to specially handle urls that aren't requesting the dev server if (isSgnlHref(target) || isSignalHttpsLink(target)) { @@ -459,13 +468,20 @@ async function createWindow() { }; if (config.environment === 'test') { - mainWindow.loadURL(prepareURL([__dirname, 'test', 'index.html'], moreKeys)); + mainWindow.loadURL( + prepareFileUrl([__dirname, 'test', 'index.html'], moreKeys) + ); } else if (config.environment === 'test-lib') { mainWindow.loadURL( - prepareURL([__dirname, 'libtextsecure', 'test', 'index.html'], moreKeys) + prepareFileUrl( + [__dirname, 'libtextsecure', 'test', 'index.html'], + moreKeys + ) ); } else { - mainWindow.loadURL(prepareURL([__dirname, 'background.html'], moreKeys)); + mainWindow.loadURL( + prepareFileUrl([__dirname, 'background.html'], moreKeys) + ); } if (!enableCI && config.get('openDevTools')) { @@ -766,7 +782,7 @@ function showAbout() { handleCommonWindowEvents(aboutWindow); - aboutWindow.loadURL(prepareURL([__dirname, 'about.html'])); + aboutWindow.loadURL(prepareFileUrl([__dirname, 'about.html'])); aboutWindow.on('closed', () => { aboutWindow = null; @@ -823,7 +839,7 @@ function showSettingsWindow() { handleCommonWindowEvents(settingsWindow); - settingsWindow.loadURL(prepareURL([__dirname, 'settings.html'])); + settingsWindow.loadURL(prepareFileUrl([__dirname, 'settings.html'])); settingsWindow.on('closed', () => { removeDarkOverlay(); @@ -895,8 +911,10 @@ async function showStickerCreator() { handleCommonWindowEvents(stickerCreatorWindow); const appUrl = config.enableHttp - ? prepareURL(['http://localhost:6380/sticker-creator/dist/index.html']) - : prepareURL([__dirname, 'sticker-creator/dist/index.html']); + ? prepareUrl( + new URL('http://localhost:6380/sticker-creator/dist/index.html') + ) + : prepareFileUrl([__dirname, 'sticker-creator/dist/index.html']); stickerCreatorWindow.loadURL(appUrl); @@ -947,7 +965,9 @@ async function showDebugLogWindow() { handleCommonWindowEvents(debugLogWindow); - debugLogWindow.loadURL(prepareURL([__dirname, 'debug_log.html'], { theme })); + debugLogWindow.loadURL( + prepareFileUrl([__dirname, 'debug_log.html'], { theme }) + ); debugLogWindow.on('closed', () => { removeDarkOverlay(); @@ -1000,7 +1020,7 @@ function showPermissionsPopupWindow(forCalling, forCamera) { handleCommonWindowEvents(permissionsPopupWindow); permissionsPopupWindow.loadURL( - prepareURL([__dirname, 'permissions_popup.html'], { + prepareFileUrl([__dirname, 'permissions_popup.html'], { theme, forCalling, forCamera, @@ -1175,7 +1195,7 @@ app.on('ready', async () => { loadingWindow = null; }); - loadingWindow.loadURL(prepareURL([__dirname, 'loading.html'])); + loadingWindow.loadURL(prepareFileUrl([__dirname, 'loading.html'])); }); // Run window preloading in parallel with database initialization. diff --git a/ts/logging/debuglogs.ts b/ts/logging/debuglogs.ts index bffaf95a25..25a6df6121 100644 --- a/ts/logging/debuglogs.ts +++ b/ts/logging/debuglogs.ts @@ -7,6 +7,7 @@ import { gzip } from 'zlib'; import pify from 'pify'; import got, { Response } from 'got'; import { getUserAgent } from '../util/getUserAgent'; +import { maybeParseUrl } from '../util/url'; const BASE_URL = 'https://debuglogs.org'; @@ -22,10 +23,8 @@ const parseTokenBody = ( ): { fields: Record; url: string } => { const body = tokenBodySchema.parse(rawBody); - let parsedUrl: URL; - try { - parsedUrl = new URL(body.url); - } catch (err) { + const parsedUrl = maybeParseUrl(body.url); + if (!parsedUrl) { throw new Error("Token body's URL was not a valid URL"); } if (parsedUrl.protocol !== 'https:') { diff --git a/ts/test-both/util/url_test.ts b/ts/test-both/util/url_test.ts new file mode 100644 index 0000000000..bae6a684cb --- /dev/null +++ b/ts/test-both/util/url_test.ts @@ -0,0 +1,87 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { size } from '../../util/iterables'; + +import { maybeParseUrl, setUrlSearchParams } from '../../util/url'; + +describe('URL utilities', () => { + describe('maybeParseUrl', () => { + it('parses valid URLs', () => { + [ + 'https://example.com', + 'https://example.com:123/pathname?query=string#hash', + 'file:///path/to/file.txt', + ].forEach(href => { + assert.deepEqual(maybeParseUrl(href), new URL(href)); + }); + }); + + it('returns undefined for invalid URLs', () => { + ['', 'example.com'].forEach(href => { + assert.isUndefined(maybeParseUrl(href)); + }); + }); + + it('handles non-strings for compatibility, returning undefined', () => { + [undefined, null, 123, ['https://example.com']].forEach(value => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.isUndefined(maybeParseUrl(value as any)); + }); + }); + }); + + describe('setUrlSearchParams', () => { + it('returns a new URL with updated search params', () => { + const params = { + normal_string: 'foo', + empty_string: '', + number: 123, + true_bool: true, + false_bool: false, + null_value: null, + undefined_value: undefined, + array: ['ok', 'wow'], + stringified: { toString: () => 'bar' }, + }; + + const newUrl = setUrlSearchParams( + new URL('https://example.com/path?should_be=overwritten#hash'), + params + ); + + assert(newUrl.href.startsWith('https://example.com/path?')); + assert.strictEqual(newUrl.hash, '#hash'); + + assert.strictEqual( + size(newUrl.searchParams.entries()), + Object.keys(params).length + ); + assert.strictEqual(newUrl.searchParams.get('normal_string'), 'foo'); + assert.strictEqual(newUrl.searchParams.get('empty_string'), ''); + assert.strictEqual(newUrl.searchParams.get('number'), '123'); + assert.strictEqual(newUrl.searchParams.get('true_bool'), 'true'); + assert.strictEqual(newUrl.searchParams.get('false_bool'), 'false'); + assert.strictEqual(newUrl.searchParams.get('null_value'), ''); + assert.strictEqual(newUrl.searchParams.get('undefined_value'), ''); + assert.strictEqual(newUrl.searchParams.get('array'), 'ok,wow'); + assert.strictEqual(newUrl.searchParams.get('stringified'), 'bar'); + }); + + it("doesn't touch the original URL or its params", () => { + const originalHref = 'https://example.com/path?query=string'; + const originalUrl = new URL(originalHref); + + const params = { foo: 'bar' }; + + const newUrl = setUrlSearchParams(originalUrl, params); + + assert.notStrictEqual(originalUrl, newUrl); + assert.strictEqual(originalUrl.href, originalHref); + + params.foo = 'should be ignored'; + assert.strictEqual(newUrl.search, '?foo=bar'); + }); + }); +}); diff --git a/ts/util/sgnlHref.ts b/ts/util/sgnlHref.ts index eecfb0145d..8e785e2f9f 100644 --- a/ts/util/sgnlHref.ts +++ b/ts/util/sgnlHref.ts @@ -1,26 +1,25 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { LoggerType } from '../types/Logging'; +import { maybeParseUrl } from './url'; -function parseUrl(value: unknown, logger: LoggerType): null | URL { +function parseUrl(value: string | URL, logger: LoggerType): undefined | URL { if (value instanceof URL) { return value; } + if (typeof value === 'string') { - try { - return new URL(value); - } catch (err) { - return null; - } + return maybeParseUrl(value); } + logger.warn('Tried to parse a sgnl:// URL but got an unexpected type'); - return null; + return undefined; } export function isSgnlHref(value: string | URL, logger: LoggerType): boolean { const url = parseUrl(value, logger); - return url !== null && url.protocol === 'sgnl:'; + return Boolean(url?.protocol === 'sgnl:'); } export function isCaptchaHref( @@ -28,7 +27,7 @@ export function isCaptchaHref( logger: LoggerType ): boolean { const url = parseUrl(value, logger); - return url !== null && url.protocol === 'signalcaptcha:'; + return Boolean(url?.protocol === 'signalcaptcha:'); } export function isSignalHttpsLink( diff --git a/ts/util/url.ts b/ts/util/url.ts new file mode 100644 index 0000000000..b214501a31 --- /dev/null +++ b/ts/util/url.ts @@ -0,0 +1,35 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { mapValues } from 'lodash'; + +export function maybeParseUrl(value: string): undefined | URL { + if (typeof value === 'string') { + try { + return new URL(value); + } catch (err) { + /* Errors are ignored. */ + } + } + + return undefined; +} + +export function setUrlSearchParams( + url: Readonly, + searchParams: Readonly> +): URL { + const result = cloneUrl(url); + result.search = new URLSearchParams( + mapValues(searchParams, stringifySearchParamValue) + ).toString(); + return result; +} + +function cloneUrl(url: Readonly): URL { + return new URL(url.href); +} + +function stringifySearchParamValue(value: unknown): string { + return value == null ? '' : String(value); +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 2f97888d1e..5f1ee99f14 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -11,6 +11,7 @@ import { MediaItemType } from '../components/LightboxGallery'; import { MessageModel } from '../models/messages'; import { MessageType } from '../state/ducks/conversations'; import { assert } from '../util/assert'; +import { maybeParseUrl } from '../util/url'; type GetLinkPreviewImageResult = { data: ArrayBuffer; @@ -3934,10 +3935,8 @@ Whisper.ConversationView = Whisper.View.extend({ url: string, abortSignal: any ): Promise { - let urlObject; - try { - urlObject = new URL(url); - } catch (err) { + const urlObject = maybeParseUrl(url); + if (!urlObject) { return null; } diff --git a/tsconfig.json b/tsconfig.json index f19ce2fdd1..02e6238e92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ // Specify library files to be included in the compilation. "lib": [ "dom", // Required to access `window` + "dom.iterable", "es2020" ], "incremental": true,