General: Better validation for HTML attributes (#6892)

This commit is contained in:
zubiden 2026-04-27 14:29:17 +02:00 committed by Alexander Zinchuk
parent 729ee8646e
commit d2edb93340
7 changed files with 99 additions and 48 deletions

View File

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

View File

@ -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 &nbsp and styles
let fragment = parseHtmlBody(html.replace(/\u00a0/g, ' ').replace(STYLE_TAG_REGEX, '')); // Strip &nbsp 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('"', '&quot;')
.replaceAll('\'', '&#39;');
}

View File

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

View File

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

View File

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

View File

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

View File

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