Proper fix for HTML-in-video attacks (#1387)

This commit is contained in:
Alexander Zinchuk 2021-08-17 09:26:21 +03:00
parent 262bf7982d
commit 9cc050b766
7 changed files with 40 additions and 31 deletions

View File

@ -225,7 +225,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
}
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);

View File

@ -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);
}

View File

@ -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<OwnProps> = ({
const [isDownloadAllowed, setIsDownloadAllowed] = useState(false);
const {
mediaData, downloadProgress,
} = useMediaWithDownloadProgress(getMessageMediaHash(message, 'download'), !isDownloadAllowed);
} = useMediaWithDownloadProgress<ApiMediaFormat.BlobUrl>(
getMessageMediaHash(message, 'download'), !isDownloadAllowed, undefined, undefined, undefined, true,
);
const {
isUploading, isTransferring, transferProgress,
} = getMediaTransferState(message, uploadProgress || downloadProgress, isDownloadAllowed);

View File

@ -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;

View File

@ -19,6 +19,7 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
mediaFormat: T = ApiMediaFormat.BlobUrl,
cacheBuster?: number,
delay?: number | false,
isHtmlAllowed = false,
) => {
const mediaData = mediaHash ? mediaLoader.getFromMemory<T>(mediaHash) : undefined;
const isStreaming = mediaFormat === ApiMediaFormat.Stream || (
@ -47,7 +48,7 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
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 <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
}, 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) {

View File

@ -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:

View File

@ -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<string, ApiPreparedMedia>();
const fetchPromises = new Map<string, Promise<ApiPreparedMedia | undefined>>();
export function fetch<T extends ApiMediaFormat>(
url: string, mediaFormat: T, onProgress?: ApiOnProgress,
url: string, mediaFormat: T, isHtmlAllowed = false, onProgress?: ApiOnProgress,
): Promise<ApiMediaFormatToPrepared<T>> {
if (mediaFormat === ApiMediaFormat.Progressive) {
return (
IS_PROGRESSIVE_SUPPORTED
? getProgressive(url)
: fetch(url, ApiMediaFormat.BlobUrl, onProgress)
: fetch(url, ApiMediaFormat.BlobUrl, isHtmlAllowed, onProgress)
) as Promise<ApiMediaFormatToPrepared<T>>;
}
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);
}