Add urlPath util for building escaped URL paths

This commit is contained in:
Jamie Kyle 2024-09-24 12:12:18 -07:00 committed by GitHub
parent e51cde1770
commit cd50c715a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 144 additions and 73 deletions

View file

@ -7,7 +7,8 @@ import { size } from '../../util/iterables';
import { import {
maybeParseUrl, maybeParseUrl,
setUrlSearchParams, setUrlSearchParams,
urlPathFromComponents, urlPath,
urlPathJoin,
} from '../../util/url'; } from '../../util/url';
describe('URL utilities', () => { describe('URL utilities', () => {
@ -85,17 +86,43 @@ describe('URL utilities', () => {
}); });
}); });
describe('urlPathFromComponents', () => { describe('urlPath', () => {
it('returns / if no components are provided', () => { it('escapes values', () => {
assert.strictEqual(urlPathFromComponents([]), '/'); assert.strictEqual(
urlPath`/path/to/${' %?&='}/${true}/${42}`.toString(),
'/path/to/%20%25%3F%26%3D/true/42'
);
}); });
it('joins components, percent-encoding them and removing empty components', () => { it('doesnt escape nested url paths', () => {
const components = ['foo', '', '~', 'bar / baz qúx'];
assert.strictEqual( assert.strictEqual(
urlPathFromComponents(components), urlPath`${urlPath`/path?param=true`}&other=${' %?&='}`.toString(),
'/foo/~/bar%20%2F%20baz%20q%C3%BAx' '/path?param=true&other=%20%25%3F%26%3D'
); );
}); });
}); });
describe('urlPathJoin', () => {
it('escapes values', () => {
assert.strictEqual(
urlPathJoin([' %?&=', true, 42], '&').toString(),
'%20%25%3F%26%3D&true&42'
);
});
it('doesnt escape nested url paths', () => {
assert.strictEqual(
urlPathJoin([urlPath`/path?param=true`, ' %?&='], '&').toString(),
'/path?param=true&%20%25%3F%26%3D'
);
});
it('works with empty arrays', () => {
assert.strictEqual(urlPathJoin([], '&').toString(), '');
});
it('works with single items', () => {
assert.strictEqual(urlPathJoin(['hi'], '&').toString(), 'hi');
});
});
}); });

View file

@ -67,7 +67,8 @@ import type {
} from './Types.d'; } from './Types.d';
import { handleStatusCode, translateError } from './Utils'; import { handleStatusCode, translateError } from './Utils';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { maybeParseUrl, urlPathFromComponents } from '../util/url'; import type { UrlPath } from '../util/url';
import { urlPathJoin, maybeParseUrl, urlPath } from '../util/url';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import { safeParseNumber } from '../util/numbers'; import { safeParseNumber } from '../util/numbers';
import { isStagingServer } from '../util/isStagingServer'; import { isStagingServer } from '../util/isStagingServer';
@ -717,7 +718,7 @@ type AjaxOptionsType = {
responseType?: 'json' | 'bytes' | 'byteswithdetails' | 'stream'; responseType?: 'json' | 'bytes' | 'byteswithdetails' | 'stream';
schema?: unknown; schema?: unknown;
timeout?: number; timeout?: number;
urlParameters?: string; urlParameters?: UrlPath;
username?: string; username?: string;
validateResponse?: any; validateResponse?: any;
isRegistration?: true; isRegistration?: true;
@ -1865,7 +1866,7 @@ export function initialize({
} }
if (!param.urlParameters) { if (!param.urlParameters) {
param.urlParameters = ''; param.urlParameters = urlPath``;
} }
const useWebSocketForEndpoint = const useWebSocketForEndpoint =
@ -1882,7 +1883,7 @@ export function initialize({
headers: param.headers, headers: param.headers,
host: param.host || url, host: param.host || url,
password: param.password ?? password, password: param.password ?? password,
path: URL_CALLS[param.call] + param.urlParameters, path: URL_CALLS[param.call] + param.urlParameters.toString(),
proxyUrl, proxyUrl,
responseType: param.responseType, responseType: param.responseType,
timeout: param.timeout, timeout: param.timeout,
@ -2045,7 +2046,7 @@ export function initialize({
httpType: 'GET', httpType: 'GET',
responseType: 'json', responseType: 'json',
validateResponse: { certificate: 'string' }, validateResponse: { certificate: 'string' },
...(omitE164 ? { urlParameters: '?includeE164=false' } : {}), ...(omitE164 ? { urlParameters: urlPath`?includeE164=false` } : {}),
})) as GetSenderCertificateResultType; })) as GetSenderCertificateResultType;
} }
@ -2084,8 +2085,8 @@ export function initialize({
httpType: 'GET', httpType: 'GET',
responseType: 'byteswithdetails', responseType: 'byteswithdetails',
urlParameters: greaterThanVersion urlParameters: greaterThanVersion
? `/version/${greaterThanVersion}` ? urlPath`/version/${greaterThanVersion}`
: '', : urlPath``,
...credentials, ...credentials,
}); });
@ -2177,13 +2178,11 @@ export function initialize({
profileKeyCredentialRequest, profileKeyCredentialRequest,
}: GetProfileCommonOptionsType }: GetProfileCommonOptionsType
) { ) {
let profileUrl = `/${serviceId}`; let profileUrl = urlPath`/${serviceId}`;
if (profileKeyVersion !== undefined) { if (profileKeyVersion !== undefined) {
profileUrl += `/${profileKeyVersion}`; profileUrl = urlPath`${profileUrl}/${profileKeyVersion}`;
if (profileKeyCredentialRequest !== undefined) { if (profileKeyCredentialRequest !== undefined) {
profileUrl += profileUrl = urlPath`${profileUrl}/${profileKeyCredentialRequest}?credentialType=expiringProfileKey`;
`/${profileKeyCredentialRequest}` +
'?credentialType=expiringProfileKey';
} }
} else { } else {
strictAssert( strictAssert(
@ -2226,7 +2225,7 @@ export function initialize({
await _ajax({ await _ajax({
call: 'username', call: 'username',
httpType: 'GET', httpType: 'GET',
urlParameters: `/${hashBase64}`, urlParameters: urlPath`/${hashBase64}`,
responseType: 'json', responseType: 'json',
redactUrl: _createRedactor(hashBase64), redactUrl: _createRedactor(hashBase64),
unauthenticated: true, unauthenticated: true,
@ -2443,7 +2442,7 @@ export function initialize({
await _ajax({ await _ajax({
httpType: 'GET', httpType: 'GET',
call: 'usernameLink', call: 'usernameLink',
urlParameters: `/${encodeURIComponent(serverId)}`, urlParameters: urlPath`/${serverId}`,
responseType: 'json', responseType: 'json',
unauthenticated: true, unauthenticated: true,
accessKey: undefined, accessKey: undefined,
@ -2462,7 +2461,7 @@ export function initialize({
await _ajax({ await _ajax({
call: 'reportMessage', call: 'reportMessage',
httpType: 'POST', httpType: 'POST',
urlParameters: urlPathFromComponents([senderAci, serverGuid]), urlParameters: urlPath`/${senderAci}/${serverGuid}`,
responseType: 'bytes', responseType: 'bytes',
jsonData, jsonData,
}); });
@ -2493,7 +2492,7 @@ export function initialize({
await _ajax({ await _ajax({
call: 'verificationSession', call: 'verificationSession',
httpType: 'PATCH', httpType: 'PATCH',
urlParameters: `/${encodeURIComponent(session.id)}`, urlParameters: urlPath`/${session.id}`,
responseType: 'json', responseType: 'json',
jsonData: { jsonData: {
captcha, captcha,
@ -2514,7 +2513,7 @@ export function initialize({
await _ajax({ await _ajax({
call: 'verificationSession', call: 'verificationSession',
httpType: 'POST', httpType: 'POST',
urlParameters: `/${encodeURIComponent(session.id)}/code`, urlParameters: urlPath`/${session.id}/code`,
responseType: 'json', responseType: 'json',
jsonData: { jsonData: {
client: 'ios', client: 'ios',
@ -2536,7 +2535,7 @@ export function initialize({
await _ajax({ await _ajax({
httpType: 'HEAD', httpType: 'HEAD',
call: 'accountExistence', call: 'accountExistence',
urlParameters: `/${serviceId}`, urlParameters: urlPath`/${serviceId}`,
unauthenticated: true, unauthenticated: true,
accessKey: undefined, accessKey: undefined,
groupSendToken: undefined, groupSendToken: undefined,
@ -2622,7 +2621,7 @@ export function initialize({
isRegistration: true, isRegistration: true,
call: 'verificationSession', call: 'verificationSession',
httpType: 'PUT', httpType: 'PUT',
urlParameters: `/${encodeURIComponent(sessionId)}/code`, urlParameters: urlPath`/${sessionId}/code`,
responseType: 'json', responseType: 'json',
jsonData: { jsonData: {
code, code,
@ -2739,7 +2738,7 @@ export function initialize({
await _ajax({ await _ajax({
call: 'devices', call: 'devices',
httpType: 'DELETE', httpType: 'DELETE',
urlParameters: `/${deviceId}`, urlParameters: urlPath`/${deviceId}`,
}); });
} }
@ -2824,7 +2823,7 @@ export function initialize({
await _ajax({ await _ajax({
isRegistration: true, isRegistration: true,
call: 'keys', call: 'keys',
urlParameters: `?${serviceIdKindToQuery(serviceIdKind)}`, urlParameters: urlPath`?${serviceIdKindToQuery(serviceIdKind)}`,
httpType: 'PUT', httpType: 'PUT',
jsonData: keys, jsonData: keys,
}); });
@ -2854,7 +2853,7 @@ export function initialize({
abortSignal, abortSignal,
}: GetBackupStreamOptionsType): Promise<Readable> { }: GetBackupStreamOptionsType): Promise<Readable> {
return _getAttachment({ return _getAttachment({
cdnPath: `/backups/${encodeURIComponent(backupDir)}/${encodeURIComponent(backupName)}`, cdnPath: urlPath`/backups/${backupDir}/${backupName}`,
cdnNumber: cdn, cdnNumber: cdn,
redactor: _createRedactor(backupDir, backupName), redactor: _createRedactor(backupDir, backupName),
headers, headers,
@ -2954,9 +2953,7 @@ export function initialize({
const res = await _ajax({ const res = await _ajax({
call: 'getBackupCredentials', call: 'getBackupCredentials',
httpType: 'GET', httpType: 'GET',
urlParameters: urlParameters: urlPath`?redemptionStartSeconds=${startDayInSeconds}&redemptionEndSeconds=${endDayInSeconds}`,
`?redemptionStartSeconds=${startDayInSeconds}&` +
`redemptionEndSeconds=${endDayInSeconds}`,
responseType: 'json', responseType: 'json',
}); });
@ -2974,7 +2971,7 @@ export function initialize({
accessKey: undefined, accessKey: undefined,
groupSendToken: undefined, groupSendToken: undefined,
headers, headers,
urlParameters: `?cdn=${cdn}`, urlParameters: urlPath`?cdn=${cdn}`,
responseType: 'json', responseType: 'json',
}); });
@ -3080,12 +3077,12 @@ export function initialize({
cursor, cursor,
limit, limit,
}: BackupListMediaOptionsType) { }: BackupListMediaOptionsType) {
const params = new Array<string>(); const params: Array<UrlPath> = [];
if (cursor != null) { if (cursor != null) {
params.push(`cursor=${encodeURIComponent(cursor)}`); params.push(urlPath`cursor=${cursor}`);
} }
params.push(`limit=${limit}`); params.push(urlPath`limit=${limit}`);
const res = await _ajax({ const res = await _ajax({
call: 'backupMedia', call: 'backupMedia',
@ -3095,7 +3092,7 @@ export function initialize({
groupSendToken: undefined, groupSendToken: undefined,
headers, headers,
responseType: 'json', responseType: 'json',
urlParameters: `?${params.join('&')}`, urlParameters: urlPath`?${urlPathJoin(params, '&')}`,
}); });
return backupListMediaResponseSchema.parse(res); return backupListMediaResponseSchema.parse(res);
@ -3128,7 +3125,7 @@ export function initialize({
): Promise<ServerKeyCountType> { ): Promise<ServerKeyCountType> {
const result = (await _ajax({ const result = (await _ajax({
call: 'keys', call: 'keys',
urlParameters: `?${serviceIdKindToQuery(serviceIdKind)}`, urlParameters: urlPath`?${serviceIdKindToQuery(serviceIdKind)}`,
httpType: 'GET', httpType: 'GET',
responseType: 'json', responseType: 'json',
validateResponse: { count: 'number', pqCount: 'number' }, validateResponse: { count: 'number', pqCount: 'number' },
@ -3230,7 +3227,7 @@ export function initialize({
const keys = (await _ajax({ const keys = (await _ajax({
call: 'keys', call: 'keys',
httpType: 'GET', httpType: 'GET',
urlParameters: `/${serviceId}/${deviceId || '*'}`, urlParameters: urlPath`/${serviceId}/${deviceId || '*'}`,
responseType: 'json', responseType: 'json',
validateResponse: { identityKey: 'string', devices: 'object' }, validateResponse: { identityKey: 'string', devices: 'object' },
})) as ServerKeyResponseType; })) as ServerKeyResponseType;
@ -3248,7 +3245,7 @@ export function initialize({
const keys = (await _ajax({ const keys = (await _ajax({
call: 'keys', call: 'keys',
httpType: 'GET', httpType: 'GET',
urlParameters: `/${serviceId}/${deviceId || '*'}`, urlParameters: urlPath`/${serviceId}/${deviceId || '*'}`,
responseType: 'json', responseType: 'json',
validateResponse: { identityKey: 'string', devices: 'object' }, validateResponse: { identityKey: 'string', devices: 'object' },
unauthenticated: true, unauthenticated: true,
@ -3284,7 +3281,7 @@ export function initialize({
await _ajax({ await _ajax({
call: 'messages', call: 'messages',
httpType: 'PUT', httpType: 'PUT',
urlParameters: `/${destination}?story=${booleanToString(story)}`, urlParameters: urlPath`/${destination}?story=${story}`,
jsonData, jsonData,
responseType: 'json', responseType: 'json',
unauthenticated: true, unauthenticated: true,
@ -3313,16 +3310,12 @@ export function initialize({
await _ajax({ await _ajax({
call: 'messages', call: 'messages',
httpType: 'PUT', httpType: 'PUT',
urlParameters: `/${destination}?story=${booleanToString(story)}`, urlParameters: urlPath`/${destination}?story=${story}`,
jsonData, jsonData,
responseType: 'json', responseType: 'json',
}); });
} }
function booleanToString(value: boolean | undefined): string {
return value ? 'true' : 'false';
}
async function sendWithSenderKey( async function sendWithSenderKey(
data: Uint8Array, data: Uint8Array,
accessKeys: Uint8Array | undefined, accessKeys: Uint8Array | undefined,
@ -3338,16 +3331,16 @@ export function initialize({
urgent?: boolean; urgent?: boolean;
} }
): Promise<MultiRecipient200ResponseType> { ): Promise<MultiRecipient200ResponseType> {
const onlineParam = `&online=${booleanToString(online)}`; const onlineParam = urlPath`&online=${online ?? false}`;
const urgentParam = `&urgent=${booleanToString(urgent)}`; const urgentParam = urlPath`&urgent=${urgent}`;
const storyParam = `&story=${booleanToString(story)}`; const storyParam = urlPath`&story=${story}`;
const response = await _ajax({ const response = await _ajax({
call: 'multiRecipient', call: 'multiRecipient',
httpType: 'PUT', httpType: 'PUT',
contentType: 'application/vnd.signal-messenger.mrm', contentType: 'application/vnd.signal-messenger.mrm',
data, data,
urlParameters: `?ts=${timestamp}${onlineParam}${urgentParam}${storyParam}`, urlParameters: urlPath`?ts=${timestamp}${onlineParam}${urgentParam}${storyParam}`,
responseType: 'json', responseType: 'json',
unauthenticated: true, unauthenticated: true,
accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined, accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined,
@ -3486,7 +3479,7 @@ export function initialize({
call: 'getStickerPackUpload', call: 'getStickerPackUpload',
responseType: 'json', responseType: 'json',
httpType: 'GET', httpType: 'GET',
urlParameters: `/${encryptedStickers.length}`, urlParameters: urlPath`/${encryptedStickers.length}`,
}); });
const { packId, manifest, stickers } = const { packId, manifest, stickers } =
@ -3552,7 +3545,7 @@ export function initialize({
}; };
}) { }) {
return _getAttachment({ return _getAttachment({
cdnPath: `/attachments/${cdnKey}`, cdnPath: urlPath`/attachments/${cdnKey}`,
cdnNumber: cdnNumber ?? 0, cdnNumber: cdnNumber ?? 0,
redactor: _createRedactor(cdnKey), redactor: _createRedactor(cdnKey),
options, options,
@ -3579,7 +3572,7 @@ export function initialize({
}; };
}) { }) {
return _getAttachment({ return _getAttachment({
cdnPath: `/backups/${backupDir}/${mediaDir}/${mediaId}`, cdnPath: urlPath`/backups/${backupDir}/${mediaDir}/${mediaId}`,
cdnNumber, cdnNumber,
headers, headers,
redactor: _createRedactor(backupDir, mediaDir, mediaId), redactor: _createRedactor(backupDir, mediaDir, mediaId),
@ -3594,7 +3587,7 @@ export function initialize({
redactor, redactor,
options, options,
}: { }: {
cdnPath: string; cdnPath: UrlPath;
cdnNumber: number; cdnNumber: number;
headers?: Record<string, string>; headers?: Record<string, string>;
redactor: RedactUrl; redactor: RedactUrl;
@ -3627,7 +3620,7 @@ export function initialize({
if (options?.downloadOffset) { if (options?.downloadOffset) {
targetHeaders.range = `bytes=${options.downloadOffset}-`; targetHeaders.range = `bytes=${options.downloadOffset}-`;
} }
streamWithDetails = await _outerAjax(`${cdnUrl}${cdnPath}`, { streamWithDetails = await _outerAjax(`${cdnUrl}${cdnPath.toString()}`, {
headers: targetHeaders, headers: targetHeaders,
certificateAuthority, certificateAuthority,
disableRetries: options?.disableRetries, disableRetries: options?.disableRetries,
@ -3689,7 +3682,7 @@ export function initialize({
} }
const timeoutStream = getTimeoutStream({ const timeoutStream = getTimeoutStream({
name: `getAttachment(${redactor(cdnPath)})`, name: `getAttachment(${redactor(cdnPath.toString())})`,
timeout: GET_ATTACHMENT_CHUNK_TIMEOUT, timeout: GET_ATTACHMENT_CHUNK_TIMEOUT,
abortController, abortController,
}); });
@ -3954,10 +3947,7 @@ export function initialize({
const endDayInSeconds = endDayInMs / durations.SECOND; const endDayInSeconds = endDayInMs / durations.SECOND;
const response = (await _ajax({ const response = (await _ajax({
call: 'getGroupCredentials', call: 'getGroupCredentials',
urlParameters: urlParameters: urlPath`?redemptionStartSeconds=${startDayInSeconds}&redemptionEndSeconds=${endDayInSeconds}&zkcCredential=true`,
`?redemptionStartSeconds=${startDayInSeconds}&` +
`redemptionEndSeconds=${endDayInSeconds}&` +
'zkcCredential=true',
httpType: 'GET', httpType: 'GET',
responseType: 'json', responseType: 'json',
})) as GetGroupCredentialsResultType; })) as GetGroupCredentialsResultType;
@ -4150,7 +4140,7 @@ export function initialize({
httpType: 'GET', httpType: 'GET',
responseType: 'bytes', responseType: 'bytes',
urlParameters: safeInviteLinkPassword urlParameters: safeInviteLinkPassword
? `${safeInviteLinkPassword}` ? urlPath`${safeInviteLinkPassword}`
: undefined, : undefined,
redactUrl: _createRedactor(safeInviteLinkPassword), redactUrl: _createRedactor(safeInviteLinkPassword),
}); });
@ -4182,7 +4172,7 @@ export function initialize({
httpType: 'PATCH', httpType: 'PATCH',
responseType: 'bytes', responseType: 'bytes',
urlParameters: safeInviteLinkPassword urlParameters: safeInviteLinkPassword
? `?inviteLinkPassword=${safeInviteLinkPassword}` ? urlPath`?inviteLinkPassword=${safeInviteLinkPassword}`
: undefined, : undefined,
redactUrl: safeInviteLinkPassword redactUrl: safeInviteLinkPassword
? _createRedactor(safeInviteLinkPassword) ? _createRedactor(safeInviteLinkPassword)
@ -4243,11 +4233,7 @@ export function initialize({
headers: { headers: {
'Cached-Send-Endorsements': String(cachedEndorsementsExpiration ?? 0), 'Cached-Send-Endorsements': String(cachedEndorsementsExpiration ?? 0),
}, },
urlParameters: urlParameters: urlPath`/${startVersion}?includeFirstState=${Boolean(includeFirstState)}&includeLastState=${Boolean(includeLastState)}&maxSupportedChangeEpoch=${Number(maxSupportedChangeEpoch)}`,
`/${startVersion}?` +
`includeFirstState=${Boolean(includeFirstState)}&` +
`includeLastState=${Boolean(includeLastState)}&` +
`maxSupportedChangeEpoch=${Number(maxSupportedChangeEpoch)}`,
}); });
const { data, response } = withDetails; const { data, response } = withDetails;
const changes = Proto.GroupChanges.decode(data); const changes = Proto.GroupChanges.decode(data);
@ -4292,7 +4278,7 @@ export function initialize({
const data = await _ajax({ const data = await _ajax({
call: 'subscriptions', call: 'subscriptions',
httpType: 'GET', httpType: 'GET',
urlParameters: `/${formattedId}`, urlParameters: urlPath`/${formattedId}`,
responseType: 'json', responseType: 'json',
unauthenticated: true, unauthenticated: true,
accessKey: undefined, accessKey: undefined,

View file

@ -1,6 +1,8 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { strictAssert } from './assert';
export function maybeParseUrl(value: string): undefined | URL { export function maybeParseUrl(value: string): undefined | URL {
if (typeof value === 'string') { if (typeof value === 'string') {
try { try {
@ -32,8 +34,64 @@ function cloneUrl(url: Readonly<URL>): URL {
return new URL(url.href); return new URL(url.href);
} }
export function urlPathFromComponents( class UrlPath {
components: ReadonlyArray<string> #urlPath: string;
): string {
return `/${components.filter(Boolean).map(encodeURIComponent).join('/')}`; constructor(escapedUrlPath: string) {
this.#urlPath = escapedUrlPath;
}
toString(): string {
return this.#urlPath;
}
}
export type { UrlPath };
export type UrlPathInput = boolean | number | string | UrlPath;
export function isUrlPath(value: unknown): value is UrlPath {
return value instanceof UrlPath;
}
function escapeValueForUrlPath(value: UrlPathInput): string {
if (typeof value === 'boolean' || typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') {
return encodeURIComponent(value);
}
if (isUrlPath(value)) {
return value.toString();
}
throw new TypeError('Unexpected url path component');
}
export function urlPath(
strings: TemplateStringsArray,
...components: ReadonlyArray<UrlPathInput>
): UrlPath {
let result = '';
for (let index = 0; index < strings.length; index += 1) {
result += strings[index];
if (index < components.length) {
result += escapeValueForUrlPath(components[index]);
}
}
return new UrlPath(result);
}
export function urlPathJoin(
values: ReadonlyArray<UrlPathInput>,
separator: string
): UrlPath {
strictAssert(isUrlPath(separator), 'Separator must be an EscapedUrlPath');
let result = '';
for (let index = 0; index < values.length; index += 1) {
result += escapeValueForUrlPath(values[index]);
if (index < values.length - 1) {
result += separator;
}
}
return new UrlPath(result);
} }