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( 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, onProgress?: ApiOnProgress,
) { ) {
return downloadMediaWithClient(args, client, isConnected, onProgress); return downloadMediaWithClient(args, client, isConnected, onProgress);

View File

@ -11,11 +11,10 @@ import {
MEDIA_CACHE_MAX_BYTES, MEDIA_CACHE_MAX_BYTES,
MEDIA_CACHE_NAME, MEDIA_CACHE_NAME,
MEDIA_CACHE_NAME_AVATARS, MEDIA_CACHE_NAME_AVATARS,
TRANSPARENT_PIXEL,
} from '../../../config'; } from '../../../config';
import localDb from '../localDb'; import localDb from '../localDb';
import { getEntityTypeById } from '../gramjsBuilders'; import { getEntityTypeById } from '../gramjsBuilders';
import { blobToDataUri, dataUriToBlob } from '../../../util/files'; import { blobToDataUri } from '../../../util/files';
import * as cacheApi from '../../../util/cacheApi'; import * as cacheApi from '../../../util/cacheApi';
type EntityType = ( type EntityType = (
@ -25,9 +24,9 @@ const MEDIA_ENTITY_TYPES = new Set(['msg', 'sticker', 'gif', 'wallpaper', 'photo
export default async function downloadMedia( 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, client: TelegramClient,
isConnected: boolean, isConnected: boolean,
@ -35,7 +34,7 @@ export default async function downloadMedia(
) { ) {
const { const {
data, mimeType, fullSize, data, mimeType, fullSize,
} = await download(url, client, isConnected, onProgress, start, end, mediaFormat) || {}; } = await download(url, client, isConnected, onProgress, start, end, mediaFormat, isHtmlAllowed) || {};
if (!data) { if (!data) {
return undefined; return undefined;
} }
@ -73,6 +72,7 @@ async function download(
start?: number, start?: number,
end?: number, end?: number,
mediaFormat?: ApiMediaFormat, mediaFormat?: ApiMediaFormat,
isHtmlAllowed?: boolean,
) { ) {
const mediaMatch = url.startsWith('webDocument') const mediaMatch = url.startsWith('webDocument')
? url.match(/(webDocument):(.+)/) ? url.match(/(webDocument):(.+)/)
@ -169,6 +169,11 @@ async function download(
fullSize = (entity as GramJs.Document).size; fullSize = (entity as GramJs.Document).size;
} }
// Prevent HTML-in-video attacks
if (!isHtmlAllowed && mimeType) {
mimeType = mimeType.replace(/html/gi, '');
}
return { mimeType, data, fullSize }; return { mimeType, data, fullSize };
} else if (entityType === 'stickerSet') { } else if (entityType === 'stickerSet') {
const data = await client.downloadStickerSetThumb(entity); const data = await client.downloadStickerSetThumb(entity);
@ -234,11 +239,6 @@ async function parseMedia(
function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia { function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia {
if (mediaData instanceof Blob) { 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); return URL.createObjectURL(mediaData);
} }

View File

@ -2,7 +2,7 @@ import React, {
FC, useCallback, useEffect, useState, memo, useRef, FC, useCallback, useEffect, useState, memo, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { ApiMessage } from '../../api/types'; import { ApiMediaFormat, ApiMessage } from '../../api/types';
import { getDocumentExtension, getDocumentHasPreview } from './helpers/documentInfo'; import { getDocumentExtension, getDocumentHasPreview } from './helpers/documentInfo';
import { import {
@ -62,7 +62,9 @@ const Document: FC<OwnProps> = ({
const [isDownloadAllowed, setIsDownloadAllowed] = useState(false); const [isDownloadAllowed, setIsDownloadAllowed] = useState(false);
const { const {
mediaData, downloadProgress, mediaData, downloadProgress,
} = useMediaWithDownloadProgress(getMessageMediaHash(message, 'download'), !isDownloadAllowed); } = useMediaWithDownloadProgress<ApiMediaFormat.BlobUrl>(
getMessageMediaHash(message, 'download'), !isDownloadAllowed, undefined, undefined, undefined, true,
);
const { const {
isUploading, isTransferring, transferProgress, isUploading, isTransferring, transferProgress,
} = getMediaTransferState(message, uploadProgress || downloadProgress, isDownloadAllowed); } = 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 CUSTOM_BG_CACHE_NAME = 'tt-custom-bg';
export const LANG_CACHE_NAME = 'tt-lang-packs-v5'; export const LANG_CACHE_NAME = 'tt-lang-packs-v5';
export const ASSET_CACHE_NAME = 'tt-assets'; export const ASSET_CACHE_NAME = 'tt-assets';
export const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
export const DOWNLOAD_WORKERS = 16; export const DOWNLOAD_WORKERS = 16;
export const UPLOAD_WORKERS = 16; export const UPLOAD_WORKERS = 16;

View File

@ -19,6 +19,7 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
mediaFormat: T = ApiMediaFormat.BlobUrl, mediaFormat: T = ApiMediaFormat.BlobUrl,
cacheBuster?: number, cacheBuster?: number,
delay?: number | false, delay?: number | false,
isHtmlAllowed = false,
) => { ) => {
const mediaData = mediaHash ? mediaLoader.getFromMemory<T>(mediaHash) : undefined; const mediaData = mediaHash ? mediaLoader.getFromMemory<T>(mediaHash) : undefined;
const isStreaming = mediaFormat === ApiMediaFormat.Stream || ( const isStreaming = mediaFormat === ApiMediaFormat.Stream || (
@ -47,7 +48,7 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
startedAtRef.current = Date.now(); startedAtRef.current = Date.now();
mediaLoader.fetch(mediaHash, mediaFormat, handleProgress).then(() => { mediaLoader.fetch(mediaHash, mediaFormat, isHtmlAllowed, handleProgress).then(() => {
const spentTime = Date.now() - startedAtRef.current!; const spentTime = Date.now() - startedAtRef.current!;
startedAtRef.current = undefined; startedAtRef.current = undefined;
@ -63,7 +64,10 @@ export default <T extends ApiMediaFormat = ApiMediaFormat.BlobUrl>(
}, STREAMING_TIMEOUT); }, STREAMING_TIMEOUT);
} }
} }
}, [noLoad, mediaHash, mediaData, mediaFormat, cacheBuster, forceUpdate, isStreaming, delay, handleProgress]); }, [
noLoad, mediaHash, mediaData, mediaFormat, cacheBuster, forceUpdate, isStreaming, delay, handleProgress,
isHtmlAllowed,
]);
useEffect(() => { useEffect(() => {
if (noLoad && startedAtRef.current) { if (noLoad && startedAtRef.current) {

View File

@ -7,7 +7,9 @@ export enum Type {
Json, 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) { if (!cacheApi) {
return undefined; return undefined;
} }
@ -36,10 +38,15 @@ export async function fetch(cacheName: string, key: string, type: Type) {
if (!blob.type) { if (!blob.type) {
const contentType = response.headers.get('Content-Type'); const contentType = response.headers.get('Content-Type');
if (contentType) { 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; return blob;
} }
case Type.Json: case Type.Json:

View File

@ -7,11 +7,11 @@ import {
} from '../api/types'; } from '../api/types';
import { 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'; } from '../config';
import { callApi, cancelApiProgress } from '../api/gramjs'; import { callApi, cancelApiProgress } from '../api/gramjs';
import * as cacheApi from './cacheApi'; 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 { IS_OPUS_SUPPORTED, IS_PROGRESSIVE_SUPPORTED, isWebpSupported } from './environment';
import { oggToWav } from './oggToWav'; import { oggToWav } from './oggToWav';
import { webpToPng } from './webpToPng'; import { webpToPng } from './webpToPng';
@ -30,18 +30,18 @@ const memoryCache = new Map<string, ApiPreparedMedia>();
const fetchPromises = new Map<string, Promise<ApiPreparedMedia | undefined>>(); const fetchPromises = new Map<string, Promise<ApiPreparedMedia | undefined>>();
export function fetch<T extends ApiMediaFormat>( export function fetch<T extends ApiMediaFormat>(
url: string, mediaFormat: T, onProgress?: ApiOnProgress, url: string, mediaFormat: T, isHtmlAllowed = false, onProgress?: ApiOnProgress,
): Promise<ApiMediaFormatToPrepared<T>> { ): Promise<ApiMediaFormatToPrepared<T>> {
if (mediaFormat === ApiMediaFormat.Progressive) { if (mediaFormat === ApiMediaFormat.Progressive) {
return ( return (
IS_PROGRESSIVE_SUPPORTED IS_PROGRESSIVE_SUPPORTED
? getProgressive(url) ? getProgressive(url)
: fetch(url, ApiMediaFormat.BlobUrl, onProgress) : fetch(url, ApiMediaFormat.BlobUrl, isHtmlAllowed, onProgress)
) as Promise<ApiMediaFormatToPrepared<T>>; ) as Promise<ApiMediaFormatToPrepared<T>>;
} }
if (!fetchPromises.has(url)) { if (!fetchPromises.has(url)) {
const promise = fetchFromCacheOrRemote(url, mediaFormat, onProgress) const promise = fetchFromCacheOrRemote(url, mediaFormat, isHtmlAllowed, onProgress)
.catch((err) => { .catch((err) => {
if (DEBUG) { if (DEBUG) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -76,10 +76,12 @@ function getProgressive(url: string) {
return Promise.resolve(progressiveUrl); 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) { if (!MEDIA_CACHE_DISABLED) {
const cacheName = url.startsWith('avatar') ? MEDIA_CACHE_NAME_AVATARS : MEDIA_CACHE_NAME; 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) { if (cached) {
let media = cached; let media = cached;
@ -136,7 +138,7 @@ async function fetchFromCacheOrRemote(url: string, mediaFormat: ApiMediaFormat,
return streamUrl; return streamUrl;
} }
const remote = await callApi('downloadMedia', { url, mediaFormat }, onProgress); const remote = await callApi('downloadMedia', { url, mediaFormat, isHtmlAllowed }, onProgress);
if (!remote) { if (!remote) {
throw new Error('Failed to fetch media'); throw new Error('Failed to fetch media');
} }
@ -167,11 +169,6 @@ async function fetchFromCacheOrRemote(url: string, mediaFormat: ApiMediaFormat,
function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia { function prepareMedia(mediaData: ApiParsedMedia): ApiPreparedMedia {
if (mediaData instanceof Blob) { 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); return URL.createObjectURL(mediaData);
} }