General: Better validation for HTML attributes (#6892)
This commit is contained in:
parent
729ee8646e
commit
d2edb93340
@ -7,9 +7,11 @@ import type { TextPart, ThreadId } from '../../../types';
|
|||||||
import type { TextFilter } from './renderText';
|
import type { TextFilter } from './renderText';
|
||||||
import { ApiMessageEntityTypes } from '../../../api/types';
|
import { ApiMessageEntityTypes } from '../../../api/types';
|
||||||
|
|
||||||
|
import { ensureProtocol } from '../../../util/browser/url';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
import { copyTextToClipboard } from '../../../util/clipboard';
|
import { copyTextToClipboard } from '../../../util/clipboard';
|
||||||
import { buildFormattedDateHtml } from '../../../util/dates/formattedDate';
|
import { buildFormattedDateHtml } from '../../../util/dates/formattedDate';
|
||||||
|
import { escapeHtmlAttribute } from '../../middle/composer/helpers/cleanHtml';
|
||||||
import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji';
|
import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji';
|
||||||
import renderText from './renderText';
|
import renderText from './renderText';
|
||||||
|
|
||||||
@ -753,7 +755,7 @@ function processEntityAsHtml(
|
|||||||
case ApiMessageEntityTypes.TextUrl:
|
case ApiMessageEntityTypes.TextUrl:
|
||||||
return `<a
|
return `<a
|
||||||
class="text-entity-link"
|
class="text-entity-link"
|
||||||
href="${getLinkUrl(rawEntityText, entity)}"
|
href="${getHtmlLinkUrl(rawEntityText, entity)}"
|
||||||
data-entity-type="${entity.type}"
|
data-entity-type="${entity.type}"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
>${renderedContent}</a>`;
|
>${renderedContent}</a>`;
|
||||||
@ -781,6 +783,10 @@ function getLinkUrl(entityContent: string, entity: ApiMessageEntity) {
|
|||||||
return type === ApiMessageEntityTypes.TextUrl && entity.url ? entity.url : entityContent;
|
return type === ApiMessageEntityTypes.TextUrl && entity.url ? entity.url : entityContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHtmlLinkUrl(entityContent: string, entity: ApiMessageEntity) {
|
||||||
|
return escapeHtmlAttribute(ensureProtocol(getLinkUrl(entityContent, entity)));
|
||||||
|
}
|
||||||
|
|
||||||
function handleBotCommandClick(e: React.MouseEvent<HTMLAnchorElement>) {
|
function handleBotCommandClick(e: React.MouseEvent<HTMLAnchorElement>) {
|
||||||
getActions().sendBotCommand({ command: e.currentTarget.innerText });
|
getActions().sendBotCommand({ command: e.currentTarget.innerText });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,32 @@ import { ApiMessageEntityTypes } from '../../../../api/types';
|
|||||||
|
|
||||||
import { DEBUG } from '../../../../config';
|
import { DEBUG } from '../../../../config';
|
||||||
import cleanDocsHtml from '../../../../lib/cleanDocsHtml';
|
import cleanDocsHtml from '../../../../lib/cleanDocsHtml';
|
||||||
import { ENTITY_CLASS_BY_NODE_NAME } from '../../../../util/parseHtmlAsFormattedText';
|
|
||||||
|
|
||||||
const STYLE_TAG_REGEX = /<style>(.*?)<\/style>/gs;
|
const STYLE_TAG_REGEX = /<style>(.*?)<\/style>/gs;
|
||||||
|
|
||||||
|
export const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = {
|
||||||
|
B: ApiMessageEntityTypes.Bold,
|
||||||
|
STRONG: ApiMessageEntityTypes.Bold,
|
||||||
|
I: ApiMessageEntityTypes.Italic,
|
||||||
|
EM: ApiMessageEntityTypes.Italic,
|
||||||
|
INS: ApiMessageEntityTypes.Underline,
|
||||||
|
U: ApiMessageEntityTypes.Underline,
|
||||||
|
S: ApiMessageEntityTypes.Strike,
|
||||||
|
STRIKE: ApiMessageEntityTypes.Strike,
|
||||||
|
DEL: ApiMessageEntityTypes.Strike,
|
||||||
|
CODE: ApiMessageEntityTypes.Code,
|
||||||
|
PRE: ApiMessageEntityTypes.Pre,
|
||||||
|
BLOCKQUOTE: ApiMessageEntityTypes.Blockquote,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseHtmlBody(html: string): HTMLElement {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const parsedDocument = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
return parsedDocument.body;
|
||||||
|
}
|
||||||
|
|
||||||
export function preparePastedHtml(html: string) {
|
export function preparePastedHtml(html: string) {
|
||||||
let fragment = document.createElement('div');
|
|
||||||
try {
|
try {
|
||||||
html = cleanDocsHtml(html);
|
html = cleanDocsHtml(html);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -16,7 +36,7 @@ export function preparePastedHtml(html: string) {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fragment.innerHTML = html.replace(/\u00a0/g, ' ').replace(STYLE_TAG_REGEX, ''); // Strip   and styles
|
let fragment = parseHtmlBody(html.replace(/\u00a0/g, ' ').replace(STYLE_TAG_REGEX, '')); // Strip   and styles
|
||||||
|
|
||||||
const textContents = fragment.querySelectorAll<HTMLDivElement>('.text-content');
|
const textContents = fragment.querySelectorAll<HTMLDivElement>('.text-content');
|
||||||
if (textContents.length) {
|
if (textContents.length) {
|
||||||
@ -63,3 +83,9 @@ export function escapeHtml(html: string) {
|
|||||||
fragment.appendChild(text);
|
fragment.appendChild(text);
|
||||||
return fragment.innerHTML;
|
return fragment.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function escapeHtmlAttribute(html: string) {
|
||||||
|
return escapeHtml(html)
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('\'', ''');
|
||||||
|
}
|
||||||
|
|||||||
@ -18,15 +18,7 @@ export function buildCustomEmojiHtml(emoji: ApiSticker) {
|
|||||||
'custom-emoji', 'emoji', 'emoji-small', isPlaceholder && 'placeholder', emoji.shouldUseTextColor && 'colorable',
|
'custom-emoji', 'emoji', 'emoji-small', isPlaceholder && 'placeholder', emoji.shouldUseTextColor && 'colorable',
|
||||||
);
|
);
|
||||||
|
|
||||||
return `<img
|
return buildCustomEmojiElementHtml(className, emoji.emoji, emoji.id, src, uniqueId);
|
||||||
class="${className}"
|
|
||||||
draggable="false"
|
|
||||||
alt="${emoji.emoji}"
|
|
||||||
data-document-id="${emoji.id}"
|
|
||||||
${uniqueId ? `data-unique-id="${uniqueId}"` : ''}
|
|
||||||
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
|
|
||||||
src="${src}"
|
|
||||||
/>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) {
|
export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) {
|
||||||
@ -41,15 +33,7 @@ export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessa
|
|||||||
customEmoji?.shouldUseTextColor && 'colorable',
|
customEmoji?.shouldUseTextColor && 'colorable',
|
||||||
);
|
);
|
||||||
|
|
||||||
return `<img
|
return buildCustomEmojiElementHtml(className, rawText, entity.documentId, src, uniqueId);
|
||||||
class="${className}"
|
|
||||||
draggable="false"
|
|
||||||
alt="${rawText}"
|
|
||||||
data-document-id="${entity.documentId}"
|
|
||||||
${uniqueId ? `data-unique-id="${uniqueId}"` : ''}
|
|
||||||
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
|
|
||||||
src="${src}"
|
|
||||||
/>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCustomEmojiSize(maxEmojisInLine?: number): number | undefined {
|
export function getCustomEmojiSize(maxEmojisInLine?: number): number | undefined {
|
||||||
@ -60,3 +44,24 @@ export function getCustomEmojiSize(maxEmojisInLine?: number): number | undefined
|
|||||||
if (maxEmojisInLine === 1) return 7 * REM;
|
if (maxEmojisInLine === 1) return 7 * REM;
|
||||||
return Math.min(7.5 - (maxEmojisInLine * 0.75), 5.625) * REM;
|
return Math.min(7.5 - (maxEmojisInLine * 0.75), 5.625) * REM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCustomEmojiElementHtml(
|
||||||
|
className: string,
|
||||||
|
alt: string | undefined,
|
||||||
|
documentId: string,
|
||||||
|
src: string,
|
||||||
|
uniqueId?: string,
|
||||||
|
) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
|
||||||
|
img.setAttribute('class', className);
|
||||||
|
img.setAttribute('draggable', 'false');
|
||||||
|
img.setAttribute('alt', alt || '');
|
||||||
|
img.setAttribute('data-document-id', documentId);
|
||||||
|
img.setAttribute('data-entity-type', ApiMessageEntityTypes.CustomEmoji);
|
||||||
|
img.setAttribute('src', src);
|
||||||
|
|
||||||
|
if (uniqueId) img.setAttribute('data-unique-id', uniqueId);
|
||||||
|
|
||||||
|
return img.outerHTML;
|
||||||
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { filterPeersByQuery, getPeerTitle } from '../../../../global/helpers/pee
|
|||||||
import focusEditableElement from '../../../../util/focusEditableElement';
|
import focusEditableElement from '../../../../util/focusEditableElement';
|
||||||
import { pickTruthy, unique } from '../../../../util/iteratees';
|
import { pickTruthy, unique } from '../../../../util/iteratees';
|
||||||
import { getCaretPosition, getHtmlBeforeSelection, setCaretPosition } from '../../../../util/selection';
|
import { getCaretPosition, getHtmlBeforeSelection, setCaretPosition } from '../../../../util/selection';
|
||||||
|
import { escapeHtml } from '../helpers/cleanHtml';
|
||||||
import { prepareForRegExp } from '../helpers/prepareForRegExp';
|
import { prepareForRegExp } from '../helpers/prepareForRegExp';
|
||||||
|
|
||||||
import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
|
import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
|
||||||
@ -109,14 +110,14 @@ export default function useMentionTooltip(
|
|||||||
const mainUsername = getMainUsername(peer);
|
const mainUsername = getMainUsername(peer);
|
||||||
const userFirstOrLastName = getPeerTitle(lang, peer) || '';
|
const userFirstOrLastName = getPeerTitle(lang, peer) || '';
|
||||||
const htmlToInsert = mainUsername
|
const htmlToInsert = mainUsername
|
||||||
? `@${mainUsername}`
|
? `@${escapeHtml(mainUsername)}`
|
||||||
: `<a
|
: `<a
|
||||||
class="text-entity-link"
|
class="text-entity-link"
|
||||||
data-entity-type="${ApiMessageEntityTypes.MentionName}"
|
data-entity-type="${ApiMessageEntityTypes.MentionName}"
|
||||||
data-user-id="${peer.id}"
|
data-user-id="${peer.id}"
|
||||||
contenteditable="false"
|
contenteditable="false"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
>${userFirstOrLastName}</a>`;
|
>${escapeHtml(userFirstOrLastName)}</a>`;
|
||||||
|
|
||||||
const inputEl = inputRef.current!;
|
const inputEl = inputRef.current!;
|
||||||
const htmlBeforeSelection = getHtmlBeforeSelection(inputEl);
|
const htmlBeforeSelection = getHtmlBeforeSelection(inputEl);
|
||||||
|
|||||||
@ -320,7 +320,7 @@ export const SUPPORTED_TRANSLATION_LANGUAGES = [
|
|||||||
export const RE_LINK_TEMPLATE = '((ftp|https?):\\/\\/)?((www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z][-a-zA-Z0-9]{1,62})\\b([-a-zA-Z0-9()@:%_+.,~#?&/=]*)';
|
export const RE_LINK_TEMPLATE = '((ftp|https?):\\/\\/)?((www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z][-a-zA-Z0-9]{1,62})\\b([-a-zA-Z0-9()@:%_+.,~#?&/=]*)';
|
||||||
export const RE_MENTION_TEMPLATE = '(@[\\w\\d_-]+)';
|
export const RE_MENTION_TEMPLATE = '(@[\\w\\d_-]+)';
|
||||||
export const RE_TG_LINK = /^tg:(\/\/)?/i;
|
export const RE_TG_LINK = /^tg:(\/\/)?/i;
|
||||||
export const RE_TME_LINK = /^(https?:\/\/)?([-a-zA-Z0-9@:%_+~#=]{1,32}\.)?t\.me/i;
|
export const RE_TME_LINK = /^(https?:\/\/)?([-a-zA-Z0-9@:%_+~#=]{1,32}\.)?t\.me(?=[:/?#]|$)/i;
|
||||||
export const RE_TELEGRAM_LINK = /^(https?:\/\/)?telegram\.org\//i;
|
export const RE_TELEGRAM_LINK = /^(https?:\/\/)?telegram\.org\//i;
|
||||||
export const TME_LINK_PREFIX = 'https://t.me/';
|
export const TME_LINK_PREFIX = 'https://t.me/';
|
||||||
export const BOT_FATHER_USERNAME = 'botfather';
|
export const BOT_FATHER_USERNAME = 'botfather';
|
||||||
|
|||||||
@ -47,14 +47,13 @@ export async function respondForDownload(e: FetchEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedFilename = e.request.url.match(/filename=(.*)/);
|
const filenameHeader = buildContentDispositionFilenameHeader(new URL(url).searchParams.get('filename') || undefined);
|
||||||
const filenameHeader = matchedFilename ? `filename="${decodeURIComponent(matchedFilename[1])}"` : '';
|
|
||||||
const { fullSize, mimeType } = partInfo;
|
const { fullSize, mimeType } = partInfo;
|
||||||
|
|
||||||
const headers: [string, string][] = [
|
const headers: [string, string][] = [
|
||||||
['Content-Length', String(fullSize)],
|
['Content-Length', String(fullSize)],
|
||||||
['Content-Type', mimeType],
|
['Content-Type', mimeType],
|
||||||
['Content-Disposition', `attachment; ${filenameHeader}`],
|
['Content-Disposition', `attachment${filenameHeader ? `; ${filenameHeader}` : ''}`],
|
||||||
];
|
];
|
||||||
|
|
||||||
const queue = new FilePartQueue<ArrayBuffer | undefined>();
|
const queue = new FilePartQueue<ArrayBuffer | undefined>();
|
||||||
@ -99,3 +98,28 @@ export async function respondForDownload(e: FetchEvent) {
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildContentDispositionFilenameHeader(filename?: string) {
|
||||||
|
if (!filename) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedFilename = Array.from(filename)
|
||||||
|
.filter((char) => {
|
||||||
|
const charCode = char.charCodeAt(0);
|
||||||
|
return charCode >= 0x20 && charCode !== 0x7F;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
if (!sanitizedFilename) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedFilename = encodeURIComponent(sanitizedFilename)
|
||||||
|
.replaceAll('\'', '%27')
|
||||||
|
.replaceAll('(', '%28')
|
||||||
|
.replaceAll(')', '%29')
|
||||||
|
.replaceAll('*', '%2A');
|
||||||
|
|
||||||
|
return `filename*=UTF-8''${encodedFilename}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,31 +2,20 @@ import type { ApiFormattedText, ApiMessageEntity } from '../api/types';
|
|||||||
import { ApiMessageEntityTypes } from '../api/types';
|
import { ApiMessageEntityTypes } from '../api/types';
|
||||||
|
|
||||||
import { RE_LINK_TEMPLATE } from '../config';
|
import { RE_LINK_TEMPLATE } from '../config';
|
||||||
|
import {
|
||||||
|
ENTITY_CLASS_BY_NODE_NAME,
|
||||||
|
escapeHtmlAttribute,
|
||||||
|
parseHtmlBody,
|
||||||
|
} from '../components/middle/composer/helpers/cleanHtml';
|
||||||
import { IS_EMOJI_SUPPORTED } from './browser/windowEnvironment';
|
import { IS_EMOJI_SUPPORTED } from './browser/windowEnvironment';
|
||||||
|
|
||||||
export const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = {
|
|
||||||
B: ApiMessageEntityTypes.Bold,
|
|
||||||
STRONG: ApiMessageEntityTypes.Bold,
|
|
||||||
I: ApiMessageEntityTypes.Italic,
|
|
||||||
EM: ApiMessageEntityTypes.Italic,
|
|
||||||
INS: ApiMessageEntityTypes.Underline,
|
|
||||||
U: ApiMessageEntityTypes.Underline,
|
|
||||||
S: ApiMessageEntityTypes.Strike,
|
|
||||||
STRIKE: ApiMessageEntityTypes.Strike,
|
|
||||||
DEL: ApiMessageEntityTypes.Strike,
|
|
||||||
CODE: ApiMessageEntityTypes.Code,
|
|
||||||
PRE: ApiMessageEntityTypes.Pre,
|
|
||||||
BLOCKQUOTE: ApiMessageEntityTypes.Blockquote,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_TAG_DEEPNESS = 3;
|
const MAX_TAG_DEEPNESS = 3;
|
||||||
|
|
||||||
export default function parseHtmlAsFormattedText(
|
export default function parseHtmlAsFormattedText(
|
||||||
html: string, withMarkdownLinks = false, skipMarkdown = false,
|
html: string, withMarkdownLinks = false, skipMarkdown = false,
|
||||||
): ApiFormattedText {
|
): ApiFormattedText {
|
||||||
const fragment = document.createElement('div');
|
const fragment = parseHtmlBody(skipMarkdown ? html
|
||||||
fragment.innerHTML = skipMarkdown ? html
|
: withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html));
|
||||||
: withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html);
|
|
||||||
fixImageContent(fragment);
|
fixImageContent(fragment);
|
||||||
const text = fragment.innerText.trim().replace(/\u200b+/g, '');
|
const text = fragment.innerText.trim().replace(/\u200b+/g, '');
|
||||||
const trimShift = fragment.innerText.indexOf(text[0]);
|
const trimShift = fragment.innerText.indexOf(text[0]);
|
||||||
@ -66,7 +55,7 @@ export default function parseHtmlAsFormattedText(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fixImageContent(fragment: HTMLDivElement) {
|
export function fixImageContent(fragment: HTMLElement) {
|
||||||
fragment.querySelectorAll('img').forEach((node) => {
|
fragment.querySelectorAll('img').forEach((node) => {
|
||||||
if (node.dataset.documentId) { // Custom Emoji
|
if (node.dataset.documentId) { // Custom Emoji
|
||||||
node.textContent = (node).alt || '';
|
node.textContent = (node).alt || '';
|
||||||
@ -137,7 +126,7 @@ function parseMarkdown(html: string) {
|
|||||||
function parseMarkdownLinks(html: string) {
|
function parseMarkdownLinks(html: string) {
|
||||||
return html.replace(new RegExp(`\\[([^\\]]+?)]\\((${RE_LINK_TEMPLATE}+?)\\)`, 'g'), (_, text, link) => {
|
return html.replace(new RegExp(`\\[([^\\]]+?)]\\((${RE_LINK_TEMPLATE}+?)\\)`, 'g'), (_, text, link) => {
|
||||||
const url = link.includes('://') ? link : link.includes('@') ? `mailto:${link}` : `https://${link}`;
|
const url = link.includes('://') ? link : link.includes('@') ? `mailto:${link}` : `https://${link}`;
|
||||||
return `<a href="${url}">${text}</a>`;
|
return `<a href="${escapeHtmlAttribute(url)}">${text}</a>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user