diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index cc2b08f3b..5942e36fc 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -225,7 +225,7 @@ export async function invokeRequest( } export function downloadMedia( - args: { url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number }, + args: { url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean }, onProgress?: ApiOnProgress, ) { return downloadMediaWithClient(args, client, isConnected, onProgress); diff --git a/src/api/gramjs/methods/media.ts b/src/api/gramjs/methods/media.ts index 8eabaadb5..cf1722dc3 100644 --- a/src/api/gramjs/methods/media.ts +++ b/src/api/gramjs/methods/media.ts @@ -11,11 +11,10 @@ import { MEDIA_CACHE_MAX_BYTES, MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS, - TRANSPARENT_PIXEL, } from '../../../config'; import localDb from '../localDb'; import { getEntityTypeById } from '../gramjsBuilders'; -import { blobToDataUri, dataUriToBlob } from '../../../util/files'; +import { blobToDataUri } from '../../../util/files'; import * as cacheApi from '../../../util/cacheApi'; type EntityType = ( @@ -25,9 +24,9 @@ const MEDIA_ENTITY_TYPES = new Set(['msg', 'sticker', 'gif', 'wallpaper', 'photo export default async function downloadMedia( { - url, mediaFormat, start, end, + url, mediaFormat, start, end, isHtmlAllowed, }: { - url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; + url: string; mediaFormat: ApiMediaFormat; start?: number; end?: number; isHtmlAllowed?: boolean; }, client: TelegramClient, isConnected: boolean, @@ -35,7 +34,7 @@ export default async function downloadMedia( ) { const { data, mimeType, fullSize, - } = await download(url, client, isConnected, onProgress, start, end, mediaFormat) || {}; + } = await download(url, client, isConnected, onProgress, start, end, mediaFormat, isHtmlAllowed) || {}; if (!data) { return undefined; } @@ -73,6 +72,7 @@ async function download( start?: number, end?: number, mediaFormat?: ApiMediaFormat, + isHtmlAllowed?: boolean, ) { const mediaMatch = url.startsWith('webDocument') ? url.match(/(webDocument):(.+)/) @@ -169,6 +169,11 @@ async function download( fullSize = (entity as GramJs.Document).size; } + // Prevent HTML-in-video attacks + if (!isHtmlAllowed && mimeType) { + mimeType = mimeType.replace(/html/gi, ''); + } + return { mimeType, data, fullSize }; } else if (entityType === 'stickerSet') { const data = await client.downloadStickerSetThumb(entity); @@ -234,11 +239,6 @@ async function parseMedia( function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia { if (mediaData instanceof Blob) { - // Prevent HTML-in-video attacks - if (mediaData.type.includes('text/html')) { - return URL.createObjectURL(dataUriToBlob(TRANSPARENT_PIXEL)); - } - return URL.createObjectURL(mediaData); } diff --git a/src/components/common/Document.tsx b/src/components/common/Document.tsx index 3c5b5a1b3..bfd6c96f8 100644 --- a/src/components/common/Document.tsx +++ b/src/components/common/Document.tsx @@ -2,7 +2,7 @@ import React, { FC, useCallback, useEffect, useState, memo, useRef, } from '../../lib/teact/teact'; -import { ApiMessage } from '../../api/types'; +import { ApiMediaFormat, ApiMessage } from '../../api/types'; import { getDocumentExtension, getDocumentHasPreview } from './helpers/documentInfo'; import { @@ -62,7 +62,9 @@ const Document: FC = ({ const [isDownloadAllowed, setIsDownloadAllowed] = useState(false); const { mediaData, downloadProgress, - } = useMediaWithDownloadProgress(getMessageMediaHash(message, 'download'), !isDownloadAllowed); + } = useMediaWithDownloadProgress( + getMessageMediaHash(message, 'download'), !isDownloadAllowed, undefined, undefined, undefined, true, + ); const { isUploading, isTransferring, transferProgress, } = getMediaTransferState(message, uploadProgress || downloadProgress, isDownloadAllowed); diff --git a/src/config.ts b/src/config.ts index d1cb40095..5d5ee7b42 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,7 +32,6 @@ export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg'; export const LANG_CACHE_NAME = 'tt-lang-packs-v5'; export const ASSET_CACHE_NAME = 'tt-assets'; -export const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; export const DOWNLOAD_WORKERS = 16; export const UPLOAD_WORKERS = 16; diff --git a/src/hooks/useMediaWithDownloadProgress.ts b/src/hooks/useMediaWithDownloadProgress.ts index 4dafd882b..41e4b12fb 100644 --- a/src/hooks/useMediaWithDownloadProgress.ts +++ b/src/hooks/useMediaWithDownloadProgress.ts @@ -19,6 +19,7 @@ export default ( mediaFormat: T = ApiMediaFormat.BlobUrl, cacheBuster?: number, delay?: number | false, + isHtmlAllowed = false, ) => { const mediaData = mediaHash ? mediaLoader.getFromMemory(mediaHash) : undefined; const isStreaming = mediaFormat === ApiMediaFormat.Stream || ( @@ -47,7 +48,7 @@ export default ( startedAtRef.current = Date.now(); - mediaLoader.fetch(mediaHash, mediaFormat, handleProgress).then(() => { + mediaLoader.fetch(mediaHash, mediaFormat, isHtmlAllowed, handleProgress).then(() => { const spentTime = Date.now() - startedAtRef.current!; startedAtRef.current = undefined; @@ -63,7 +64,10 @@ export default ( }, STREAMING_TIMEOUT); } } - }, [noLoad, mediaHash, mediaData, mediaFormat, cacheBuster, forceUpdate, isStreaming, delay, handleProgress]); + }, [ + noLoad, mediaHash, mediaData, mediaFormat, cacheBuster, forceUpdate, isStreaming, delay, handleProgress, + isHtmlAllowed, + ]); useEffect(() => { if (noLoad && startedAtRef.current) { diff --git a/src/util/cacheApi.ts b/src/util/cacheApi.ts index 4e4fc6559..c6c97a7d9 100644 --- a/src/util/cacheApi.ts +++ b/src/util/cacheApi.ts @@ -7,7 +7,9 @@ export enum Type { Json, } -export async function fetch(cacheName: string, key: string, type: Type) { +export async function fetch( + cacheName: string, key: string, type: Type, isHtmlAllowed = false, +) { if (!cacheApi) { return undefined; } @@ -36,10 +38,15 @@ export async function fetch(cacheName: string, key: string, type: Type) { if (!blob.type) { const contentType = response.headers.get('Content-Type'); if (contentType) { - return new Blob([blob], { type: contentType }); + return new Blob([blob], { type: isHtmlAllowed ? contentType : contentType.replace(/html/gi, '') }); } } + // Prevent HTML-in-video attacks (for files that were cached before fix) + if (!isHtmlAllowed && blob.type.includes('html')) { + return new Blob([blob], { type: blob.type.replace(/html/gi, '') }); + } + return blob; } case Type.Json: diff --git a/src/util/mediaLoader.ts b/src/util/mediaLoader.ts index 851b3b35d..032879426 100644 --- a/src/util/mediaLoader.ts +++ b/src/util/mediaLoader.ts @@ -7,11 +7,11 @@ import { } from '../api/types'; import { - DEBUG, MEDIA_CACHE_DISABLED, MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS, TRANSPARENT_PIXEL, + DEBUG, MEDIA_CACHE_DISABLED, MEDIA_CACHE_NAME, MEDIA_CACHE_NAME_AVATARS, } from '../config'; import { callApi, cancelApiProgress } from '../api/gramjs'; import * as cacheApi from './cacheApi'; -import { dataUriToBlob, fetchBlob } from './files'; +import { fetchBlob } from './files'; import { IS_OPUS_SUPPORTED, IS_PROGRESSIVE_SUPPORTED, isWebpSupported } from './environment'; import { oggToWav } from './oggToWav'; import { webpToPng } from './webpToPng'; @@ -30,18 +30,18 @@ const memoryCache = new Map(); const fetchPromises = new Map>(); export function fetch( - url: string, mediaFormat: T, onProgress?: ApiOnProgress, + url: string, mediaFormat: T, isHtmlAllowed = false, onProgress?: ApiOnProgress, ): Promise> { if (mediaFormat === ApiMediaFormat.Progressive) { return ( IS_PROGRESSIVE_SUPPORTED ? getProgressive(url) - : fetch(url, ApiMediaFormat.BlobUrl, onProgress) + : fetch(url, ApiMediaFormat.BlobUrl, isHtmlAllowed, onProgress) ) as Promise>; } if (!fetchPromises.has(url)) { - const promise = fetchFromCacheOrRemote(url, mediaFormat, onProgress) + const promise = fetchFromCacheOrRemote(url, mediaFormat, isHtmlAllowed, onProgress) .catch((err) => { if (DEBUG) { // eslint-disable-next-line no-console @@ -76,10 +76,12 @@ function getProgressive(url: string) { return Promise.resolve(progressiveUrl); } -async function fetchFromCacheOrRemote(url: string, mediaFormat: ApiMediaFormat, onProgress?: ApiOnProgress) { +async function fetchFromCacheOrRemote( + url: string, mediaFormat: ApiMediaFormat, isHtmlAllowed: boolean, onProgress?: ApiOnProgress, +) { if (!MEDIA_CACHE_DISABLED) { const cacheName = url.startsWith('avatar') ? MEDIA_CACHE_NAME_AVATARS : MEDIA_CACHE_NAME; - const cached = await cacheApi.fetch(cacheName, url, asCacheApiType[mediaFormat]!); + const cached = await cacheApi.fetch(cacheName, url, asCacheApiType[mediaFormat]!, isHtmlAllowed); if (cached) { let media = cached; @@ -136,7 +138,7 @@ async function fetchFromCacheOrRemote(url: string, mediaFormat: ApiMediaFormat, return streamUrl; } - const remote = await callApi('downloadMedia', { url, mediaFormat }, onProgress); + const remote = await callApi('downloadMedia', { url, mediaFormat, isHtmlAllowed }, onProgress); if (!remote) { throw new Error('Failed to fetch media'); } @@ -167,11 +169,6 @@ async function fetchFromCacheOrRemote(url: string, mediaFormat: ApiMediaFormat, function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia { if (mediaData instanceof Blob) { - // Prevent HTML-in-video attacks - if (mediaData.type.includes('text/html')) { - return URL.createObjectURL(dataUriToBlob(TRANSPARENT_PIXEL)); - } - return URL.createObjectURL(mediaData); }