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 { ApiMessageEntityTypes } from '../../../api/types';
|
||||
|
||||
import { ensureProtocol } from '../../../util/browser/url';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { copyTextToClipboard } from '../../../util/clipboard';
|
||||
import { buildFormattedDateHtml } from '../../../util/dates/formattedDate';
|
||||
import { escapeHtmlAttribute } from '../../middle/composer/helpers/cleanHtml';
|
||||
import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji';
|
||||
import renderText from './renderText';
|
||||
|
||||
@ -753,7 +755,7 @@ function processEntityAsHtml(
|
||||
case ApiMessageEntityTypes.TextUrl:
|
||||
return `<a
|
||||
class="text-entity-link"
|
||||
href="${getLinkUrl(rawEntityText, entity)}"
|
||||
href="${getHtmlLinkUrl(rawEntityText, entity)}"
|
||||
data-entity-type="${entity.type}"
|
||||
dir="auto"
|
||||
>${renderedContent}</a>`;
|
||||
@ -781,6 +783,10 @@ function getLinkUrl(entityContent: string, entity: ApiMessageEntity) {
|
||||
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>) {
|
||||
getActions().sendBotCommand({ command: e.currentTarget.innerText });
|
||||
}
|
||||
|
||||
@ -2,12 +2,32 @@ import { ApiMessageEntityTypes } from '../../../../api/types';
|
||||
|
||||
import { DEBUG } from '../../../../config';
|
||||
import cleanDocsHtml from '../../../../lib/cleanDocsHtml';
|
||||
import { ENTITY_CLASS_BY_NODE_NAME } from '../../../../util/parseHtmlAsFormattedText';
|
||||
|
||||
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) {
|
||||
let fragment = document.createElement('div');
|
||||
try {
|
||||
html = cleanDocsHtml(html);
|
||||
} catch (err) {
|
||||
@ -16,7 +36,7 @@ export function preparePastedHtml(html: string) {
|
||||
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');
|
||||
if (textContents.length) {
|
||||
@ -63,3 +83,9 @@ export function escapeHtml(html: string) {
|
||||
fragment.appendChild(text);
|
||||
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',
|
||||
);
|
||||
|
||||
return `<img
|
||||
class="${className}"
|
||||
draggable="false"
|
||||
alt="${emoji.emoji}"
|
||||
data-document-id="${emoji.id}"
|
||||
${uniqueId ? `data-unique-id="${uniqueId}"` : ''}
|
||||
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
|
||||
src="${src}"
|
||||
/>`;
|
||||
return buildCustomEmojiElementHtml(className, emoji.emoji, emoji.id, src, uniqueId);
|
||||
}
|
||||
|
||||
export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) {
|
||||
@ -41,15 +33,7 @@ export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessa
|
||||
customEmoji?.shouldUseTextColor && 'colorable',
|
||||
);
|
||||
|
||||
return `<img
|
||||
class="${className}"
|
||||
draggable="false"
|
||||
alt="${rawText}"
|
||||
data-document-id="${entity.documentId}"
|
||||
${uniqueId ? `data-unique-id="${uniqueId}"` : ''}
|
||||
data-entity-type="${ApiMessageEntityTypes.CustomEmoji}"
|
||||
src="${src}"
|
||||
/>`;
|
||||
return buildCustomEmojiElementHtml(className, rawText, entity.documentId, src, uniqueId);
|
||||
}
|
||||
|
||||
export function getCustomEmojiSize(maxEmojisInLine?: number): number | undefined {
|
||||
@ -60,3 +44,24 @@ export function getCustomEmojiSize(maxEmojisInLine?: number): number | undefined
|
||||
if (maxEmojisInLine === 1) return 7 * 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 { pickTruthy, unique } from '../../../../util/iteratees';
|
||||
import { getCaretPosition, getHtmlBeforeSelection, setCaretPosition } from '../../../../util/selection';
|
||||
import { escapeHtml } from '../helpers/cleanHtml';
|
||||
import { prepareForRegExp } from '../helpers/prepareForRegExp';
|
||||
|
||||
import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
|
||||
@ -109,14 +110,14 @@ export default function useMentionTooltip(
|
||||
const mainUsername = getMainUsername(peer);
|
||||
const userFirstOrLastName = getPeerTitle(lang, peer) || '';
|
||||
const htmlToInsert = mainUsername
|
||||
? `@${mainUsername}`
|
||||
? `@${escapeHtml(mainUsername)}`
|
||||
: `<a
|
||||
class="text-entity-link"
|
||||
data-entity-type="${ApiMessageEntityTypes.MentionName}"
|
||||
data-user-id="${peer.id}"
|
||||
contenteditable="false"
|
||||
dir="auto"
|
||||
>${userFirstOrLastName}</a>`;
|
||||
>${escapeHtml(userFirstOrLastName)}</a>`;
|
||||
|
||||
const inputEl = inputRef.current!;
|
||||
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_MENTION_TEMPLATE = '(@[\\w\\d_-]+)';
|
||||
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 TME_LINK_PREFIX = 'https://t.me/';
|
||||
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 = matchedFilename ? `filename="${decodeURIComponent(matchedFilename[1])}"` : '';
|
||||
const filenameHeader = buildContentDispositionFilenameHeader(new URL(url).searchParams.get('filename') || undefined);
|
||||
const { fullSize, mimeType } = partInfo;
|
||||
|
||||
const headers: [string, string][] = [
|
||||
['Content-Length', String(fullSize)],
|
||||
['Content-Type', mimeType],
|
||||
['Content-Disposition', `attachment; ${filenameHeader}`],
|
||||
['Content-Disposition', `attachment${filenameHeader ? `; ${filenameHeader}` : ''}`],
|
||||
];
|
||||
|
||||
const queue = new FilePartQueue<ArrayBuffer | undefined>();
|
||||
@ -99,3 +98,28 @@ export async function respondForDownload(e: FetchEvent) {
|
||||
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 { 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';
|
||||
|
||||
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;
|
||||
|
||||
export default function parseHtmlAsFormattedText(
|
||||
html: string, withMarkdownLinks = false, skipMarkdown = false,
|
||||
): ApiFormattedText {
|
||||
const fragment = document.createElement('div');
|
||||
fragment.innerHTML = skipMarkdown ? html
|
||||
: withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html);
|
||||
const fragment = parseHtmlBody(skipMarkdown ? html
|
||||
: withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html));
|
||||
fixImageContent(fragment);
|
||||
const text = fragment.innerText.trim().replace(/\u200b+/g, '');
|
||||
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) => {
|
||||
if (node.dataset.documentId) { // Custom Emoji
|
||||
node.textContent = (node).alt || '';
|
||||
@ -137,7 +126,7 @@ function parseMarkdown(html: string) {
|
||||
function parseMarkdownLinks(html: string) {
|
||||
return html.replace(new RegExp(`\\[([^\\]]+?)]\\((${RE_LINK_TEMPLATE}+?)\\)`, 'g'), (_, text, 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