Proper fix for HTML-in-video attacks (#1387)
This commit is contained in:
parent
262bf7982d
commit
9cc050b766
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user